feat: MY Page, 게시판, Favicon 기능 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-10 18:38:21 +09:00
parent 2ef0e47693
commit 1a6e9b6327
2 changed files with 646 additions and 381 deletions

View File

@@ -393,3 +393,111 @@ def update_user_pages(user_id: int, data: AccessUpdate, token=Depends(require_ad
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
# ─── 게시판 ──────────────────────────────────────────────
class PostCreate(BaseModel):
title: str
content: str
class ReplyCreate(BaseModel):
content: str
def init_board_db():
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS board_posts (
id SERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS board_replies (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES board_posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
cur.close()
conn.close()
@app.on_event("startup")
def startup_board():
import time
for _ in range(10):
try:
init_board_db()
print("Board DB initialized")
break
except Exception as e:
print(f"Board DB not ready... {e}")
time.sleep(3)
@app.get("/api/board/posts")
def list_posts(token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT id, title, author_name, created_at FROM board_posts ORDER BY created_at DESC")
return cur.fetchall()
@app.post("/api/board/posts")
def create_post(data: PostCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO board_posts (title, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(data.title, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.get("/api/board/posts/{post_id}")
def get_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
cur.execute("SELECT * FROM board_replies WHERE post_id = %s ORDER BY created_at ASC", (post_id,))
replies = cur.fetchall()
return {"post": post, "replies": replies}
@app.delete("/api/board/posts/{post_id}")
def delete_post(post_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_posts WHERE id = %s", (post_id,))
post = cur.fetchone()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not token.get("is_admin") and post["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM board_posts WHERE id = %s", (post_id,))
conn.commit()
return {"ok": True}
@app.post("/api/board/posts/{post_id}/replies")
def create_reply(post_id: int, data: ReplyCreate, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"INSERT INTO board_replies (post_id, content, author_id, author_name) VALUES (%s, %s, %s, %s) RETURNING *",
(post_id, data.content, int(token["sub"]), token["username"])
)
conn.commit()
return cur.fetchone()
@app.delete("/api/board/replies/{reply_id}")
def delete_reply(reply_id: int, token=Depends(verify_token), conn=Depends(get_db)):
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM board_replies WHERE id = %s", (reply_id,))
reply = cur.fetchone()
if not reply:
raise HTTPException(status_code=404, detail="Reply not found")
if not token.get("is_admin") and reply["author_id"] != int(token["sub"]):
raise HTTPException(status_code=403, detail="Permission denied")
cur.execute("DELETE FROM board_replies WHERE id = %s", (reply_id,))
conn.commit()
return {"ok": True}

View File

@@ -6,91 +6,131 @@
<title>Web Portal</title> <title>Web Portal</title>
<style> <style>
:root { :root {
--primary: #2563eb; --primary-dark: #1d4ed8; --primary:#2563eb;--primary-dark:#1d4ed8;--bg:#f1f5f9;--card:#fff;
--bg: #f1f5f9; --card: #ffffff; --text: #1e293b; --text:#1e293b;--muted:#64748b;--danger:#ef4444;--success:#22c55e;
--muted: #64748b; --danger: #ef4444; --success: #22c55e;
--warning:#f59e0b;--border:#e2e8f0;--admin:#7c3aed; --warning:#f59e0b;--border:#e2e8f0;--admin:#7c3aed;
} }
*{box-sizing:border-box;margin:0;padding:0;} *{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;} body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;}
/* ── Login ── */ /* Login */
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); } #login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
.login-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); } .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{text-align:center;margin-bottom:32px;}
.login-logo svg { width: 56px; height: 56px; }
.login-logo h1{font-size:1.5rem;font-weight:700;color:var(--primary);margin-top:12px;} .login-logo h1{font-size:1.5rem;font-weight:700;color:var(--primary);margin-top:12px;}
.login-logo p { color: var(--muted); font-size: 0.875rem; margin-top: 4px; } .login-logo p{color:var(--muted);font-size:.875rem;margin-top:4px;}
.form-group{margin-bottom:18px;} .form-group{margin-bottom:18px;}
.form-group label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 6px; } .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: 0.95rem; transition: border-color .2s; } .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);} .form-group input:focus{outline:none;border-color:var(--primary);}
.btn { width: 100%; padding: 12px; background: var(--primary); color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background .2s; } .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:hover{background:var(--primary-dark);}
.btn-sm { width: auto; padding: 7px 16px; font-size: 0.8rem; border-radius: 6px; } .btn-sm{width:auto;padding:7px 16px;font-size:.8rem;border-radius:6px;}
.btn-danger{background:var(--danger);}.btn-danger:hover{background:#dc2626;} .btn-danger{background:var(--danger);}.btn-danger:hover{background:#dc2626;}
.btn-success{background:var(--success);}.btn-success:hover{background:#16a34a;} .btn-success{background:var(--success);}.btn-success:hover{background:#16a34a;}
.btn-warning { background: var(--warning); color: white; } .btn-warning:hover { background: #d97706; } .btn-warning{background:var(--warning);color:#fff;}.btn-warning:hover{background:#d97706;}
.btn-gray{background:#94a3b8;}.btn-gray:hover{background:#64748b;} .btn-gray{background:#94a3b8;}.btn-gray:hover{background:#64748b;}
.btn-purple{background:var(--admin);}.btn-purple:hover{background:#6d28d9;} .btn-purple{background:var(--admin);}.btn-purple:hover{background:#6d28d9;}
.error-msg { color: var(--danger); font-size: 0.85rem; margin-top: 10px; text-align: center; } .btn-outline{background:#fff;color:var(--primary);border:2px solid var(--primary);}.btn-outline:hover{background:var(--primary);color:#fff;}
.warning-msg { color: var(--warning); font-size: 0.85rem; margin-top: 10px; text-align: center; } .error-msg{color:var(--danger);font-size:.85rem;margin-top:10px;text-align:center;}
/* ── Force Change Password Page ── */ /* Force PW */
#change-pw-page { display: none; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%); } #change-pw-page{display:none;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
.change-pw-box { background: white; border-radius: 16px; padding: 48px 40px; width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); } .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; color: var(--text); } .change-pw-box h2{font-size:1.3rem;font-weight:700;margin-bottom:8px;}
.change-pw-box p { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; line-height: 1.5; } .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: 0.85rem; color: #92400e; } .notice-box{background:#fef3c7;border:1px solid #fbbf24;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:.85rem;color:#92400e;}
/* ── App Layout ── */ /* App */
#app-page{display:none;min-height:100vh;flex-direction:column;} #app-page{display:none;min-height:100vh;flex-direction:column;}
header { background: white; 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,0.06); } 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-left{display:flex;align-items:center;gap:12px;}
.header-logo{font-weight:700;font-size:1.1rem;color:var(--primary);} .header-logo{font-weight:700;font-size:1.1rem;color:var(--primary);}
.badge-admin { background: var(--admin); color: white; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; } .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: 14px; } .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: 0.875rem; font-weight: 500; } .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: white; border-bottom: 1px solid var(--border); padding: 0 32px; display: flex; gap: 4px; } 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: 0.9rem; color: var(--muted); font-weight: 500; border-bottom: 3px solid transparent; transition: all .2s; } .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.active{color:var(--primary);border-bottom-color:var(--primary);}
.nav-btn:hover:not(.active){color:var(--text);} .nav-btn:hover:not(.active){color:var(--text);}
main{padding:32px;flex:1;} main{padding:32px;flex:1;}
.page { display: none; } .page{display:none;}.page.active{display:block;}
.page.active { display: block; }
/* ── Cards ── */ /* Cards */
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;} .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 h2{font-size:1.3rem;font-weight:700;}
.page-header p { color: var(--muted); font-size: 0.875rem; margin-top: 2px; } .page-header p{color:var(--muted);font-size:.875rem;margin-top:2px;}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:20px;}
.card { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,0.07); border: 1px solid var(--border); transition: transform .15s, box-shadow .15s; cursor: pointer; text-decoration: none; color: inherit; } .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,0.1); } .card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.1);}
.card-icon { width: 48px; height: 48px; border-radius: 10px; background: linear-gradient(135deg, var(--primary), #60a5fa); display: flex; align-items: center; justify-content: center; margin-bottom: 14px; } .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 svg { color: white; } .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 h3{font-size:1rem;font-weight:600;margin-bottom:6px;}
.card p { font-size: 0.825rem; color: var(--muted); line-height: 1.5; } .card p{font-size:.825rem;color:var(--muted);line-height:1.5;}
.card-url { font-size: 0.75rem; color: var(--primary); margin-top: 10px; word-break: break-all; }
.empty-state{text-align:center;padding:80px 20px;color:var(--muted);} .empty-state{text-align:center;padding:80px 20px;color:var(--muted);}
/* ── Table ── */ /* Table */
.table-wrapper { background: white; border-radius: 12px; border: 1px solid var(--border); overflow: hidden; } .table-wrapper{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden;}
table{width:100%;border-collapse:collapse;} table{width:100%;border-collapse:collapse;}
th { background: var(--bg); padding: 12px 16px; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; } 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: 0.875rem; border-top: 1px solid var(--border); vertical-align: middle; } td{padding:13px 16px;font-size:.875rem;border-top:1px solid var(--border);vertical-align:middle;}
tr:hover td{background:#f8fafc;} tr:hover td{background:#f8fafc;}
.actions{display:flex;gap:6px;flex-wrap:wrap;} .actions{display:flex;gap:6px;flex-wrap:wrap;}
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; } .tag{display:inline-block;padding:2px 10px;border-radius:20px;font-size:.75rem;font-weight:600;}
.tag-admin{background:#ede9fe;color:var(--admin);} .tag-admin{background:#ede9fe;color:var(--admin);}
.tag-user{background:#dbeafe;color:var(--primary);} .tag-user{background:#dbeafe;color:var(--primary);}
.tag-locked{background:#fee2e2;color:var(--danger);} .tag-locked{background:#fee2e2;color:var(--danger);}
.tag-warning{background:#fef3c7;color:#92400e;} .tag-warning{background:#fef3c7;color:#92400e;}
.tag-ok{background:#dcfce7;color:#166534;} .tag-ok{background:#dcfce7;color:#166534;}
/* ── Modal ── */ /* Board */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; align-items: center; justify-content: center; } .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-overlay.open{display:flex;}
.modal { background: white; border-radius: 16px; padding: 32px; width: 480px; max-width: 95vw; box-shadow: 0 30px 80px rgba(0,0,0,0.25); max-height: 90vh; overflow-y: auto; } .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 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{display:flex;gap:10px;margin-top:24px;justify-content:flex-end;}
.modal-footer .btn{width:auto;} .modal-footer .btn{width:auto;}
@@ -100,38 +140,31 @@
.checkbox-item:hover{background:var(--bg);} .checkbox-item:hover{background:var(--bg);}
.checkbox-item input[type=checkbox]{width:16px;height:16px;accent-color:var(--primary);} .checkbox-item input[type=checkbox]{width:16px;height:16px;accent-color:var(--primary);}
.checkbox-item .item-info{flex:1;} .checkbox-item .item-info{flex:1;}
.checkbox-item .item-info strong { font-size: 0.875rem; display: block; } .checkbox-item .item-info strong{font-size:.875rem;display:block;}
.checkbox-item .item-info span { font-size: 0.75rem; color: var(--muted); } .checkbox-item .item-info span{font-size:.75rem;color:var(--muted);}
/* ── Temp PW Result ── */
.temp-pw-box{background:#f0fdf4;border:2px solid var(--success);border-radius:10px;padding:20px;margin-top:16px;text-align:center;} .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: 0.85rem; color: var(--muted); margin-bottom: 8px; } .temp-pw-box p{font-size:.85rem;color:var(--muted);margin-bottom:8px;}
.temp-pw-box strong { font-size: 1.4rem; font-family: monospace; color: var(--text); letter-spacing: 2px; } .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: 0.75rem; color: var(--danger); } .temp-pw-box small{display:block;margin-top:8px;font-size:.75rem;color:var(--danger);}
</style> </style>
</head> </head>
<body> <body>
<!-- ══════════ LOGIN ══════════ --> <!-- LOGIN -->
<div id="login-page"> <div id="login-page">
<div class="login-box"> <div class="login-box">
<div class="login-logo"> <div class="login-logo">
<svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg"> <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>
<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> <h1>Web Portal</h1>
<p>조직 내부 웹페이지 통합 포털</p> <p>조직 내부 웹페이지 통합 포털</p>
</div> </div>
<div class="form-group"><label>아이디</label><input type="text" id="login-user" placeholder="사용자명 입력"/></div> <div class="form-group"><label>아이디</label><input type="text" id="login-user" placeholder="사용자명 입력"/></div>
<div class="form-group"> <div class="form-group">
<label>비밀번호</label> <label>비밀번호</label>
<div style="position:relative"> <div class="pw-wrap">
<input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()" style="width:100%;padding-right:44px"/> <input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()"/>
<button type="button" onclick="togglePw('login-pass','toggle-eye')" style="position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);padding:0;width:auto"> <button type="button" class="pw-toggle" onclick="togglePw('login-pass','eye-login')">
<svg id="toggle-eye" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <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>
<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> </button>
</div> </div>
</div> </div>
@@ -140,7 +173,7 @@
</div> </div>
</div> </div>
<!-- ══════════ 강제 비밀번호 변경 ══════════ --> <!-- FORCE CHANGE PW -->
<div id="change-pw-page"> <div id="change-pw-page">
<div class="change-pw-box"> <div class="change-pw-box">
<h2>🔒 비밀번호 변경 필요</h2> <h2>🔒 비밀번호 변경 필요</h2>
@@ -154,7 +187,7 @@
</div> </div>
</div> </div>
<!-- ══════════ APP ══════════ --> <!-- APP -->
<div id="app-page"> <div id="app-page">
<header> <header>
<div class="header-left"> <div class="header-left">
@@ -166,26 +199,28 @@
<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> <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> <span id="header-username"></span>
</div> </div>
<button class="btn btn-sm btn-gray" onclick="showPage('my-password')">🔑 비밀번호 변경</button> <button class="btn btn-sm btn-outline" onclick="showPage('my-password')">🔑 비밀번호 변경</button>
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button> <button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
</div> </div>
</header> </header>
<nav> <nav id="main-nav">
<button class="nav-btn active" onclick="showPage('my-pages')">🏠 내 페이지</button> <button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">📋 페이지 관리</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-users')" style="display:none">👥 사용자 관리</button>
</nav> </nav>
<main> <main>
<!-- My Pages -->
<!-- MY Page -->
<div id="page-my-pages" class="page active"> <div id="page-my-pages" class="page active">
<div class="page-header"><div><h2>내 페이지 목록</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div></div> <div class="page-header"><div><h2>MY Page List</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div></div>
<div id="my-pages-grid" class="grid"></div> <div id="my-pages-grid" class="grid"></div>
</div> </div>
<!-- My Password --> <!-- My Password -->
<div id="page-my-password" class="page"> <div id="page-my-password" class="page">
<div class="page-header"><div><h2>비밀번호 변경</h2><p>현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p></div></div> <div class="page-header"><div><h2>비밀번호 변경</h2><p>현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p></div></div>
<div style="background:white;border-radius:12px;padding:32px;max-width:440px;border:1px solid var(--border)"> <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-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-new-pw" placeholder="6자 이상 입력"/></div>
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="my-confirm-pw" placeholder="새 비밀번호 재입력"/></div> <div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="my-confirm-pw" placeholder="새 비밀번호 재입력"/></div>
@@ -194,6 +229,59 @@
</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 --> <!-- Admin: Pages -->
<div id="page-admin-pages" class="page"> <div id="page-admin-pages" class="page">
<div class="page-header"> <div class="page-header">
@@ -220,8 +308,7 @@
</main> </main>
</div> </div>
<!-- ══════════ MODALS ══════════ --> <!-- MODALS -->
<!-- Page Modal -->
<div id="page-modal" class="modal-overlay"> <div id="page-modal" class="modal-overlay">
<div class="modal"> <div class="modal">
<h3 id="page-modal-title">페이지 추가</h3> <h3 id="page-modal-title">페이지 추가</h3>
@@ -234,27 +321,19 @@
</div> </div>
</div> </div>
</div> </div>
<!-- User Modal -->
<div id="user-modal" class="modal-overlay"> <div id="user-modal" class="modal-overlay">
<div class="modal"> <div class="modal">
<h3>사용자 추가</h3> <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="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>초기 비밀번호 *</label><input type="password" id="um-password" placeholder="6자 이상"/></div>
<div class="form-group"> <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>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <p style="font-size:.8rem;color:var(--muted)">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</p>
<input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)"> 관리자 권한 부여
</label>
</div>
<p style="font-size:0.8rem;color:var(--muted);margin-top:-8px">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</p>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button> <button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
<button class="btn btn-sm" onclick="saveUser()">저장</button> <button class="btn btn-sm" onclick="saveUser()">저장</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Access Modal -->
<div id="access-modal" class="modal-overlay"> <div id="access-modal" class="modal-overlay">
<div class="modal"> <div class="modal">
<h3 id="access-modal-title">접근 권한 설정</h3> <h3 id="access-modal-title">접근 권한 설정</h3>
@@ -266,22 +345,18 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Admin Change Password Modal -->
<div id="admin-pw-modal" class="modal-overlay"> <div id="admin-pw-modal" class="modal-overlay">
<div class="modal"> <div class="modal">
<h3 id="admin-pw-modal-title">비밀번호 변경</h3> <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-password" placeholder="6자 이상 입력"/></div>
<div class="form-group"><label>새 비밀번호 확인 *</label><input type="password" id="apm-confirm" placeholder="새 비밀번호 재입력"/></div> <div class="form-group"><label>새 비밀번호 확인 *</label><input type="password" id="apm-confirm" placeholder="새 비밀번호 재입력"/></div>
<p style="font-size:0.8rem;color:var(--muted)">※ 변경 후 해당 사용자는 다음 로그인 시 비밀번호 변경이 강제됩니다.</p> <p style="font-size:.8rem;color:var(--muted)">※ 변경 후 해당 사용자는 다음 로그인 시 비밀번호 변경이 강제됩니다.</p>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-sm btn-gray" onclick="closeModal('admin-pw-modal')">취소</button> <button class="btn btn-sm btn-gray" onclick="closeModal('admin-pw-modal')">취소</button>
<button class="btn btn-sm btn-warning" onclick="saveAdminPw()">변경</button> <button class="btn btn-sm btn-warning" onclick="saveAdminPw()">변경</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Reset Password Result Modal -->
<div id="reset-pw-modal" class="modal-overlay"> <div id="reset-pw-modal" class="modal-overlay">
<div class="modal"> <div class="modal">
<h3>임시 비밀번호 발급 완료</h3> <h3>임시 비밀번호 발급 완료</h3>
@@ -290,9 +365,7 @@
<strong id="temp-pw-display"></strong> <strong id="temp-pw-display"></strong>
<small>⚠️ 이 창을 닫으면 다시 확인할 수 없습니다!</small> <small>⚠️ 이 창을 닫으면 다시 확인할 수 없습니다!</small>
</div> </div>
<div class="modal-footer"> <div class="modal-footer"><button class="btn btn-sm" onclick="closeModal('reset-pw-modal');loadAdminUsers()">확인</button></div>
<button class="btn btn-sm" onclick="closeModal('reset-pw-modal');loadAdminUsers()">확인</button>
</div>
</div> </div>
</div> </div>
@@ -302,12 +375,10 @@ let token = localStorage.getItem('portal_token');
let currentUser=null; let currentUser=null;
let editingPageId=null; let editingPageId=null;
let editingUserId=null; let editingUserId=null;
let currentPostId=null;
async function apiFetch(path,opts={}){ async function apiFetch(path,opts={}){
const res = await fetch(API + path, { const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})}, ...opts});
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
...opts
});
if(res.status===401){doLogout();return;} if(res.status===401){doLogout();return;}
if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'Error');} if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'Error');}
return res.json(); return res.json();
@@ -318,9 +389,10 @@ function showPage(name) {
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active')); document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active'); document.getElementById('page-'+name).classList.add('active');
document.querySelectorAll('.nav-btn').forEach(b=>{ document.querySelectorAll('.nav-btn').forEach(b=>{
if (b.getAttribute('onclick') && b.getAttribute('onclick').includes(name)) b.classList.add('active'); if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
}); });
if(name==='my-pages')loadMyPages(); if(name==='my-pages')loadMyPages();
if(name==='board')loadBoard();
if(name==='admin-pages')loadAdminPages(); if(name==='admin-pages')loadAdminPages();
if(name==='admin-users')loadAdminUsers(); if(name==='admin-users')loadAdminUsers();
} }
@@ -328,7 +400,7 @@ function showPage(name) {
function closeModal(id){document.getElementById(id).classList.remove('open');} function closeModal(id){document.getElementById(id).classList.remove('open');}
function openModal(id){document.getElementById(id).classList.add('open');} function openModal(id){document.getElementById(id).classList.add('open');}
// ── Login / Logout ─────────────────────────────────────── // ── Login ─────────────────────────────────────────────────
async function doLogin(){ async function doLogin(){
const username=document.getElementById('login-user').value.trim(); const username=document.getElementById('login-user').value.trim();
const password=document.getElementById('login-pass').value; const password=document.getElementById('login-pass').value;
@@ -338,11 +410,8 @@ async function doLogin() {
token=data.token; token=data.token;
localStorage.setItem('portal_token',token); localStorage.setItem('portal_token',token);
currentUser={username:data.username,is_admin:data.is_admin,must_change_password:data.must_change_password}; currentUser={username:data.username,is_admin:data.is_admin,must_change_password:data.must_change_password};
if (data.must_change_password) { if(data.must_change_password)showForceChangePw();
showForceChangePw(); else showApp();
} else {
showApp();
}
}catch(e){ }catch(e){
document.getElementById('login-error').textContent=e.message; document.getElementById('login-error').textContent=e.message;
document.getElementById('login-pass').value=''; document.getElementById('login-pass').value='';
@@ -362,9 +431,6 @@ function doLogout() {
function showForceChangePw(){ function showForceChangePw(){
document.getElementById('login-page').style.display='none'; document.getElementById('login-page').style.display='none';
document.getElementById('change-pw-page').style.display='flex'; document.getElementById('change-pw-page').style.display='flex';
document.getElementById('force-current-pw').value = '';
document.getElementById('force-new-pw').value = '';
document.getElementById('force-confirm-pw').value = '';
} }
async function doForceChangePw(){ async function doForceChangePw(){
@@ -376,11 +442,8 @@ async function doForceChangePw() {
if(newPw.length<6){errEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;} if(newPw.length<6){errEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
if(newPw!==confirm){errEl.textContent='새 비밀번호가 일치하지 않습니다.';return;} if(newPw!==confirm){errEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
try{ try{
const data = await apiFetch('/auth/change-password', { const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPw }) token=data.token;localStorage.setItem('portal_token',token);
});
token = data.token;
localStorage.setItem('portal_token', token);
currentUser.must_change_password=false; currentUser.must_change_password=false;
document.getElementById('change-pw-page').style.display='none'; document.getElementById('change-pw-page').style.display='none';
showApp(); showApp();
@@ -393,54 +456,169 @@ function showApp() {
document.getElementById('app-page').style.display='flex'; document.getElementById('app-page').style.display='flex';
document.getElementById('header-username').textContent=currentUser.username; document.getElementById('header-username').textContent=currentUser.username;
document.getElementById('admin-badge').style.display=currentUser.is_admin?'inline':'none'; document.getElementById('admin-badge').style.display=currentUser.is_admin?'inline':'none';
document.querySelectorAll('.admin-only').forEach(el => { document.querySelectorAll('.admin-only').forEach(el=>{el.style.display=currentUser.is_admin?'inline-block':'none';});
el.style.display = currentUser.is_admin ? 'inline-block' : 'none';
});
showPage('my-pages'); showPage('my-pages');
} }
// ── My Password Change ─────────────────────────────────── 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(){ async function doChangePw(){
const current=document.getElementById('my-current-pw').value; const current=document.getElementById('my-current-pw').value;
const newPw=document.getElementById('my-new-pw').value; const newPw=document.getElementById('my-new-pw').value;
const confirm=document.getElementById('my-confirm-pw').value; const confirm=document.getElementById('my-confirm-pw').value;
const msgEl=document.getElementById('my-pw-msg'); const msgEl=document.getElementById('my-pw-msg');
msgEl.style.color = 'var(--danger)'; msgEl.style.color='var(--danger)';msgEl.textContent='';
msgEl.textContent = '';
if(newPw.length<6){msgEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;} if(newPw.length<6){msgEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
if(newPw!==confirm){msgEl.textContent='새 비밀번호가 일치하지 않습니다.';return;} if(newPw!==confirm){msgEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
try{ try{
const data = await apiFetch('/auth/change-password', { const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
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='✅ 비밀번호가 성공적으로 변경되었습니다.';
token = data.token;
localStorage.setItem('portal_token', token);
msgEl.style.color = 'var(--success)';
msgEl.textContent = '✅ 비밀번호가 성공적으로 변경되었습니다.';
document.getElementById('my-current-pw').value=''; document.getElementById('my-current-pw').value='';
document.getElementById('my-new-pw').value=''; document.getElementById('my-new-pw').value='';
document.getElementById('my-confirm-pw').value=''; document.getElementById('my-confirm-pw').value='';
}catch(e){msgEl.textContent=e.message;} }catch(e){msgEl.textContent=e.message;}
} }
// ── My Pages ───────────────────────────────────────────── // ── MY Page List ──────────────────────────────────────────
async function loadMyPages(){ async function loadMyPages(){
const pages=await apiFetch('/my-pages'); const pages=await apiFetch('/my-pages');
const grid=document.getElementById('my-pages-grid'); const grid=document.getElementById('my-pages-grid');
if(!pages||pages.length===0){ if(!pages||pages.length===0){
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>`; grid.innerHTML='<div class="empty-state" style="grid-column:1/-1"><p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>';return;
return;
} }
grid.innerHTML = pages.map(p => ` grid.innerHTML=pages.map(p=>{
<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener"> const faviconUrl=getFaviconUrl(p.url);
<div class="card-icon"><svg 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> 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> <h3>${escHtml(p.name)}</h3>
<p>${escHtml(p.description||'설명 없음')}</p> <p>${escHtml(p.description||'설명 없음')}</p>
<div class="card-url">${escHtml(p.url)}</div> </a>`;
</a>`).join(''); }).join('');
} }
// ── Admin: Pages ───────────────────────────────────────── function getFaviconUrl(url){
try{
const u=new URL(url);
return u.origin+'/favicon.ico';
}catch{return '';}
}
// ── 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(){ async function loadAdminPages(){
const pages=await apiFetch('/admin/webpages'); const pages=await apiFetch('/admin/webpages');
const tbody=document.getElementById('pages-table-body'); const tbody=document.getElementById('pages-table-body');
@@ -477,36 +655,31 @@ async function savePage() {
async function deletePage(id){ async function deletePage(id){
if(!confirm('이 페이지를 삭제하시겠습니까?'))return; if(!confirm('이 페이지를 삭제하시겠습니까?'))return;
await apiFetch('/admin/webpages/' + id, { method: 'DELETE' }); await apiFetch('/admin/webpages/'+id,{method:'DELETE'});loadAdminPages();
loadAdminPages();
} }
// ── Admin: Users ───────────────────────────────────────── // ── Admin: Users ─────────────────────────────────────────
async function loadAdminUsers(){ async function loadAdminUsers(){
const users=await apiFetch('/admin/users'); const users=await apiFetch('/admin/users');
const tbody=document.getElementById('users-table-body'); const tbody=document.getElementById('users-table-body');
tbody.innerHTML=users.map(u=>{ tbody.innerHTML=users.map(u=>{
const statusTags = []; const tags=[];
if (u.is_locked) statusTags.push(`<span class="tag tag-locked">🔒 잠김</span>`); if(u.is_locked)tags.push('<span class="tag tag-locked">🔒 잠김</span>');
else statusTags.push(`<span class="tag tag-ok">정상</span>`); else tags.push('<span class="tag tag-ok">정상</span>');
if (u.must_change_password) statusTags.push(`<span class="tag tag-warning">초기PW</span>`); if(u.must_change_password)tags.push('<span class="tag tag-warning">초기PW</span>');
if (u.password_change_requested) statusTags.push(`<span class="tag tag-locked">변경요청</span>`); if(u.password_change_requested)tags.push('<span class="tag tag-locked">변경요청</span>');
const acts=[];
const actions = []; if(!u.is_admin)acts.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
if (!u.is_admin) actions.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
if(u.username!=='admin'){ if(u.username!=='admin'){
actions.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-warning" onclick="openAdminPwModal(${u.id},'${escJs(u.username)}')">PW변경</button>`);
actions.push(`<button class="btn btn-sm btn-gray" onclick="resetPassword(${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) actions.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`); if(u.is_locked)acts.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`);
if (u.username !== 'admin') actions.push(`<button class="btn btn-sm btn-danger" onclick="deleteUser(${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>
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><span class="tag ${u.is_admin?'tag-admin':'tag-user'}">${u.is_admin?'관리자':'일반사용자'}</span></td>
<td>${statusTags.join(' ')}</td> <td>${tags.join(' ')}</td>
<td><div class="actions">${actions.join('')}</div></td> <td><div class="actions">${acts.join('')}</div></td></tr>`;
</tr>`;
}).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>'; }).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
} }
@@ -526,11 +699,9 @@ async function saveUser() {
async function deleteUser(id){ async function deleteUser(id){
if(!confirm('이 사용자를 삭제하시겠습니까?'))return; if(!confirm('이 사용자를 삭제하시겠습니까?'))return;
await apiFetch('/admin/users/' + id, { method: 'DELETE' }); await apiFetch('/admin/users/'+id,{method:'DELETE'});loadAdminUsers();
loadAdminUsers();
} }
// ── Admin: 비밀번호 변경 ─────────────────────────────────
function openAdminPwModal(userId,username){ function openAdminPwModal(userId,username){
editingUserId=userId; editingUserId=userId;
document.getElementById('admin-pw-modal-title').textContent=`'${username}' 비밀번호 변경`; document.getElementById('admin-pw-modal-title').textContent=`'${username}' 비밀번호 변경`;
@@ -546,13 +717,10 @@ async function saveAdminPw() {
if(pw!==confirm){alert('비밀번호가 일치하지 않습니다.');return;} if(pw!==confirm){alert('비밀번호가 일치하지 않습니다.');return;}
try{ try{
await apiFetch('/admin/users/'+editingUserId+'/password',{method:'PUT',body:JSON.stringify({new_password:pw})}); await apiFetch('/admin/users/'+editingUserId+'/password',{method:'PUT',body:JSON.stringify({new_password:pw})});
closeModal('admin-pw-modal'); closeModal('admin-pw-modal');alert('비밀번호가 변경되었습니다.');loadAdminUsers();
alert('비밀번호가 변경되었습니다.');
loadAdminUsers();
}catch(e){alert(e.message);} }catch(e){alert(e.message);}
} }
// ── Admin: 임시 비밀번호 발급 ────────────────────────────
async function resetPassword(userId,username){ async function resetPassword(userId,username){
if(!confirm(`'${username}' 의 임시 비밀번호를 발급하시겠습니까?`))return; if(!confirm(`'${username}' 의 임시 비밀번호를 발급하시겠습니까?`))return;
try{ try{
@@ -562,14 +730,11 @@ async function resetPassword(userId, username) {
}catch(e){alert(e.message);} }catch(e){alert(e.message);}
} }
// ── Admin: 잠금 해제 ─────────────────────────────────────
async function unlockUser(userId){ async function unlockUser(userId){
if(!confirm('이 계정의 잠금을 해제하시겠습니까?'))return; if(!confirm('이 계정의 잠금을 해제하시겠습니까?'))return;
await apiFetch('/admin/users/' + userId + '/unlock', { method: 'POST' }); await apiFetch('/admin/users/'+userId+'/unlock',{method:'POST'});loadAdminUsers();
loadAdminUsers();
} }
// ── Admin: Access ────────────────────────────────────────
async function openAccessModal(userId,username){ async function openAccessModal(userId,username){
editingUserId=userId; editingUserId=userId;
document.getElementById('access-modal-title').textContent=`'${username}' 접근 권한 설정`; document.getElementById('access-modal-title').textContent=`'${username}' 접근 권한 설정`;
@@ -586,28 +751,20 @@ async function openAccessModal(userId, username) {
async function saveAccess(){ async function saveAccess(){
const checked=[...document.querySelectorAll('#access-checkbox-list input:checked')].map(i=>parseInt(i.value)); 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})}); await apiFetch('/admin/users/'+editingUserId+'/pages',{method:'PUT',body:JSON.stringify({webpage_ids:checked})});
closeModal('access-modal'); closeModal('access-modal');alert('권한이 저장되었습니다.');
alert('권한이 저장되었습니다.');
} }
// ── Password Toggle ────────────────────────────────────── // ── Utils ─────────────────────────────────────────────────
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"/>';
}
}
// ── Utils ────────────────────────────────────────────────
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');} function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escJs(s){return String(s||'').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 ───────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────
(async function init(){ (async function init(){
if(token){ if(token){
try{ try{