Files
myProfile/learning.html

4135 lines
144 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="이종재의 학습 일지 — Game & XR 개발 공부 기록">
<meta property="og:title" content="Learning Log | 이종재">
<meta property="og:description" content="Game & XR Developer 이종재의 학습 일지">
<meta property="og:type" content="website">
<meta property="og:url" content="https://portfolio.whdwo798.synology.me/learning.html">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Learning Log | 이종재">
<meta name="twitter:description" content="Game & XR Developer 이종재의 학습 일지">
<title>Learning Log | 이종재</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="style.css">
<!-- 마크다운 렌더링 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.5/purify.min.js"></script>
<!-- 코드 하이라이팅 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<style>
/* ===== 라이트 모드 (기본) ===== */
/* ===== 페이지 헤더 ===== */
.page-header {
padding: 60px 8% 40px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
letter-spacing: 1.5px;
}
.page-header p {
color: var(--text-dim);
font-size: 1rem;
max-width: 700px;
}
/* ===== 캘린더 (잔디) ===== */
.activity-calendar {
margin-top: 2rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.2rem 1.4rem;
overflow-x: auto;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 12px;
}
.calendar-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
color: var(--text-dim);
letter-spacing: 1.5px;
}
.calendar-stats {
color: var(--primary);
font-size: 0.85rem;
font-family: 'JetBrains Mono', monospace;
}
.cal-clear-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 209, 102, 0.1);
border: 1px solid var(--learning-yellow);
color: var(--learning-yellow);
padding: 5px 12px;
border-radius: 14px;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
transition: 0.2s;
}
.cal-clear-btn:hover {
background: var(--learning-yellow);
color: var(--bg);
}
.calendar-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
min-width: 720px;
}
.calendar-month-labels {
grid-column: 2;
display: flex;
justify-content: space-between;
color: var(--text-dim);
font-size: 0.7rem;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 4px;
padding: 0 4px;
}
.calendar-day-labels {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 4px;
font-size: 0.7rem;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
padding-top: 22px;
}
.calendar-day-labels span {
height: 16px;
line-height: 16px;
}
.calendar-cells {
display: grid;
grid-template-rows: repeat(7, 1fr);
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 4px;
min-height: 140px;
}
.cal-cell {
width: 100%;
aspect-ratio: 1;
min-width: 14px;
background: rgba(255,255,255,0.04);
border-radius: 3px;
cursor: pointer;
transition: 0.15s;
position: relative;
}
.cal-cell.has-post {
background: var(--grass-l1);
box-shadow: none;
}
.cal-cell.has-post[data-count="2"] {
background: var(--grass-l2);
box-shadow: none;
}
.cal-cell.has-post[data-count="3"],
.cal-cell.has-post[data-count="4"],
.cal-cell.has-post[data-count="5"] {
background: var(--grass-l3);
box-shadow: none;
}
.cal-cell:hover {
transform: scale(1.3);
z-index: 5;
border: 1px solid rgba(255,255,255,0.5);
}
.cal-cell.today {
border: 1px solid var(--learning-yellow);
}
.cal-cell.selected {
transform: scale(1.5);
border: 2px solid var(--learning-yellow);
box-shadow: 0 0 12px rgba(255, 209, 102, 0.8);
z-index: 4;
}
/* 활성 필터 표시 (날짜 필터 등) */
.active-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 1rem;
}
.active-filters:empty {
display: none;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(255, 209, 102, 0.1);
border: 1px solid rgba(255, 209, 102, 0.4);
color: var(--learning-yellow);
border-radius: 16px;
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
}
.filter-chip button {
background: transparent;
border: none;
color: var(--learning-yellow);
cursor: pointer;
padding: 0;
width: 16px; height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.7rem;
transition: 0.2s;
}
.filter-chip button:hover {
background: var(--learning-yellow);
color: var(--bg);
}
.calendar-tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(8px);
color: #ffffff;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.75rem;
border: 1px solid var(--border-card);
pointer-events: none;
z-index: 100;
display: none;
white-space: nowrap;
}
/* ===== 본문 레이아웃 ===== */
.layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 2rem;
padding: 40px 8%;
align-items: start;
}
/* 좌측 사이드바 */
.sidebar {
position: sticky;
top: 90px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.2rem;
}
.sidebar-title {
font-family: 'Orbitron', sans-serif;
color: var(--primary);
font-size: 0.8rem;
letter-spacing: 1.5px;
margin-bottom: 1rem;
padding-bottom: 0.7rem;
border-bottom: 1px dashed var(--border-card);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-title .add-cat-btn {
background: var(--border);
border: 1px solid var(--primary);
color: var(--primary);
width: 22px; height: 22px;
border-radius: 4px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 0.7rem;
transition: 0.2s;
}
.sidebar-title .add-cat-btn:hover {
background: var(--primary);
color: var(--bg);
}
.admin-on .sidebar-title .add-cat-btn { display: flex; }
.cat-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.cat-item {
padding: 9px 12px;
border-radius: 6px;
cursor: pointer;
color: var(--text);
font-size: 0.9rem;
transition: 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
border: 1px solid transparent;
user-select: none;
}
.cat-item:hover {
background: var(--primary-dim);
border-color: var(--border);
}
.cat-item.active {
background: var(--border);
border-color: var(--primary);
color: var(--primary);
}
.cat-item .cat-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.cat-item .cat-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.cat-item .cat-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-item .cat-count {
color: var(--text-dim);
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
}
.cat-item.active .cat-count { color: var(--primary); }
/* 큰 메뉴(parent) 스타일 */
.cat-parent {
margin-top: 6px;
}
.cat-parent:first-child { margin-top: 0; }
.cat-parent .cat-item {
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
letter-spacing: 0.5px;
font-weight: 700;
}
.cat-parent .cat-item .cat-toggle {
color: var(--text-dim);
font-size: 0.7rem;
transition: transform 0.2s;
width: 12px;
text-align: center;
}
.cat-parent.expanded .cat-item .cat-toggle {
transform: rotate(90deg);
color: var(--primary);
}
/* 서브 카테고리 컨테이너 */
.cat-children {
display: none;
flex-direction: column;
gap: 2px;
padding-left: 16px;
margin-top: 2px;
margin-bottom: 4px;
position: relative;
}
.cat-parent.expanded .cat-children {
display: flex;
}
.cat-children::before {
content: '';
position: absolute;
left: 14px;
top: 4px;
bottom: 4px;
width: 1px;
background: var(--border);
}
.cat-children .cat-item {
padding: 7px 12px 7px 16px;
font-size: 0.85rem;
}
/* 카테고리 액션 버튼 */
.cat-actions {
display: none;
gap: 4px;
}
.admin-on .cat-item:hover .cat-actions { display: flex; }
.cat-action-btn {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px 4px;
font-size: 0.75rem;
transition: 0.2s;
}
.cat-action-btn:hover { color: var(--primary); }
.cat-action-btn.del:hover { color: var(--danger); }
.cat-action-btn.add:hover { color: var(--learning-yellow); }
/* 빈 상태 */
.cat-empty {
color: var(--text-dim);
font-size: 0.8rem;
padding: 12px;
text-align: center;
font-style: italic;
}
.cat-divider {
height: 1px;
background: var(--border);
margin: 8px 0;
}
/* 메인 영역 */
.main-area {
min-width: 0;
}
.main-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 12px;
}
.main-toolbar .current-section {
display: flex;
align-items: center;
gap: 12px;
}
.main-toolbar h2 {
font-size: 1.6rem;
letter-spacing: 1px;
}
.main-toolbar .post-count {
color: var(--text-dim);
font-size: 0.85rem;
font-family: 'JetBrains Mono', monospace;
}
.main-toolbar .actions {
display: flex;
gap: 8px;
}
.admin-only { display: none !important; }
.admin-on .admin-only { display: inline-flex !important; }
.admin-on .modal-actions.admin-only { display: flex !important; }
/* 검색 + 필터 */
.search-row {
display: flex;
gap: 10px;
margin-bottom: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.search-row input {
flex: 1;
min-width: 160px;
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 8px 8px 0;
border-left: none;
color: var(--text);
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.9rem;
transition: 0.2s;
}
.search-row input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--border);
}
.search-scope-select {
padding: 10px 10px 10px 14px;
background: var(--primary-dim);
border: 1px solid var(--border-card);
border-right: none;
border-radius: 8px 0 0 8px;
color: var(--primary);
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 0.5px;
cursor: pointer;
transition: 0.2s;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2300f2ff' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
.search-scope-select:focus {
outline: none;
border-color: var(--primary);
}
/* 날짜 범위 선택 시 input을 date 타입처럼 힌트 */
.search-scope-select.scope-date + input {
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.5px;
}
.search-row select#sortSelect {
padding: 10px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.9rem;
cursor: pointer;
flex-shrink: 0;
}
/* 글 카드 */
.post-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.4rem 1.6rem;
cursor: pointer;
transition: 0.25s;
position: relative;
overflow: hidden;
}
.post-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--cat-color, var(--primary));
opacity: 0.5;
transition: 0.2s;
}
.post-card:hover {
transform: translateY(-3px);
border-color: var(--border-card);
box-shadow: 0 6px 20px var(--primary-dim);
}
.post-card:hover::before { opacity: 1; }
.post-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.6rem;
flex-wrap: wrap;
}
.post-card h3 {
font-family: 'Noto Sans KR', sans-serif;
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
line-height: 1.4;
flex: 1;
min-width: 0;
}
.post-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.75rem;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
flex-shrink: 0;
}
.post-meta .learning-badge {
background: rgba(255, 209, 102, 0.15);
color: var(--learning-yellow);
border: 1px solid rgba(255, 209, 102, 0.4);
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7rem;
font-family: 'Orbitron', sans-serif;
letter-spacing: 1px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.post-cat-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.04);
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 0.8rem;
}
.post-cat-pill .cat-dot {
width: 6px; height: 6px;
border-radius: 50%;
}
.post-preview {
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.6;
margin-bottom: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.post-tag {
background: var(--bg-deep);
color: var(--text-dim);
padding: 3px 10px;
border-radius: 10px;
font-size: 0.72rem;
border: 1px solid var(--border);
font-family: 'JetBrains Mono', monospace;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
background: var(--bg-card);
border: 1px dashed var(--border);
border-radius: 12px;
}
.empty-state i { font-size: 2.5rem; opacity: 0.3; margin-bottom: 1rem; }
/* ===== 모달 (공통) ===== */
.checkbox-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.checkbox-row input { width: auto; }
.checkbox-row label {
margin: 0; font-family: 'Noto Sans KR', sans-serif;
font-size: 0.9rem; color: var(--text); letter-spacing: 0;
cursor: pointer;
}
/* 마크다운 에디터 */
.editor-tabs {
display: flex;
gap: 4px;
margin-bottom: -1px;
}
.editor-tab {
padding: 8px 16px;
background: var(--bg-deep);
color: var(--text-dim);
border: 1px solid var(--border);
border-bottom: none;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 1px;
transition: 0.2s;
}
.editor-tab.active {
color: var(--primary);
background: var(--bg-card);
border-color: var(--primary);
}
.editor-pane { display: none; }
.editor-pane.active { display: block; }
.editor-pane textarea {
min-height: 320px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.88rem;
line-height: 1.6;
}
.editor-preview {
min-height: 320px;
padding: 16px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 6px;
overflow-y: auto;
}
.editor-hint {
margin-top: 6px;
font-size: 0.75rem;
color: var(--text-dim);
}
.editor-hint code {
background: var(--bg-deep);
padding: 2px 6px;
border-radius: 3px;
color: var(--primary);
font-size: 0.75rem;
}
/* ===== 마크다운 에디터 툴바 ===== */
.md-toolbar {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 6px 8px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-bottom: none;
border-radius: 6px 6px 0 0;
align-items: center;
}
.md-tool-btn {
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
transition: 0.15s;
position: relative;
padding: 0;
}
.md-tool-btn:hover {
background: var(--border);
color: var(--primary);
border-color: var(--border-card);
}
.md-tool-btn sup {
font-size: 0.55rem;
margin-left: 1px;
}
.md-tool-divider {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
}
/* 텍스트에리어 상단 모서리 제거 (툴바와 연결) */
.editor-pane textarea {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* ===== 이미지 삽입 모달 ===== */
.img-insert-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.img-insert-tab {
padding: 10px 18px;
background: transparent;
color: var(--text-dim);
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: 'Orbitron', sans-serif;
font-size: 0.78rem;
letter-spacing: 1px;
transition: 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.img-insert-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.img-insert-pane { display: none; }
.img-insert-pane.active { display: block; }
/* ===== 색상 선택 그리드 ===== */
.hl-pick-btn {
padding: 10px 8px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
font-family: 'Noto Sans KR', sans-serif;
font-size: 0.85rem;
font-weight: 700;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.hl-pick-btn:hover { filter: brightness(1.1); transform: translateY(-1px); }
.color-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
margin-top: 8px;
}
.color-swatch {
aspect-ratio: 1;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: 0.15s;
position: relative;
}
.color-swatch:hover {
transform: scale(1.1);
border-color: rgba(255,255,255,0.4);
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
/* ===== 콜아웃 옵션 리스트 ===== */
.callout-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.callout-option {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
cursor: pointer;
text-align: left;
transition: 0.2s;
font-family: 'Noto Sans KR', sans-serif;
width: 100%;
}
.callout-option:hover {
transform: translateX(4px);
}
.callout-option i {
font-size: 1.3rem;
width: 30px;
text-align: center;
}
.callout-option .callout-name {
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
letter-spacing: 1px;
margin-bottom: 2px;
}
.callout-option .callout-desc {
color: var(--text-dim);
font-size: 0.78rem;
}
.callout-option.callout-note { border-color: var(--border-card); }
.callout-option.callout-note i { color: var(--primary); }
.callout-option.callout-note:hover { background: var(--primary-dim); border-color: var(--primary); }
.callout-option.callout-tip { border-color: rgba(92, 138, 106, 0.3); }
.callout-option.callout-tip i { color: var(--primary); }
.callout-option.callout-tip:hover { background: var(--primary-dim); border-color: var(--primary); }
.callout-option.callout-warn { border-color: rgba(255, 209, 102, 0.3); }
.callout-option.callout-warn i { color: var(--learning-yellow); }
.callout-option.callout-warn:hover { background: rgba(255, 209, 102, 0.05); border-color: var(--learning-yellow); }
.callout-option.callout-danger { border-color: rgba(255, 71, 87, 0.3); }
.callout-option.callout-danger i { color: var(--danger); }
.callout-option.callout-danger:hover { background: rgba(255, 71, 87, 0.05); border-color: var(--danger); }
/* ===== 미디어 삽입 모달 - 옵션 영역 ===== */
.media-options {
background: var(--primary-dim);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin: 16px 0;
}
.media-options-title {
font-family: 'Orbitron', sans-serif;
color: var(--primary);
font-size: 0.75rem;
letter-spacing: 1.5px;
margin-bottom: 12px;
}
.media-options .form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 0;
}
.media-options .form-group {
margin-bottom: 0;
}
.media-options code {
background: var(--bg);
padding: 1px 5px;
border-radius: 3px;
color: var(--primary);
font-size: 0.7rem;
font-family: 'JetBrains Mono', monospace;
}
/* ===== 커스텀 비디오 플레이어 ===== */
.markdown-body .md-video-wrap {
position: relative;
background: #000;
border-radius: 8px;
overflow: hidden;
margin: 1rem 0;
max-width: 100%;
outline: none;
display: block; /* inline-block 아니라 block으로 - 너비 제어 용이 */
}
.markdown-body .md-video-wrap:focus {
box-shadow: 0 0 0 2px var(--primary);
}
.markdown-body .md-video-wrap video {
display: block;
width: 100%; /* wrap 크기를 항상 100% 채움 */
height: auto;
cursor: pointer;
}
/* 큰 가운데 재생 버튼 */
.md-video-bigplay {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 70px; height: 70px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
border: 2px solid var(--primary);
color: var(--primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
transition: 0.25s;
z-index: 2;
}
.md-video-bigplay:hover {
background: var(--primary);
color: var(--bg);
transform: translate(-50%, -50%) scale(1.1);
}
.md-video-wrap.playing .md-video-bigplay {
opacity: 0;
pointer-events: none;
}
/* 컨트롤 바 */
.md-video-controls {
position: absolute;
left: 0; right: 0; bottom: 0;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.85) 100%);
padding: 30px 12px 10px;
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
transform: translateY(8px);
transition: 0.25s;
z-index: 3;
}
.md-video-wrap:hover .md-video-controls,
.md-video-wrap:focus .md-video-controls {
opacity: 1;
transform: translateY(0);
}
/* 정지 상태일 땐 항상 살짝 보이게 */
.md-video-wrap:not(.playing) .md-video-controls {
opacity: 0.85;
transform: translateY(0);
}
.vc-btn {
background: transparent;
border: none;
color: #fff;
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: 0.15s;
font-size: 0.85rem;
flex-shrink: 0;
}
.vc-btn:hover {
background: var(--border);
color: var(--primary);
}
.vc-time {
color: #fff;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
margin: 0 6px;
flex-shrink: 0;
min-width: 80px;
}
.vc-progress {
flex: 1;
height: 5px;
background: var(--border);
border-radius: 3px;
cursor: pointer;
position: relative;
margin: 0 6px;
transition: height 0.15s;
}
.vc-progress:hover {
height: 7px;
}
.vc-progress-fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: var(--primary);
border-radius: 3px;
width: 0%;
transition: width 0.1s linear;
box-shadow: 0 0 4px rgba(0, 242, 255, 0.5);
}
.vc-progress-thumb {
position: absolute;
top: 50%;
width: 12px; height: 12px;
border-radius: 50%;
background: var(--primary);
transform: translate(-50%, -50%);
left: 0%;
opacity: 0;
transition: opacity 0.15s;
box-shadow: 0 0 8px rgba(0, 242, 255, 0.8);
}
.md-video-wrap:hover .vc-progress-thumb {
opacity: 1;
}
/* 전체화면 모드 */
.md-video-wrap:fullscreen {
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.md-video-wrap:fullscreen video {
max-height: 100vh;
width: auto;
max-width: 100vw;
}
/* ===== 마크다운 본문 - 추가 스타일 (콜아웃, 형광펜, 색상) ===== */
.markdown-body mark {
background: rgba(176, 122, 32, 0.18);
color: var(--learning-yellow);
padding: 1px 4px;
border-radius: 3px;
}
.markdown-body mark.hl-green { background: rgba(92,138,106,0.2); color: var(--primary); }
.markdown-body mark.hl-mint { background: rgba(178,226,210,0.25);color: #3d6b50; }
.markdown-body mark.hl-red { background: rgba(192,57,43,0.15); color: var(--danger); }
.markdown-body mark.hl-blue { background: rgba(74,127,160,0.18); color: #4a7fa0; }
.markdown-body mark.hl-purple { background: rgba(90,109,160,0.18); color: #5a6da0; }
.markdown-body .md-color {
display: inline;
}
.markdown-body input[type="checkbox"] {
margin-right: 6px;
accent-color: var(--primary);
width: 16px;
height: 16px;
vertical-align: middle;
}
.markdown-body ul.contains-task-list {
list-style: none;
padding-left: 1rem;
}
.markdown-body li.task-list-item {
list-style: none;
margin-left: -1rem;
}
.markdown-body .callout {
border-left: 4px solid;
background: rgba(255,255,255,0.02);
padding: 12px 16px;
margin: 1rem 0;
border-radius: 0 8px 8px 0;
}
.markdown-body .callout-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.8rem;
letter-spacing: 1.5px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.markdown-body .callout-title i {
font-size: 0.95rem;
}
.markdown-body .callout > p:last-child { margin-bottom: 0; }
.markdown-body .callout-note {
border-color: var(--primary);
background: var(--primary-dim);
}
.markdown-body .callout-note .callout-title { color: var(--primary); }
.markdown-body .callout-tip {
border-color: var(--primary);
background: var(--primary-dim);
}
.markdown-body .callout-tip .callout-title { color: var(--primary); }
.markdown-body .callout-warning {
border-color: var(--learning-yellow);
background: rgba(255, 209, 102, 0.06);
}
.markdown-body .callout-warning .callout-title { color: var(--learning-yellow); }
.markdown-body .callout-danger {
border-color: var(--danger);
background: rgba(255, 71, 87, 0.05);
}
.markdown-body .callout-danger .callout-title { color: var(--danger); }
/* ===== 글 상세 모달 ===== */
.post-detail-header {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.post-detail-header h1 {
font-family: 'Noto Sans KR', sans-serif;
font-size: 1.7rem;
line-height: 1.4;
margin-bottom: 1rem;
padding-right: 40px;
}
.post-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
color: var(--text-dim);
font-size: 0.85rem;
}
.post-detail-meta .post-cat-pill { margin: 0; }
/* 마크다운 콘텐츠 */
.markdown-body {
color: var(--text);
font-size: 0.95rem;
line-height: 1.8;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
font-family: 'Noto Sans KR', sans-serif;
margin: 1.6rem 0 0.8rem;
color: var(--primary);
letter-spacing: 0;
line-height: 1.4;
}
.markdown-body h1 { font-size: 1.5rem; border-bottom: 1px solid var(--border-card); padding-bottom: 0.5rem; }
.markdown-body h2 { font-size: 1.3rem; }
.markdown-body h3 { font-size: 1.1rem; color: var(--text); }
.markdown-body h4 { font-size: 1rem; color: var(--text); }
.markdown-body p { margin: 0.8rem 0; color: var(--text); }
.markdown-body ul, .markdown-body ol { margin: 0.8rem 0; padding-left: 1.6rem; }
.markdown-body li { margin: 0.3rem 0; }
.markdown-body a {
color: var(--primary);
text-decoration: none;
border-bottom: 1px dashed var(--border-card);
}
.markdown-body a:hover { border-bottom-style: solid; }
/* 파일 첨부 링크 (📎로 시작하는 링크) */
.markdown-body a[href*="uploads/"][href$=".pdf"],
.markdown-body a[href*="uploads/"][href$=".zip"],
.markdown-body a[href*="uploads/"][href$=".7z"],
.markdown-body a[href*="uploads/"][href$=".xlsx"],
.markdown-body a[href*="uploads/"][href$=".docx"],
.markdown-body a[href*="uploads/"][href$=".pptx"],
.markdown-body a[href*="uploads/"][href$=".txt"],
.markdown-body a[href*="uploads/"][href$=".csv"],
.markdown-body a[href*="uploads/"][href$=".json"],
.markdown-body a[href*="uploads/"][href$=".md"] {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--primary-dim);
border: 1px solid var(--border-card);
border-radius: 6px;
font-size: 0.85rem;
margin: 4px 0;
}
.markdown-body strong { color: var(--primary); }
.markdown-body em { color: var(--text-dim); }
.markdown-body blockquote {
border-left: 3px solid var(--primary);
background: var(--primary-dim);
padding: 0.6rem 1rem;
margin: 1rem 0;
color: var(--text-dim);
border-radius: 0 6px 6px 0;
}
.markdown-body code {
background: var(--primary-dim);
color: var(--primary);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.85em;
font-family: 'JetBrains Mono', monospace;
}
.markdown-body pre {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
overflow-x: auto;
line-height: 1.5;
}
.markdown-body pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.85rem;
font-family: 'JetBrains Mono', monospace;
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5rem 0;
}
.markdown-body table {
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
}
.markdown-body th, .markdown-body td {
border: 1px solid var(--border);
padding: 8px 12px;
}
.markdown-body th {
background: var(--primary-dim);
color: var(--primary);
}
.markdown-body img {
max-width: 100%;
border-radius: 6px;
margin: 1rem 0;
}
/* ===== 미디어 크기/위치 조절 (글 상세 뷰) ===== */
.md-media-wrapper {
position: relative;
display: inline-block;
margin: 1rem 0;
max-width: 100%;
vertical-align: top;
}
.md-media-wrapper.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
.md-media-wrapper.align-right {
display: block;
margin-left: auto;
margin-right: 0;
}
.md-media-wrapper.align-left {
display: block;
margin-left: 0;
margin-right: auto;
}
.md-media-wrapper img {
display: block;
max-width: 100%;
width: 100%;
border-radius: 6px;
margin: 0;
user-select: none;
}
/* 비디오 래퍼는 md-video-wrap이 이미 있어서 거기에 추가 클래스만 */
.md-media-wrapper .md-video-wrap {
margin: 0;
border-radius: 6px;
width: 100%;
display: block;
}
.md-media-wrapper .md-video-wrap video {
width: 100%;
height: auto;
}
/* 크기 조절 핸들 */
.md-resize-handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 16px; height: 16px;
background: var(--primary);
border-radius: 3px;
cursor: se-resize;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
display: flex; align-items: center; justify-content: center;
}
.md-resize-handle::after {
content: '';
display: block;
width: 7px; height: 7px;
border-right: 2px solid #000;
border-bottom: 2px solid #000;
}
.md-media-wrapper:hover .md-resize-handle {
opacity: 1;
}
/* 정렬 + 크기 조절 툴바 */
.md-media-toolbar {
position: absolute;
top: -34px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--border-card);
border-radius: 8px;
padding: 4px 6px;
opacity: 0;
transition: opacity 0.2s;
z-index: 20;
pointer-events: none;
white-space: nowrap;
}
.md-media-wrapper:hover .md-media-toolbar {
opacity: 1;
pointer-events: auto;
}
.md-toolbar-btn {
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
width: 26px; height: 26px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
display: flex; align-items: center; justify-content: center;
transition: 0.15s;
}
.md-toolbar-btn:hover {
background: var(--border);
color: var(--primary);
border-color: var(--border-card);
}
.md-toolbar-btn.active {
background: var(--border-card);
color: var(--primary);
border-color: var(--primary);
}
.md-toolbar-divider {
width: 1px;
background: var(--border);
margin: 2px 2px;
}
.md-toolbar-size-input {
width: 60px;
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
text-align: center;
padding: 0 4px;
height: 26px;
}
.md-toolbar-size-input:focus {
outline: none;
border-color: var(--primary);
}
footer {
padding: 60px 8% 30px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
gap: 1rem;
}
.sidebar {
position: static;
}
}
@media (max-width: 768px) {
.page-header { padding: 40px 5% 30px; }
.page-header h1 { font-size: 1.7rem; letter-spacing: 1px; }
.page-header p { font-size: 0.9rem; }
.activity-calendar { padding: 1rem; }
.calendar-grid { min-width: 600px; }
.layout { padding: 30px 5%; }
.main-toolbar h2 { font-size: 1.3rem; }
.post-card { padding: 1.2rem; }
.post-card h3 { font-size: 1rem; }
.post-card-header { gap: 0.5rem; }
.post-meta { font-size: 0.7rem; }
.modal { padding: 1.4rem; max-height: 92vh; border-radius: 0.8rem; }
.modal h2 { font-size: 1.15rem; }
.form-row, .form-row-3 { grid-template-columns: 1fr; gap: 0; }
.form-group label { font-size: 0.75rem; }
.form-group input, .form-group textarea, .form-group select {
padding: 10px; font-size: 0.9rem;
}
.modal-actions { flex-direction: column-reverse; gap: 8px; }
.modal-actions .btn { width: 100%; justify-content: center; padding: 0.8rem; font-size: 0.8rem; }
.post-detail-header h1 { font-size: 1.3rem; }
.markdown-body { font-size: 0.9rem; }
.markdown-body h1 { font-size: 1.3rem; }
.markdown-body h2 { font-size: 1.15rem; }
footer { padding: 40px 5% 20px; font-size: 0.8rem; }
}
</style>
</head>
<body>
<nav>
<a href="index.html" class="logo">JONGJAE.XR</a>
<div class="links">
<a href="index.html">PROJECTS</a>
<a href="learning.html" class="nav-active">LEARNING</a>
<a href="profile.html">PROFILE</a>
<button class="theme-toggle" onclick="toggleTheme()" title="테마 전환" aria-label="테마 전환" id="themeToggleBtn"><i class="fa-solid fa-moon"></i></button>
</div>
</nav>
<div class="page-header">
<h1>LEARNING <span style="color: var(--primary);">LOG</span></h1>
<p>학습한 내용과 현재 공부 중인 주제를 기록하는 공간입니다. 결과물보다 그 과정을 보여주고 싶었습니다.</p>
<div class="activity-calendar">
<div class="calendar-header">
<span class="calendar-title">// ACTIVITY (최근 1년)</span>
<span style="display: flex; align-items: center; gap: 12px;">
<button id="calClearBtn" class="cal-clear-btn" onclick="clearDateFilter()" style="display: none;">
<i class="fa-solid fa-xmark"></i> 날짜 필터 해제
</button>
<span class="calendar-stats" id="calendarStats">— posts</span>
</span>
</div>
<div class="calendar-grid">
<div class="calendar-day-labels">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div>
<div class="calendar-month-labels" id="calendarMonthLabels"></div>
<div class="calendar-cells" id="calendarCells"></div>
</div>
</div>
</div>
</div>
<div class="layout" id="mainLayout">
<!-- 좌측 사이드바 -->
<aside class="sidebar">
<div class="sidebar-title">
CATEGORIES
<button class="add-cat-btn" onclick="openCategoryModal()" title="카테고리 추가">
<i class="fa-solid fa-plus"></i>
</button>
</div>
<div class="cat-list" id="catList"></div>
</aside>
<!-- 메인 영역 -->
<main class="main-area">
<div class="main-toolbar">
<div class="current-section">
<h2 id="currentSectionTitle">전체</h2>
<span class="post-count" id="postCount">0 posts</span>
</div>
<div class="actions">
<button class="btn btn-primary admin-only" onclick="openPostModal()">
<i class="fa-solid fa-plus"></i> 새 글
</button>
<button class="btn btn-outline admin-only" onclick="logout()">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</button>
</div>
</div>
<div class="search-row">
<select id="searchScope" onchange="onSearchScopeChange()" class="search-scope-select">
<option value="all">전체</option>
<option value="title">제목</option>
<option value="content">내용</option>
<option value="tags">태그</option>
<option value="date">날짜</option>
</select>
<input type="text" id="searchInput" name="search" autocomplete="off" placeholder="🔍 검색..." oninput="renderPosts()">
<select id="sortSelect" onchange="renderPosts()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="updated">최근 수정순</option>
</select>
</div>
<div class="active-filters" id="activeFilters"></div>
<div class="post-list" id="postList"></div>
</main>
</div>
<footer>
<p>&copy; 2026 Lee Jong-jae. Hosted on Private Synology NAS.
<span style="opacity: 0.3; cursor: pointer; margin-left: 10px;" onclick="openLoginModal()" title="Admin">
<i class="fa-solid fa-shield-halved"></i>
</span>
</p>
</footer>
<!-- 캘린더 툴팁 -->
<div class="calendar-tooltip" id="calTooltip"></div>
<!-- 로그인 모달 -->
<div class="modal-overlay" id="loginModal" role="dialog" aria-modal="true" aria-label="관리자 로그인">
<div class="modal" style="max-width: 420px;">
<button class="modal-close-x" onclick="closeLoginModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-lock"></i> ADMIN LOGIN</h2>
<div id="loginAlert"></div>
<div class="form-group">
<label>비밀번호</label>
<input type="password" id="loginPassword" name="admin-password" autocomplete="current-password" placeholder="비밀번호를 입력하세요"
onkeypress="if(event.key==='Enter') doLogin()">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="doLogin()">로그인</button>
<button class="btn btn-outline" onclick="closeLoginModal()">취소</button>
</div>
</div>
</div>
<!-- 카테고리 추가/수정 모달 -->
<div class="modal-overlay" id="categoryModal" role="dialog" aria-modal="true" aria-label="카테고리 관리">
<div class="modal" style="max-width: 460px;">
<button class="modal-close-x" onclick="closeCategoryModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2 id="categoryModalTitle">카테고리 추가</h2>
<div id="categoryAlert"></div>
<input type="hidden" id="categoryId">
<div class="form-group">
<label>유형</label>
<select id="categoryParent">
<option value="">큰 메뉴 (예: Unity, Shader)</option>
</select>
<p style="font-size: 0.75rem; color: var(--text-dim); margin-top: 6px;">
<i class="fa-solid fa-info-circle"></i> 큰 메뉴 아래 서브 메뉴를 만들어야 글을 작성할 수 있어요.
</p>
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="categoryName" placeholder="예: Unity / 학습 / 개발일지">
</div>
<div class="form-group">
<label>색상</label>
<input type="color" id="categoryColor" value="#00f2ff" style="height: 44px;">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="saveCategory()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
<button class="btn btn-outline" onclick="closeCategoryModal()">취소</button>
</div>
</div>
</div>
<!-- 글 작성/수정 모달 -->
<div class="modal-overlay" id="postModal" role="dialog" aria-modal="true" aria-label="글 작성/수정">
<div class="modal modal-wide">
<button class="modal-close-x" onclick="closePostModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2 id="postModalTitle">새 학습 일지</h2>
<div id="postAlert"></div>
<input type="hidden" id="postId">
<div class="form-group">
<label>제목 *</label>
<input type="text" id="postTitle" placeholder="예: Unity URP에서 셰이더 그래프 익히기">
</div>
<div class="form-row">
<div class="form-group">
<label>큰 카테고리 *</label>
<select id="postParentCategory" onchange="updatePostSubCategoryOptions()"></select>
</div>
<div class="form-group">
<label>서브 카테고리 *</label>
<select id="postCategory"></select>
</div>
</div>
<div class="form-group">
<label>날짜</label>
<input type="date" id="postDate">
</div>
<div class="form-group">
<label>태그 (쉼표로 구분)</label>
<input type="text" id="postTags" placeholder="예: URP, Shader Graph, HLSL">
</div>
<div class="form-group">
<label>내용 * (마크다운)</label>
<div class="editor-tabs">
<button type="button" class="editor-tab active" onclick="switchEditorTab('write')">
<i class="fa-solid fa-pen"></i> 작성
</button>
<button type="button" class="editor-tab" onclick="switchEditorTab('preview')">
<i class="fa-solid fa-eye"></i> 미리보기
</button>
</div>
<div class="editor-pane active" id="editorWrite">
<div class="md-toolbar">
<button type="button" class="md-tool-btn" onclick="mdInsert('heading2')" title="제목 (H2)">
<i class="fa-solid fa-heading"></i><sup>2</sup>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('heading3')" title="소제목 (H3)">
<i class="fa-solid fa-heading"></i><sup>3</sup>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('bold')" title="굵게 (Ctrl+B)">
<i class="fa-solid fa-bold"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('italic')" title="기울임 (Ctrl+I)">
<i class="fa-solid fa-italic"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('strike')" title="취소선">
<i class="fa-solid fa-strikethrough"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('color')" title="글자 색상" style="color: #ff6b9d;">
<i class="fa-solid fa-palette"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('highlight')" title="형광펜" style="color: var(--learning-yellow);">
<i class="fa-solid fa-highlighter"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('link')" title="링크 (Ctrl+K)">
<i class="fa-solid fa-link"></i>
</button>
<button type="button" class="md-tool-btn" onclick="openImageInsert()" title="이미지/동영상 삽입">
<i class="fa-solid fa-photo-film"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('quote')" title="인용 박스">
<i class="fa-solid fa-quote-right"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('callout')" title="콜아웃 박스 (NOTE/TIP/WARN)">
<i class="fa-solid fa-circle-info"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('code')" title="인라인 코드">
<i class="fa-solid fa-code"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('codeblock')" title="코드 블록">
<i class="fa-solid fa-file-code"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('ul')" title="목록">
<i class="fa-solid fa-list-ul"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('ol')" title="번호 목록">
<i class="fa-solid fa-list-ol"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('check')" title="체크박스">
<i class="fa-solid fa-square-check"></i>
</button>
<span class="md-tool-divider"></span>
<button type="button" class="md-tool-btn" onclick="mdInsert('hr')" title="구분선 (수평선)">
<i class="fa-solid fa-minus"></i>
</button>
<button type="button" class="md-tool-btn" onclick="mdInsert('table')" title="표">
<i class="fa-solid fa-table"></i>
</button>
</div>
<textarea id="postContent" placeholder="## 배운 것&#10;&#10;여기에 학습 내용을 자유롭게 작성하세요.&#10;&#10;```javascript&#10;// 코드 블록도 가능합니다&#10;console.log('hello');&#10;```"></textarea>
<p class="editor-hint">
<i class="fa-solid fa-keyboard"></i> 단축키: <code>Ctrl+B</code> 굵게 · <code>Ctrl+I</code> 기울임 · <code>Ctrl+K</code> 링크 · <code>Tab</code> 들여쓰기
</p>
</div>
<div class="editor-pane" id="editorPreview">
<div class="editor-preview markdown-body" id="postContentPreview"></div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="savePost()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
<button class="btn btn-outline" onclick="closePostModal()">취소</button>
</div>
</div>
</div>
<!-- 미디어 삽입 모달 (이미지 + 동영상) -->
<div class="modal-overlay" id="imageInsertModal" role="dialog" aria-modal="true" aria-label="이미지/동영상 삽입">
<div class="modal" style="max-width: 560px;">
<button class="modal-close-x" onclick="closeImageInsert()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-photo-film"></i> 미디어/파일 삽입</h2>
<div id="imageInsertAlert"></div>
<div class="img-insert-tabs">
<button type="button" class="img-insert-tab active" onclick="switchImageTab('upload')">
<i class="fa-solid fa-cloud-arrow-up"></i> 이미지/영상
</button>
<button type="button" class="img-insert-tab" onclick="switchImageTab('file')">
<i class="fa-solid fa-paperclip"></i> 파일 첨부
</button>
<button type="button" class="img-insert-tab" onclick="switchImageTab('url')">
<i class="fa-solid fa-link"></i> URL
</button>
</div>
<div class="img-insert-pane active" id="imgPaneUpload">
<div class="image-uploader" id="postImageUploader">
<input type="file" id="postImageFileInput" class="image-uploader-hidden-input" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,video/ogg,video/quicktime">
<div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
<div class="upload-text">이미지/동영상을 끌어다 놓거나 클릭해서 선택</div>
<div class="upload-hint">이미지: JPG/PNG/GIF/WebP · 동영상: MP4/WebM/OGG/MOV</div>
<div class="upload-btn"><i class="fa-solid fa-folder-open"></i> 파일 선택</div>
</div>
<div class="image-progress" id="postImageProgress">
<div class="image-progress-fill" id="postImageProgressFill"></div>
</div>
</div>
<div class="img-insert-pane" id="imgPaneFile">
<div class="image-uploader" id="postFileUploader">
<input type="file" id="postFileInput" class="image-uploader-hidden-input"
accept=".pdf,.zip,.7z,.rar,.xls,.xlsx,.doc,.docx,.ppt,.pptx,.txt,.md,.csv,.json">
<div class="upload-icon"><i class="fa-solid fa-paperclip"></i></div>
<div class="upload-text">파일을 끌어다 놓거나 클릭해서 선택</div>
<div class="upload-hint">PDF · ZIP · Excel · Word · PPT · TXT · CSV · JSON · MD</div>
<div class="upload-btn"><i class="fa-solid fa-folder-open"></i> 파일 선택</div>
</div>
<div id="fileAttachPreview" style="display:none; margin-top:10px; padding:10px 12px; background:#0a0a0f; border:1px solid rgba(0,242,255,0.2); border-radius:8px;">
<div style="display:flex; align-items:center; gap:10px;">
<i class="fa-solid fa-file" style="color:var(--primary); font-size:1.4rem;"></i>
<div style="flex:1; min-width:0;">
<div id="fileAttachName" style="font-size:0.9rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
<div id="fileAttachSize" style="font-size:0.75rem; color:var(--text-dim); margin-top:2px;"></div>
</div>
<button type="button" onclick="clearFileAttach()" style="background:transparent; border:1px solid var(--danger); color:var(--danger); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:0.75rem;">제거</button>
</div>
<p style="color:var(--text-dim); font-size:0.73rem; margin-top:8px;">
<i class="fa-solid fa-info-circle"></i> 저장 버튼을 눌러야 NAS에 업로드됩니다
</p>
</div>
</div>
<div class="img-insert-pane" id="imgPaneUrl">
<div class="form-group">
<label>미디어 종류</label>
<select id="imageInsertKind">
<option value="image">이미지</option>
<option value="video">동영상</option>
</select>
</div>
<div class="form-group">
<label>URL</label>
<input type="text" id="imageInsertUrl" placeholder="https://..." onkeypress="if(event.key==='Enter'){event.preventDefault();insertMediaFromUrl();}">
</div>
<div class="form-group">
<label>대체 텍스트 (이미지인 경우)</label>
<input type="text" id="imageInsertAlt" placeholder="이미지 설명">
</div>
</div>
<div class="media-options" id="mediaOptionsWrap">
<h3 class="media-options-title">// 표시 옵션</h3>
<div class="form-row">
<div class="form-group">
<label>너비</label>
<select id="mediaWidth">
<option value="">기본 (원본/100%)</option>
<option value="200px">작게 (200px)</option>
<option value="400px">중간 (400px)</option>
<option value="600px">크게 (600px)</option>
<option value="50%">50%</option>
<option value="75%">75%</option>
<option value="100%">100% 가득</option>
<option value="custom">직접 입력...</option>
</select>
<input type="text" id="mediaWidthCustom" placeholder="예: 350px 또는 60%"
style="display:none; margin-top:6px;">
</div>
<div class="form-group">
<label>위치</label>
<select id="mediaAlign">
<option value="">기본 (왼쪽)</option>
<option value="left">왼쪽</option>
<option value="center">가운데</option>
<option value="right">오른쪽</option>
</select>
</div>
</div>
<p style="font-size:0.75rem;color:var(--text-dim);margin-top:6px;">
<i class="fa-solid fa-circle-info"></i> 삽입 후에도 본문에서 직접 <code>w=400,center</code> 형태로 수정 가능
</p>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="insertAttachFile()" id="insertFileBtn" style="display:none;">
<i class="fa-solid fa-paperclip"></i> 파일 첨부 삽입
</button>
<button class="btn btn-primary" onclick="insertMediaFromUrl()" id="insertMediaUrlBtn" style="display:none;">
<i class="fa-solid fa-plus"></i> URL로 삽입
</button>
<button class="btn btn-outline" onclick="closeImageInsert()">취소</button>
</div>
</div>
</div>
<!-- 형광펜 색상 선택 -->
<div class="modal-overlay" id="highlightPickerModal" role="dialog" aria-modal="true" aria-label="형광펜 색상 선택">
<div class="modal" style="max-width: 360px;">
<button class="modal-close-x" onclick="closeHighlightPicker()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-highlighter"></i> 형광펜 색상</h2>
<div style="display:grid; grid-template-columns: repeat(3,1fr); gap:10px; margin-top:0.5rem;">
<button class="hl-pick-btn" onclick="applyHighlight('')"
style="background:rgba(176,122,32,0.18);color:var(--learning-yellow);">
<i class="fa-solid fa-highlighter"></i> 노랑
</button>
<button class="hl-pick-btn" onclick="applyHighlight('green')"
style="background:rgba(92,138,106,0.2);color:var(--primary);">
<i class="fa-solid fa-highlighter"></i> 그린
</button>
<button class="hl-pick-btn" onclick="applyHighlight('mint')"
style="background:rgba(178,226,210,0.25);color:#3d6b50;">
<i class="fa-solid fa-highlighter"></i> 민트
</button>
<button class="hl-pick-btn" onclick="applyHighlight('red')"
style="background:rgba(192,57,43,0.15);color:var(--danger);">
<i class="fa-solid fa-highlighter"></i> 레드
</button>
<button class="hl-pick-btn" onclick="applyHighlight('blue')"
style="background:rgba(74,127,160,0.18);color:#4a7fa0;">
<i class="fa-solid fa-highlighter"></i> 블루
</button>
<button class="hl-pick-btn" onclick="applyHighlight('purple')"
style="background:rgba(90,109,160,0.18);color:#5a6da0;">
<i class="fa-solid fa-highlighter"></i> 퍼플
</button>
</div>
</div>
</div>
<!-- 색상 선택 팝오버 -->
<div class="modal-overlay" id="colorPickerModal" role="dialog" aria-modal="true" aria-label="색상 선택">
<div class="modal" style="max-width: 380px;">
<button class="modal-close-x" onclick="closeColorPicker()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2 id="colorPickerTitle"><i class="fa-solid fa-palette"></i> 색상 선택</h2>
<div class="color-grid" id="colorGrid"></div>
<p style="color: var(--text-dim); font-size: 0.78rem; margin-top: 12px;">
<i class="fa-solid fa-info-circle"></i> 텍스트를 먼저 선택한 후 색상을 클릭하세요.
</p>
</div>
</div>
<!-- 콜아웃 종류 선택 -->
<div class="modal-overlay" id="calloutModal" role="dialog" aria-modal="true" aria-label="콜아웃 삽입">
<div class="modal" style="max-width: 420px;">
<button class="modal-close-x" onclick="closeCalloutModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2><i class="fa-solid fa-circle-info"></i> 콜아웃 종류</h2>
<div class="callout-list">
<button type="button" class="callout-option callout-note" onclick="insertCallout('NOTE')">
<i class="fa-solid fa-circle-info"></i>
<div>
<div class="callout-name">NOTE</div>
<div class="callout-desc">일반 정보, 참고사항</div>
</div>
</button>
<button type="button" class="callout-option callout-tip" onclick="insertCallout('TIP')">
<i class="fa-solid fa-lightbulb"></i>
<div>
<div class="callout-name">TIP</div>
<div class="callout-desc">팁, 추천</div>
</div>
</button>
<button type="button" class="callout-option callout-warn" onclick="insertCallout('WARNING')">
<i class="fa-solid fa-triangle-exclamation"></i>
<div>
<div class="callout-name">WARNING</div>
<div class="callout-desc">주의사항</div>
</div>
</button>
<button type="button" class="callout-option callout-danger" onclick="insertCallout('DANGER')">
<i class="fa-solid fa-circle-exclamation"></i>
<div>
<div class="callout-name">DANGER</div>
<div class="callout-desc">위험, 절대 하지 말 것</div>
</div>
</button>
</div>
</div>
</div>
<!-- 글 상세 모달 -->
<div class="modal-overlay" id="postDetailModal" role="dialog" aria-modal="true" aria-label="글 상세보기">
<div class="modal modal-wide">
<button class="modal-close-x" onclick="closePostDetail()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<div class="post-detail-header">
<h1 id="detailTitle"></h1>
<div class="post-detail-meta" id="detailMeta"></div>
</div>
<div class="markdown-body" id="detailContent"></div>
<div class="modal-actions admin-only">
<button class="btn btn-primary" onclick="editCurrentPost()">
<i class="fa-solid fa-pen"></i> 수정
</button>
<button class="btn btn-danger" onclick="deleteCurrentPost()">
<i class="fa-solid fa-trash"></i> 삭제
</button>
</div>
<div style="text-align:center; margin-top: 1.5rem; padding-top: 1.2rem; border-top: 1px solid rgba(255,255,255,0.06);">
<button onclick="closePostDetail()" class="btn btn-outline" style="min-width: 120px;">
<i class="fa-solid fa-xmark"></i> 닫기
</button>
</div>
</div>
</div>
<script>
// ===== 상태 =====
// ===== 테마 =====
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('portfolio-theme', theme);
const btn = document.getElementById('themeToggleBtn');
if (btn) {
btn.innerHTML = theme === 'dark'
? '<i class="fa-solid fa-sun"></i>'
: '<i class="fa-solid fa-moon"></i>';
btn.title = theme === 'dark' ? '라이트 모드로' : '다크 모드로';
}
}
function toggleTheme() {
const curr = document.documentElement.getAttribute('data-theme');
applyTheme(curr === 'dark' ? 'light' : 'dark');
}
(function() {
const saved = localStorage.getItem('portfolio-theme');
const prefer = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
applyTheme(saved || prefer);
})();
let isAdmin = false;
let csrfToken = '';
let categories = []; // 모든 카테고리 (parent + child)
let posts = [];
let currentFilter = { type: 'all', value: null };
// type: 'all' | 'parent' (큰 카테고리, 자식들의 글 모두) | 'category' (서브 카테고리)
let currentDetailId = null;
let expandedParents = new Set(); // 확장된 큰 카테고리 ID
let dateFilter = null; // 'YYYY-MM-DD' 또는 null
// ===== 마크다운 설정 =====
marked.setOptions({
breaks: true, gfm: true,
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try { return hljs.highlight(code, { language: lang }).value; } catch (e) {}
}
return hljs.highlightAuto(code).value;
}
});
// 마크다운 렌더링 - 콜아웃, 색상, 형광펜 확장 적용
function renderMarkdown(src) {
if (!src) return '';
// ===== 헬퍼: |속성 파싱 =====
// "w=400,center" → { width: '400', align: 'center' }
function parseAttrs(str) {
const result = { width: null, align: null };
if (!str) return result;
const parts = str.split(',').map(s => s.trim()).filter(s => s);
parts.forEach(p => {
const m = p.match(/^w(?:idth)?\s*=\s*(.+)$/i);
if (m) {
result.width = sanitizeMediaWidth(m[1].trim());
return;
}
if (/^(left|center|right)$/i.test(p)) {
result.align = p.toLowerCase();
}
});
return result;
}
// 정렬 → CSS
function alignStyle(align) {
if (align === 'center') return 'display:block;margin-left:auto;margin-right:auto;';
if (align === 'right') return 'display:block;margin-left:auto;margin-right:0;';
if (align === 'left') return 'display:block;margin-left:0;margin-right:auto;';
return '';
}
// ===== 1) 동영상: @video[속성](url) =====
// 예: @video[w=600,center](uploads/learning/vid_xxx.mp4)
// 속성 없을 때: @video(url)
src = src.replace(
/@video(?:\[([^\]]*)\])?\(([^)]+)\)/g,
function(match, attrStr, url) {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const attrs = parseAttrs(attrStr || '');
// width와 align은 wrap에 적용 (video 자체가 아님)
const styles = [];
if (attrs.width) styles.push(`width:${attrs.width}`);
const alignS = alignStyle(attrs.align);
if (alignS) styles.push(alignS.replace(/;$/, ''));
styles.push('max-width:100%');
const wrapStyle = `style="${styles.join(';')}"`;
return `<div class="md-video-wrap" ${wrapStyle}><video class="md-video" preload="metadata" src="${escapeHtml(safeUrl)}"></video></div>`;
}
);
// ===== 2) 이미지에 |속성 적용: ![alt|w=400,center](url) =====
// marked가 이미지를 변환하기 전에 미리 속성을 빼서 처리
src = src.replace(
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g,
function(match, alt, attrStr, url) {
const safeUrl = sanitizeUrl(url);
if (!safeUrl) return '';
const attrs = parseAttrs(attrStr);
const styles = [];
if (attrs.width) styles.push(`width:${attrs.width}`);
styles.push('max-width:100%');
const alignS = alignStyle(attrs.align);
if (alignS) styles.push(alignS.replace(/;$/, ''));
return `<img src="${escapeHtml(safeUrl)}" alt="${escapeHtml(alt)}" style="${styles.join(';')};">`;
}
);
// ===== 3) 콜아웃 처리: > [!NOTE] 형태 =====
const calloutMap = {
'NOTE': { cls: 'callout-note', icon: 'fa-circle-info', label: 'NOTE' },
'TIP': { cls: 'callout-tip', icon: 'fa-lightbulb', label: 'TIP' },
'WARNING': { cls: 'callout-warning', icon: 'fa-triangle-exclamation', label: 'WARNING' },
'DANGER': { cls: 'callout-danger', icon: 'fa-circle-exclamation', label: 'DANGER' }
};
src = src.replace(
/(?:^|\n)> \[!(NOTE|TIP|WARNING|DANGER)\]\s*\n((?:>.*(?:\n|$))*)/g,
function(match, type, body) {
const meta = calloutMap[type];
const inner = body.replace(/^>\s?/gm, '').trim();
const innerHtml = DOMPurify.sanitize(marked.parse(inner));
return `\n<div class="callout ${meta.cls}"><div class="callout-title"><i class="fa-solid ${meta.icon}"></i>${meta.label}</div>${innerHtml}</div>\n`;
}
);
// ===== 4) 형광펜: ==text== → <mark> =====
// 형광펜: ==[color]:text== 또는 ==text==
src = src.replace(/==\[([a-z]+)\]:([^=\n]+)==/g, '<mark class="hl-$1">$2</mark>');
src = src.replace(/==([^=\n\[]+)==/g, '<mark>$1</mark>');
// ===== 5) 색상: {color:#ff0000}text{/color} =====
src = src.replace(/\{color:([^}]+)\}([\s\S]+?)\{\/color\}/g, function(match, color, text) {
const safeColor = sanitizeCssColor(color);
return safeColor ? `<span class="md-color" style="color:${safeColor}">${text}</span>` : text;
});
return sanitizeAllowedStyles(DOMPurify.sanitize(marked.parse(src)));
}
// 렌더 후 비디오에 커스텀 컨트롤 부착
function attachVideoControls(rootEl) {
if (!rootEl) return;
rootEl.querySelectorAll('.md-video-wrap').forEach(wrap => {
if (wrap.dataset.controlsAttached === '1') return;
wrap.dataset.controlsAttached = '1';
const video = wrap.querySelector('video.md-video');
if (!video) return;
// 컨트롤 오버레이 추가
const controls = document.createElement('div');
controls.className = 'md-video-controls';
controls.innerHTML = `
<button class="vc-btn vc-back" title="-10초"><i class="fa-solid fa-rotate-left"></i></button>
<button class="vc-btn vc-play" title="재생"><i class="fa-solid fa-play"></i></button>
<button class="vc-btn vc-forward" title="+10초"><i class="fa-solid fa-rotate-right"></i></button>
<span class="vc-time">0:00 / 0:00</span>
<div class="vc-progress">
<div class="vc-progress-fill"></div>
<div class="vc-progress-thumb"></div>
</div>
<button class="vc-btn vc-mute" title="음소거"><i class="fa-solid fa-volume-high"></i></button>
<button class="vc-btn vc-fullscreen" title="전체화면"><i class="fa-solid fa-expand"></i></button>
`;
wrap.appendChild(controls);
// 큰 가운데 재생 버튼 (정지 상태일 때 표시)
const bigPlay = document.createElement('button');
bigPlay.className = 'md-video-bigplay';
bigPlay.innerHTML = '<i class="fa-solid fa-play"></i>';
wrap.appendChild(bigPlay);
const playBtn = controls.querySelector('.vc-play');
const backBtn = controls.querySelector('.vc-back');
const fwdBtn = controls.querySelector('.vc-forward');
const muteBtn = controls.querySelector('.vc-mute');
const fullBtn = controls.querySelector('.vc-fullscreen');
const timeEl = controls.querySelector('.vc-time');
const progressEl = controls.querySelector('.vc-progress');
const fillEl = controls.querySelector('.vc-progress-fill');
const thumbEl = controls.querySelector('.vc-progress-thumb');
function fmt(s) {
if (isNaN(s) || !isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function togglePlay() {
if (video.paused) video.play();
else video.pause();
}
playBtn.addEventListener('click', togglePlay);
bigPlay.addEventListener('click', togglePlay);
video.addEventListener('click', togglePlay);
video.addEventListener('play', () => {
playBtn.innerHTML = '<i class="fa-solid fa-pause"></i>';
playBtn.title = '일시정지';
wrap.classList.add('playing');
});
video.addEventListener('pause', () => {
playBtn.innerHTML = '<i class="fa-solid fa-play"></i>';
playBtn.title = '재생';
wrap.classList.remove('playing');
});
backBtn.addEventListener('click', () => {
video.currentTime = Math.max(0, video.currentTime - 10);
});
fwdBtn.addEventListener('click', () => {
video.currentTime = Math.min(video.duration || 0, video.currentTime + 10);
});
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
muteBtn.innerHTML = video.muted
? '<i class="fa-solid fa-volume-xmark"></i>'
: '<i class="fa-solid fa-volume-high"></i>';
});
fullBtn.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrap.requestFullscreen();
}
});
// 시간 표시 + 진행 바
video.addEventListener('timeupdate', () => {
const dur = video.duration || 0;
const cur = video.currentTime || 0;
timeEl.textContent = `${fmt(cur)} / ${fmt(dur)}`;
const pct = dur > 0 ? (cur / dur * 100) : 0;
fillEl.style.width = pct + '%';
thumbEl.style.left = pct + '%';
});
video.addEventListener('loadedmetadata', () => {
timeEl.textContent = `0:00 / ${fmt(video.duration)}`;
// 비디오의 실제 표시 비율을 컨테이너에 적용 (세로 영상 등 대응)
if (video.videoWidth && video.videoHeight) {
wrap.style.aspectRatio = `${video.videoWidth} / ${video.videoHeight}`;
}
});
// 진행 바 클릭으로 시간 이동
progressEl.addEventListener('click', (e) => {
const rect = progressEl.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
video.currentTime = (video.duration || 0) * pct;
});
// 진행 바 드래그
let isDragging = false;
progressEl.addEventListener('mousedown', () => { isDragging = true; });
document.addEventListener('mouseup', () => { isDragging = false; });
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = progressEl.getBoundingClientRect();
let pct = (e.clientX - rect.left) / rect.width;
pct = Math.max(0, Math.min(1, pct));
video.currentTime = (video.duration || 0) * pct;
});
// 키보드 단축키 (포커스 받았을 때만)
wrap.tabIndex = 0;
wrap.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'k') {
e.preventDefault();
togglePlay();
} else if (e.key === 'ArrowLeft' || e.key === 'j') {
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 10);
} else if (e.key === 'ArrowRight' || e.key === 'l') {
e.preventDefault();
video.currentTime = Math.min(video.duration || 0, video.currentTime + 10);
} else if (e.key === 'm') {
e.preventDefault();
muteBtn.click();
} else if (e.key === 'f') {
e.preventDefault();
fullBtn.click();
}
});
});
}
// ===== 미디어 크기/위치 조절 (이미지 + 동영상) =====
// 글 상세 화면에서만 활성화 (편집 모달이 열려있을 때만 본문 수정 가능)
function attachMediaControls(rootEl) {
if (!rootEl) return;
// 이미지 래핑
rootEl.querySelectorAll('img').forEach(img => {
// 이미 래핑된 경우 스킵
if (img.closest('.md-media-wrapper')) return;
// 이모지/인라인 이미지는 스킵 (너무 작은 것)
if (img.width < 30) return;
const wrap = document.createElement('div');
wrap.className = 'md-media-wrapper';
// 기존 인라인 스타일에서 align 읽기
const style = img.getAttribute('style') || '';
const wMatch = style.match(/width:\s*([^;]+)/);
const curWidth = wMatch ? wMatch[1].trim() : '';
const isCenter = style.includes('margin-left:auto') && style.includes('margin-right:auto');
const isRight = style.includes('margin-right:0') || style.includes('margin-right: 0');
const curAlign = isCenter ? 'center' : (isRight ? 'right' : 'left');
if (curAlign !== 'left') wrap.classList.add(`align-${curAlign}`);
if (curWidth) wrap.style.width = curWidth;
img.parentNode.insertBefore(wrap, img);
wrap.appendChild(img);
img.style.removeProperty('margin-left');
img.style.removeProperty('margin-right');
img.style.removeProperty('display');
addMediaToolbar(wrap, 'image', curWidth, curAlign);
});
// 동영상 래핑
rootEl.querySelectorAll('.md-video-wrap').forEach(videoWrap => {
if (videoWrap.closest('.md-media-wrapper')) return;
const wrap = document.createElement('div');
wrap.className = 'md-media-wrapper';
const wStyle = videoWrap.style.width || '';
const dStyle = videoWrap.style.marginLeft;
const isCenter = dStyle === 'auto' || videoWrap.classList.contains('align-center');
const isRight = videoWrap.classList.contains('align-right');
const curAlign = isCenter ? 'center' : (isRight ? 'right' : 'left');
if (curAlign !== 'left') wrap.classList.add(`align-${curAlign}`);
if (wStyle) wrap.style.width = wStyle;
videoWrap.parentNode.insertBefore(wrap, videoWrap);
wrap.appendChild(videoWrap);
videoWrap.style.width = '100%';
addMediaToolbar(wrap, 'video', wStyle, curAlign);
});
}
function addMediaToolbar(wrap, kind, curWidth, curAlign) {
const toolbar = document.createElement('div');
toolbar.className = 'md-media-toolbar';
const alignOptions = [
{ val: 'left', icon: 'fa-align-left' },
{ val: 'center', icon: 'fa-align-center' },
{ val: 'right', icon: 'fa-align-right' }
];
alignOptions.forEach(opt => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'md-toolbar-btn' + (curAlign === opt.val ? ' active' : '');
btn.innerHTML = `<i class="fa-solid ${opt.icon}"></i>`;
btn.title = opt.val === 'left' ? '왼쪽' : opt.val === 'center' ? '가운데' : '오른쪽';
btn.addEventListener('click', (e) => {
e.stopPropagation();
// 기존 align 클래스 제거
wrap.classList.remove('align-left', 'align-center', 'align-right');
if (opt.val !== 'left') wrap.classList.add(`align-${opt.val}`);
// 버튼 active 상태 갱신
toolbar.querySelectorAll('.md-toolbar-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 본문 마크다운 업데이트 (에디터가 열려있을 때)
updateMarkdownInEditor(wrap, kind);
});
toolbar.appendChild(btn);
});
// 구분선
const div1 = document.createElement('span');
div1.className = 'md-toolbar-divider';
toolbar.appendChild(div1);
// 너비 입력
const sizeInput = document.createElement('input');
sizeInput.type = 'text';
sizeInput.className = 'md-toolbar-size-input';
sizeInput.placeholder = '너비';
sizeInput.title = '너비 입력 (예: 400px, 50%, 100%)';
sizeInput.value = curWidth || '';
sizeInput.addEventListener('click', (e) => e.stopPropagation());
sizeInput.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') {
applyWidth(wrap, sizeInput.value.trim(), kind);
sizeInput.blur();
}
});
sizeInput.addEventListener('blur', () => {
applyWidth(wrap, sizeInput.value.trim(), kind);
});
toolbar.appendChild(sizeInput);
// 원본 크기 버튼
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'md-toolbar-btn';
resetBtn.innerHTML = '<i class="fa-solid fa-maximize"></i>';
resetBtn.title = '원본 크기 (100%)';
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
sizeInput.value = '';
applyWidth(wrap, '', kind);
});
toolbar.appendChild(resetBtn);
wrap.appendChild(toolbar);
// resize 핸들 (우하단 드래그)
const handle = document.createElement('div');
handle.className = 'md-resize-handle';
wrap.appendChild(handle);
let startX, startWidth;
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startWidth = wrap.offsetWidth;
const onMove = (mv) => {
const delta = mv.clientX - startX;
const newW = Math.max(80, startWidth + delta);
wrap.style.width = newW + 'px';
sizeInput.value = newW + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
applyWidth(wrap, sizeInput.value.trim(), kind);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
function applyWidth(wrap, widthVal, kind) {
widthVal = sanitizeMediaWidth(widthVal);
wrap.style.width = widthVal || '';
// 툴바 input 동기화
const input = wrap.querySelector('.md-toolbar-size-input');
if (input) input.value = widthVal || '';
updateMarkdownInEditor(wrap, kind);
}
// 글 수정 모달이 열려있으면 본문 마크다운도 업데이트
function updateMarkdownInEditor(wrap, kind) {
const ta = document.getElementById('postContent');
if (!ta || !document.getElementById('postModal').classList.contains('active')) return;
// 현재 상태 읽기
const widthVal = wrap.style.width || '';
const align = wrap.classList.contains('align-center') ? 'center'
: wrap.classList.contains('align-right') ? 'right'
: '';
const attrs = [
widthVal ? `w=${widthVal}` : '',
align
].filter(Boolean).join(',');
// 이미지/비디오의 실제 src 찾기
let src = '';
if (kind === 'image') {
const img = wrap.querySelector('img');
src = img ? img.getAttribute('src') : '';
} else {
const vid = wrap.querySelector('video');
src = vid ? vid.getAttribute('src') : '';
}
if (!src) return;
// 마크다운에서 해당 src를 포함한 라인 찾아서 교체
const lines = ta.value.split('\n');
const updated = lines.map(line => {
if (kind === 'image') {
// ![alt|old_attrs](src) 또는 ![alt](src)
const re = /^(!\[[^\]]*?)(?:\|[^\]]*)?(\]\()([^)]+)(\))(.*)$/;
const m = line.match(re);
if (m && m[3] === src) {
const altPart = m[1];
const newLine = attrs
? `${altPart}|${attrs}${m[2]}${src}${m[4]}${m[5]}`
: `${altPart}${m[2]}${src}${m[4]}${m[5]}`;
return newLine;
}
} else {
// @video[old_attrs](src) 또는 @video(src)
const re = /^@video(?:\[[^\]]*\])?\(([^)]+)\)(.*)$/;
const m = line.match(re);
if (m && m[1] === src) {
return attrs ? `@video[${attrs}](${src})${m[2]}` : `@video(${src})${m[2]}`;
}
}
return line;
});
ta.value = updated.join('\n');
}
async function init() {
await checkAuth();
await loadCategories();
await loadPosts();
// 처음에는 모든 큰 카테고리 펼쳐서 시작
categories.filter(c => !c.parent_id).forEach(c => expandedParents.add(c.id));
renderCalendar();
renderCategoryList();
renderActiveFilters();
renderPosts();
}
async function checkAuth() {
try {
const res = await fetch('api/auth.php?action=check');
const data = await res.json();
isAdmin = data.authenticated === true;
csrfToken = data.csrf_token || '';
document.body.classList.toggle('admin-on', isAdmin);
} catch (e) { isAdmin = false; }
}
function csrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
function csrfFetch(url, options = {}) {
return fetch(url, { ...options, headers: csrfHeaders(options.headers || {}) });
}
function openLoginModal() {
if (isAdmin) { logout(); return; }
document.getElementById('loginModal').classList.add('active');
document.getElementById('loginPassword').focus();
}
function closeLoginModal() {
document.getElementById('loginModal').classList.remove('active');
document.getElementById('loginPassword').value = '';
document.getElementById('loginAlert').innerHTML = '';
}
async function doLogin() {
const password = document.getElementById('loginPassword').value;
if (!password) return;
try {
const res = await fetch('api/auth.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (data.success) {
csrfToken = data.csrf_token || csrfToken;
isAdmin = true;
document.body.classList.add('admin-on');
closeLoginModal();
} else {
showAlert('loginAlert', data.error || '로그인 실패', 'error');
}
} catch (e) {
showAlert('loginAlert', '서버 오류', 'error');
}
}
async function logout() {
await csrfFetch('api/auth.php?action=logout', { method: 'POST' });
csrfToken = '';
isAdmin = false;
document.body.classList.remove('admin-on');
}
// ===== 데이터 로드 =====
async function loadCategories() {
try {
const res = await fetch('api/categories.php');
categories = await res.json();
} catch (e) {
console.error('Failed to load categories', e);
categories = [];
}
}
async function loadPosts() {
try {
const res = await fetch('api/learning.php');
posts = await res.json();
} catch (e) {
console.error('Failed to load posts', e);
posts = [];
}
}
function getCategoryById(id) {
return categories.find(c => c.id === id);
}
function getParentCategories() {
return categories.filter(c => !c.parent_id);
}
function getChildCategories(parentId) {
return categories.filter(c => c.parent_id === parentId);
}
// 글의 부모 카테고리 ID 찾기
function getParentIdOfPost(post) {
const cat = getCategoryById(post.category_id);
return cat ? cat.parent_id : null;
}
// ===== 캘린더 =====
function renderCalendar() {
const cells = document.getElementById('calendarCells');
const labels = document.getElementById('calendarMonthLabels');
const today = new Date();
today.setHours(0, 0, 0, 0);
const start = new Date(today);
start.setFullYear(start.getFullYear() - 1);
while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
const countByDate = {};
posts.forEach(p => {
const d = (p.created_at || '').trim();
if (!d) return;
countByDate[d] = (countByDate[d] || 0) + 1;
});
cells.innerHTML = '';
let weekIndex = 0;
let currentDate = new Date(start);
let totalPosts = 0;
while (currentDate <= today) {
const dateStr = formatDateKey(currentDate);
const count = countByDate[dateStr] || 0;
if (count > 0) totalPosts += count;
const cell = document.createElement('div');
cell.className = 'cal-cell';
if (count > 0) {
cell.classList.add('has-post');
cell.dataset.count = Math.min(count, 5);
}
if (dateStr === formatDateKey(today)) cell.classList.add('today');
if (dateStr === dateFilter) cell.classList.add('selected');
cell.dataset.date = dateStr;
cell.dataset.postCount = count;
cell.addEventListener('mouseenter', showCalTooltip);
cell.addEventListener('mouseleave', hideCalTooltip);
cell.addEventListener('click', () => filterByDate(dateStr));
cells.appendChild(cell);
currentDate.setDate(currentDate.getDate() + 1);
if (currentDate.getDay() === 0) weekIndex++;
}
document.getElementById('calendarStats').textContent = `${totalPosts} posts`;
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
labels.innerHTML = '';
const weekCount = Math.ceil((today - start) / (7 * 24 * 60 * 60 * 1000)) + 1;
let lastMonth = -1;
for (let w = 0; w < weekCount; w++) {
const d = new Date(start);
d.setDate(d.getDate() + w * 7);
if (d.getMonth() !== lastMonth && d.getDate() <= 7) {
const span = document.createElement('span');
span.style.position = 'absolute';
// 셀 그리드 안에서 비율로 위치 잡기 (셀 크기 가변)
span.style.left = (w / weekCount * 100) + '%';
span.textContent = monthNames[d.getMonth()];
labels.appendChild(span);
lastMonth = d.getMonth();
}
}
labels.style.position = 'relative';
labels.style.height = '16px';
}
function showCalTooltip(e) {
const cell = e.currentTarget;
const date = cell.dataset.date;
const count = parseInt(cell.dataset.postCount) || 0;
const tooltip = document.getElementById('calTooltip');
tooltip.textContent = count > 0
? `${date} · ${count}개의 글`
: `${date} · 글 없음`;
tooltip.style.display = 'block';
const rect = cell.getBoundingClientRect();
tooltip.style.left = (rect.left + rect.width / 2 - tooltip.offsetWidth / 2) + 'px';
tooltip.style.top = (rect.top - tooltip.offsetHeight - 8) + 'px';
}
function hideCalTooltip() {
document.getElementById('calTooltip').style.display = 'none';
}
function filterByDate(dateStr) {
// 같은 날짜를 다시 클릭하면 해제
if (dateFilter === dateStr) {
clearDateFilter();
return;
}
dateFilter = dateStr;
renderCalendar(); // selected 표시 갱신
renderActiveFilters();
renderPosts();
document.getElementById('mainLayout').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function clearDateFilter() {
dateFilter = null;
renderCalendar();
renderActiveFilters();
renderPosts();
}
function renderActiveFilters() {
const wrap = document.getElementById('activeFilters');
const calBtn = document.getElementById('calClearBtn');
if (calBtn) {
calBtn.style.display = dateFilter ? 'inline-flex' : 'none';
}
if (!wrap) return;
const chips = [];
if (dateFilter) {
chips.push(`
<div class="filter-chip">
<i class="fa-solid fa-calendar-day"></i>
${escapeHtml(dateFilter)}
<button onclick="clearDateFilter()" title="해제">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
`);
}
wrap.innerHTML = chips.join('');
}
function formatDateKey(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// ===== 사이드바 (계층형) =====
function renderCategoryList() {
const list = document.getElementById('catList');
// 글 개수 집계
const countByCategoryId = {};
posts.forEach(p => {
countByCategoryId[p.category_id] = (countByCategoryId[p.category_id] || 0) + 1;
});
const items = [];
// 전체
items.push(`
<div class="cat-item ${currentFilter.type === 'all' ? 'active' : ''}"
onclick="setFilter('all')">
<div class="cat-name">
<span class="cat-dot" style="background: var(--text-dim);"></span>
<span class="cat-label">전체</span>
</div>
<span class="cat-count">${posts.length}</span>
</div>
<div class="cat-divider"></div>
`);
const parents = getParentCategories();
if (parents.length === 0) {
items.push(`<div class="cat-empty">카테고리가 없습니다.${isAdmin ? '<br>+ 버튼으로 추가하세요.' : ''}</div>`);
}
parents.forEach(parent => {
const children = getChildCategories(parent.id);
// 자식들의 글 합계
const childIds = children.map(c => c.id);
const parentTotalCount = childIds.reduce((sum, id) => sum + (countByCategoryId[id] || 0), 0);
const isExpanded = expandedParents.has(parent.id);
const isParentActive = currentFilter.type === 'parent' && currentFilter.value === parent.id;
// 큰 카테고리 행
items.push(`
<div class="cat-parent ${isExpanded ? 'expanded' : ''}" data-parent-id="${parent.id}">
<div class="cat-item ${isParentActive ? 'active' : ''}"
onclick="onParentClick(${parent.id})">
<div class="cat-name">
<i class="fa-solid fa-chevron-right cat-toggle"></i>
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(parent.color, '#00f2ff'))};"></span>
<span class="cat-label">${escapeHtml(parent.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span class="cat-count">${parentTotalCount}</span>
<div class="cat-actions">
<button class="cat-action-btn add" onclick="event.stopPropagation(); addSubCategory(${parent.id})" title="서브 카테고리 추가">
<i class="fa-solid fa-plus"></i>
</button>
<button class="cat-action-btn" onclick="event.stopPropagation(); editCategory(${parent.id})" title="수정">
<i class="fa-solid fa-pen"></i>
</button>
<button class="cat-action-btn del" onclick="event.stopPropagation(); deleteCategory(${parent.id})" title="삭제">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
<div class="cat-children">
`);
if (children.length === 0) {
items.push(`
<div class="cat-empty" style="font-size: 0.75rem; padding: 8px;">
서브 카테고리 없음${isAdmin ? '' : ''}
</div>
`);
} else {
children.forEach(child => {
const count = countByCategoryId[child.id] || 0;
const isActive = currentFilter.type === 'category' && currentFilter.value === child.id;
items.push(`
<div class="cat-item ${isActive ? 'active' : ''}"
onclick="setFilter('category', ${child.id})">
<div class="cat-name">
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(child.color, '#00f2ff'))};"></span>
<span class="cat-label">${escapeHtml(child.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<span class="cat-count">${count}</span>
<div class="cat-actions">
<button class="cat-action-btn" onclick="event.stopPropagation(); editCategory(${child.id})" title="수정">
<i class="fa-solid fa-pen"></i>
</button>
<button class="cat-action-btn del" onclick="event.stopPropagation(); deleteCategory(${child.id})" title="삭제">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
`);
});
}
items.push(`
</div>
</div>
`);
});
list.innerHTML = items.join('');
}
// 큰 카테고리 클릭: 펼침/접힘 토글 + 필터 적용
function onParentClick(parentId) {
const wasExpanded = expandedParents.has(parentId);
const wasActive = currentFilter.type === 'parent' && currentFilter.value === parentId;
if (wasActive && wasExpanded) {
// 활성 + 펼침 → 접고 전체로
expandedParents.delete(parentId);
currentFilter = { type: 'all', value: null };
} else if (wasExpanded) {
// 펼침만 → 활성화 (이미 펼친 상태)
currentFilter = { type: 'parent', value: parentId };
} else {
// 접힘 → 펼치고 활성화
expandedParents.add(parentId);
currentFilter = { type: 'parent', value: parentId };
}
renderCategoryList();
renderPosts();
}
function setFilter(type, value = null) {
currentFilter = { type, value };
// 서브 카테고리 클릭 시: 부모도 펼치기
if (type === 'category') {
const cat = getCategoryById(value);
if (cat && cat.parent_id) expandedParents.add(cat.parent_id);
}
renderCategoryList();
renderPosts();
}
// ===== 검색 범위 변경 =====
function onSearchScopeChange() {
const scope = document.getElementById('searchScope').value;
const input = document.getElementById('searchInput');
// placeholder 변경
const placeholders = {
all: '🔍 전체 검색...',
title: '🔍 제목 검색...',
content: '🔍 내용 검색...',
tags: '🔍 태그 검색... (예: Unity)',
date: '🔍 날짜 검색... (예: 2026-05)'
};
input.placeholder = placeholders[scope] || '🔍 검색...';
// 날짜 범위 선택 시 select 색상 표시
const sel = document.getElementById('searchScope');
if (scope === 'date') {
sel.classList.add('scope-date');
input.style.fontFamily = "'JetBrains Mono', monospace";
} else {
sel.classList.remove('scope-date');
input.style.fontFamily = '';
}
// 검색어 초기화 후 재렌더
input.value = '';
renderPosts();
input.focus();
}
// ===== 글 목록 =====
function renderPosts() {
const list = document.getElementById('postList');
const titleEl = document.getElementById('currentSectionTitle');
const countEl = document.getElementById('postCount');
const search = document.getElementById('searchInput').value.trim().toLowerCase();
const sort = document.getElementById('sortSelect').value;
// 제목 갱신
if (currentFilter.type === 'all') titleEl.textContent = '전체';
else if (currentFilter.type === 'parent') {
const parent = getCategoryById(currentFilter.value);
titleEl.textContent = parent ? parent.name : '카테고리';
}
else if (currentFilter.type === 'category') {
const cat = getCategoryById(currentFilter.value);
const parent = cat ? getCategoryById(cat.parent_id) : null;
titleEl.textContent = parent ? `${parent.name} ${cat.name}` : (cat ? cat.name : '카테고리');
}
// 필터링
let filtered = posts.slice();
if (currentFilter.type === 'parent') {
// 큰 카테고리: 자식들의 글 모두
const childIds = getChildCategories(currentFilter.value).map(c => c.id);
filtered = filtered.filter(p => childIds.includes(p.category_id));
} else if (currentFilter.type === 'category') {
filtered = filtered.filter(p => p.category_id === currentFilter.value);
}
// 날짜 필터
if (dateFilter) {
filtered = filtered.filter(p => {
const postDate = (p.created_at || '').trim();
return postDate === dateFilter;
});
}
// 검색 (scope에 따라 분기)
if (search) {
const scope = document.getElementById('searchScope').value;
filtered = filtered.filter(p => {
const title = (p.title || '').toLowerCase();
const content = (p.content || '').toLowerCase();
const tags = (p.tags || []).join(' ').toLowerCase();
const date = (p.created_at || '').trim().toLowerCase();
switch (scope) {
case 'title': return title.includes(search);
case 'content': return content.includes(search);
case 'tags': return tags.includes(search);
case 'date': return date.includes(search);
default: return title.includes(search) || content.includes(search)
|| tags.includes(search) || date.includes(search);
}
});
}
// 정렬
filtered.sort((a, b) => {
if (sort === 'oldest') {
return (a.created_at || '').localeCompare(b.created_at || '');
} else if (sort === 'updated') {
return (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '');
}
return (b.created_at || '').localeCompare(a.created_at || '');
});
countEl.textContent = `${filtered.length} post${filtered.length !== 1 ? 's' : ''}`;
if (filtered.length === 0) {
list.innerHTML = `
<div class="empty-state">
<i class="fa-solid fa-folder-open"></i>
<p>${search ? '검색 결과가 없습니다' : '아직 글이 없습니다'}</p>
</div>
`;
return;
}
list.innerHTML = filtered.map(p => {
const cat = getCategoryById(p.category_id);
const parent = cat ? getCategoryById(cat.parent_id) : null;
const catColor = cat ? sanitizeCssColor(cat.color, '#00f2ff') : '#00f2ff';
const preview = stripMarkdown(p.content || '').slice(0, 200);
return `
<div class="post-card" onclick="openPostDetail(${p.id})" style="--cat-color: ${escapeHtml(catColor)};">
<div class="post-card-header">
<h3>${escapeHtml(p.title)}</h3>
<div class="post-meta">
<span>${escapeHtml(p.created_at || '')}</span>
</div>
</div>
${cat ? `
<div class="post-cat-pill">
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} </span>` : ''}
${escapeHtml(cat.name)}
</div>
` : ''}
<p class="post-preview">${escapeHtml(preview)}</p>
${p.tags && p.tags.length > 0 ? `
<div class="post-tags">
${p.tags.map(t => `<span class="post-tag">#${escapeHtml(t)}</span>`).join('')}
</div>
` : ''}
</div>
`;
}).join('');
}
function stripMarkdown(md) {
return md
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/[#*_~>\-\[\]\(\)]/g, ' ')
.replace(/!\[.*?\]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
// ===== 글 상세 =====
function openPostDetail(id) {
const p = posts.find(x => x.id === id);
if (!p) return;
currentDetailId = id;
const cat = getCategoryById(p.category_id);
const parent = cat ? getCategoryById(cat.parent_id) : null;
document.getElementById('detailTitle').textContent = p.title;
const metaParts = [];
if (cat) {
metaParts.push(`
<div class="post-cat-pill">
<span class="cat-dot" style="background: ${escapeHtml(sanitizeCssColor(cat.color, '#00f2ff'))};"></span>
${parent ? `<span style="opacity: 0.7;">${escapeHtml(parent.name)} </span>` : ''}
${escapeHtml(cat.name)}
</div>
`);
}
metaParts.push(`<span><i class="fa-solid fa-calendar"></i> ${escapeHtml(p.created_at || '')}</span>`);
if (p.updated_at && p.updated_at !== p.created_at) {
metaParts.push(`<span style="opacity: 0.6;">수정: ${escapeHtml(p.updated_at)}</span>`);
}
if (p.tags && p.tags.length > 0) {
metaParts.push(`<div class="post-tags">${p.tags.map(t => `<span class="post-tag">#${escapeHtml(t)}</span>`).join('')}</div>`);
}
document.getElementById('detailMeta').innerHTML = metaParts.join('');
document.getElementById('detailContent').innerHTML = renderMarkdown(p.content || '');
document.querySelectorAll('#detailContent pre code').forEach(block => {
hljs.highlightElement(block);
});
attachVideoControls(document.getElementById('detailContent'));
// 관리자일 때만 크기/위치 조절 UI 활성화
if (isAdmin) {
attachMediaControls(document.getElementById('detailContent'));
}
document.getElementById('postDetailModal').classList.add('active');
}
function closePostDetail() {
document.getElementById('postDetailModal').classList.remove('active');
currentDetailId = null;
}
function editCurrentPost() {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
if (currentDetailId === null) return;
const id = currentDetailId;
closePostDetail();
openPostModal(posts.find(p => p.id === id));
}
// 글 본문에서 uploads/ 로 시작하는 미디어 URL 추출
function extractAttachedMediaUrls(content) {
if (!content) return [];
const urls = new Set();
// 이미지 마크다운: ![alt](url) 또는 ![alt|attrs](url)
const imgRe = /!\[[^\]]*\]\((uploads\/[^)\s]+)\)/g;
let m;
while ((m = imgRe.exec(content)) !== null) {
urls.add(m[1]);
}
// 비디오: @video[attrs](url) 또는 @video(url)
const vidRe = /@video(?:\[[^\]]*\])?\((uploads\/[^)\s]+)\)/g;
while ((m = vidRe.exec(content)) !== null) {
urls.add(m[1]);
}
return Array.from(urls);
}
async function deleteCurrentPost() {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
if (currentDetailId === null) return;
const post = posts.find(p => p.id === currentDetailId);
const attachedUrls = post ? extractAttachedMediaUrls(post.content || '') : [];
let confirmMsg = '정말 이 글을 삭제하시겠습니까?';
let deleteFiles = false;
if (attachedUrls.length > 0) {
confirmMsg = `이 글에 첨부된 미디어 파일이 ${attachedUrls.length}개 있습니다.\n\n` +
`[확인] 글 + 파일 모두 삭제\n` +
`[취소] 아무것도 삭제 안 함\n\n` +
`(글만 남기고 파일만 지우거나 그 반대는 다음 안내에서 선택)`;
const answer = confirm(confirmMsg);
if (!answer) return;
// 한 번 더 물어서 정확히 어떻게 처리할지 결정
deleteFiles = confirm(
`첨부 파일 ${attachedUrls.length}개도 함께 영구 삭제하시겠습니까?\n\n` +
`[확인] 파일도 삭제 (권장)\n` +
`[취소] 글만 삭제하고 파일은 보존`
);
} else {
if (!confirm(confirmMsg)) return;
}
try {
// 글 삭제
const res = await csrfFetch(`api/learning.php?id=${currentDetailId}`, { method: 'DELETE' });
const result = await res.json();
if (!result.success) {
alert(result.error || '글 삭제 실패');
return;
}
// 첨부 파일 삭제 (선택했을 때)
if (deleteFiles && attachedUrls.length > 0) {
try {
const fileRes = await csrfFetch('api/delete_files.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: attachedUrls })
});
const fileResult = await fileRes.json();
if (fileResult.success) {
if (fileResult.failed_count > 0) {
alert(`글은 삭제되었지만, ${fileResult.failed_count}개 파일 삭제에 실패했습니다.`);
}
}
} catch (e) {
alert('글은 삭제되었지만, 파일 삭제 중 오류가 발생했습니다: ' + e.message);
}
}
closePostDetail();
await loadPosts();
renderCalendar();
renderCategoryList();
renderPosts();
} catch (e) { alert('서버 오류'); }
}
// ===== 글 작성 모달 =====
function openPostModal(post = null) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
document.getElementById('postAlert').innerHTML = '';
// 부모 카테고리 옵션 채우기
const parentSel = document.getElementById('postParentCategory');
const parents = getParentCategories();
if (parents.length === 0) {
showAlert('postAlert', '먼저 카테고리를 만들어주세요. (좌측 사이드바 + 버튼)', 'error');
return;
}
parentSel.innerHTML = parents.map(p =>
`<option value="${p.id}">${escapeHtml(p.name)}</option>`
).join('');
let initialParentId = parents[0].id;
let initialChildId = null;
if (post) {
document.getElementById('postModalTitle').textContent = '글 수정';
document.getElementById('postId').value = post.id;
document.getElementById('postTitle').value = post.title || '';
document.getElementById('postDate').value = post.created_at || '';
document.getElementById('postTags').value = (post.tags || []).join(', ');
document.getElementById('postContent').value = post.content || '';
const cat = getCategoryById(post.category_id);
if (cat) {
initialParentId = cat.parent_id || initialParentId;
initialChildId = cat.id;
}
} else {
document.getElementById('postModalTitle').textContent = '새 글';
document.getElementById('postId').value = '';
document.getElementById('postTitle').value = '';
document.getElementById('postDate').value = formatDateKey(new Date());
document.getElementById('postTags').value = '';
document.getElementById('postContent').value = '';
// 현재 필터 기준으로 기본값 설정
if (currentFilter.type === 'category') {
const cat = getCategoryById(currentFilter.value);
if (cat) {
initialParentId = cat.parent_id || initialParentId;
initialChildId = cat.id;
}
} else if (currentFilter.type === 'parent') {
initialParentId = currentFilter.value;
}
}
parentSel.value = initialParentId;
updatePostSubCategoryOptions(initialChildId);
switchEditorTab('write');
document.getElementById('postModal').classList.add('active');
}
function updatePostSubCategoryOptions(preselectChildId = null) {
const parentSel = document.getElementById('postParentCategory');
const childSel = document.getElementById('postCategory');
const parentId = parseInt(parentSel.value);
const children = getChildCategories(parentId);
if (children.length === 0) {
childSel.innerHTML = '<option value="">서브 카테고리 없음 - 먼저 추가하세요</option>';
childSel.disabled = true;
} else {
childSel.disabled = false;
childSel.innerHTML = children.map(c =>
`<option value="${c.id}">${escapeHtml(c.name)}</option>`
).join('');
if (preselectChildId && children.some(c => c.id === preselectChildId)) {
childSel.value = preselectChildId;
}
}
}
function closePostModal() {
// 사용되지 않은 임시 blob URL 해제
for (const [blobUrl] of pendingMediaFiles.entries()) {
URL.revokeObjectURL(blobUrl);
}
pendingMediaFiles.clear();
document.getElementById('postModal').classList.remove('active');
}
function switchEditorTab(tab) {
document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.editor-pane').forEach(p => p.classList.remove('active'));
if (tab === 'write') {
document.querySelectorAll('.editor-tab')[0].classList.add('active');
document.getElementById('editorWrite').classList.add('active');
} else {
document.querySelectorAll('.editor-tab')[1].classList.add('active');
document.getElementById('editorPreview').classList.add('active');
const content = document.getElementById('postContent').value;
const preview = document.getElementById('postContentPreview');
preview.innerHTML = renderMarkdown(content || '_(내용이 비어있습니다)_');
preview.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
attachVideoControls(preview);
}
}
// ===== 마크다운 에디터 툴바 =====
function getEditorState() {
const ta = document.getElementById('postContent');
return {
ta,
start: ta.selectionStart,
end: ta.selectionEnd,
text: ta.value,
selected: ta.value.substring(ta.selectionStart, ta.selectionEnd)
};
}
// 선택 영역을 prefix + selection + suffix로 감싸기
function wrapSelection(prefix, suffix, placeholder = '') {
const { ta, start, end, text, selected } = getEditorState();
const replacement = (selected || placeholder);
const newText = text.substring(0, start) + prefix + replacement + suffix + text.substring(end);
ta.value = newText;
ta.focus();
if (selected) {
ta.setSelectionRange(start + prefix.length, start + prefix.length + replacement.length);
} else {
ta.setSelectionRange(start + prefix.length, start + prefix.length + replacement.length);
}
}
// 새 줄에 블록 삽입
function insertBlock(block) {
const { ta, start, text } = getEditorState();
// 현재 라인의 시작 찾기
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--;
// 현재 라인이 비어있는지 확인
let lineEnd = start;
while (lineEnd < text.length && text[lineEnd] !== '\n') lineEnd++;
const currentLine = text.substring(lineStart, lineEnd);
const isEmptyLine = currentLine.trim() === '';
let prefix = '';
if (!isEmptyLine) prefix = '\n';
if (lineStart > 0 && text[lineStart - 2] !== '\n' && !isEmptyLine) prefix = '\n';
const suffix = '\n';
const insertAt = isEmptyLine ? lineStart : lineEnd;
const newText = text.substring(0, insertAt) + prefix + block + suffix + text.substring(insertAt);
ta.value = newText;
ta.focus();
const cursorPos = insertAt + prefix.length + block.length + suffix.length;
ta.setSelectionRange(cursorPos, cursorPos);
}
function mdInsert(type) {
switch (type) {
case 'bold':
wrapSelection('**', '**', '굵은 글씨');
break;
case 'italic':
wrapSelection('*', '*', '기울임');
break;
case 'strike':
wrapSelection('~~', '~~', '취소선');
break;
case 'code':
wrapSelection('`', '`', '코드');
break;
case 'highlight':
openHighlightPicker();
break;
case 'color':
openColorPicker();
break;
case 'link': {
const { selected } = getEditorState();
const linkText = selected || '링크 텍스트';
wrapSelection('[' + linkText + '](', ')', 'https://');
break;
}
case 'quote':
insertBlock('> 인용문을 작성하세요');
break;
case 'callout':
openCalloutModal();
break;
case 'codeblock':
insertBlock('```javascript\n// 코드를 작성하세요\n```');
break;
case 'heading2':
insertBlock('## 제목');
break;
case 'heading3':
insertBlock('### 소제목');
break;
case 'ul':
insertBlock('- 항목 1\n- 항목 2\n- 항목 3');
break;
case 'ol':
insertBlock('1. 항목 1\n2. 항목 2\n3. 항목 3');
break;
case 'check':
insertBlock('- [ ] 할 일 1\n- [ ] 할 일 2\n- [x] 완료한 일');
break;
case 'hr':
insertBlock('---');
break;
case 'table':
insertBlock('| 헤더1 | 헤더2 | 헤더3 |\n| --- | --- | --- |\n| 셀1 | 셀2 | 셀3 |\n| 셀4 | 셀5 | 셀6 |');
break;
}
}
// 텍스트에어리어에 직접 텍스트 삽입 (커서 위치에)
function insertAtCursor(textToInsert) {
const { ta, start, end, text } = getEditorState();
ta.value = text.substring(0, start) + textToInsert + text.substring(end);
ta.focus();
const newPos = start + textToInsert.length;
ta.setSelectionRange(newPos, newPos);
}
// ===== 미디어 삽입 (이미지 + 동영상) =====
let imageInsertInitialized = false;
function openImageInsert() {
document.getElementById('imageInsertAlert').innerHTML = '';
document.getElementById('imageInsertUrl').value = '';
document.getElementById('imageInsertAlt').value = '';
document.getElementById('imageInsertKind').value = 'image';
document.getElementById('mediaWidth').value = '';
document.getElementById('mediaWidthCustom').value = '';
document.getElementById('mediaWidthCustom').style.display = 'none';
document.getElementById('mediaAlign').value = '';
document.getElementById('postImageProgress').classList.remove('active');
document.getElementById('postImageProgressFill').style.width = '0%';
if (!imageInsertInitialized) {
initPostImageUploader();
initPostFileUploader();
document.getElementById('mediaWidth').addEventListener('change', (e) => {
const cust = document.getElementById('mediaWidthCustom');
cust.style.display = e.target.value === 'custom' ? 'block' : 'none';
if (e.target.value === 'custom') cust.focus();
});
imageInsertInitialized = true;
}
switchImageTab('upload');
document.getElementById('imageInsertModal').classList.add('active');
}
function closeImageInsert() {
document.getElementById('imageInsertModal').classList.remove('active');
}
function switchImageTab(tab) {
document.querySelectorAll('.img-insert-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.img-insert-pane').forEach(p => p.classList.remove('active'));
const urlBtn = document.getElementById('insertMediaUrlBtn');
const mediaOptions = document.getElementById('mediaOptionsWrap');
if (tab === 'upload') {
document.querySelectorAll('.img-insert-tab')[0].classList.add('active');
document.getElementById('imgPaneUpload').classList.add('active');
if (urlBtn) urlBtn.style.display = 'none';
const fileBtn = document.getElementById('insertFileBtn');
if (fileBtn) fileBtn.style.display = 'none';
if (mediaOptions) mediaOptions.style.display = 'block';
} else if (tab === 'file') {
document.querySelectorAll('.img-insert-tab')[1].classList.add('active');
document.getElementById('imgPaneFile').classList.add('active');
if (urlBtn) urlBtn.style.display = 'none';
const fileBtn = document.getElementById('insertFileBtn');
if (fileBtn) fileBtn.style.display = 'inline-flex';
if (mediaOptions) mediaOptions.style.display = 'none';
} else {
document.querySelectorAll('.img-insert-tab')[2].classList.add('active');
document.getElementById('imgPaneUrl').classList.add('active');
if (urlBtn) urlBtn.style.display = 'inline-flex';
if (mediaOptions) mediaOptions.style.display = 'block';
setTimeout(() => document.getElementById('imageInsertUrl').focus(), 50);
}
}
// ===== 파일 첨부 (이미지/영상 외 일반 파일) =====
let pendingAttachFile = null;
function initPostFileUploader() {
const uploader = document.getElementById('postFileUploader');
const fileInput = document.getElementById('postFileInput');
if (!uploader || !fileInput) return;
uploader.addEventListener('click', (e) => {
if (e.target === fileInput) return;
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const f = e.target.files[0];
if (f) setAttachFile(f);
fileInput.value = '';
});
['dragenter', 'dragover'].forEach(ev => {
uploader.addEventListener(ev, (e) => {
e.preventDefault(); e.stopPropagation();
uploader.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
uploader.addEventListener(ev, (e) => {
e.preventDefault(); e.stopPropagation();
uploader.classList.remove('dragover');
});
});
uploader.addEventListener('drop', (e) => {
const f = e.dataTransfer.files[0];
if (f) setAttachFile(f);
});
}
function setAttachFile(file) {
pendingAttachFile = file;
document.getElementById('fileAttachName').textContent = file.name;
document.getElementById('fileAttachSize').textContent = formatFileSize(file.size);
document.getElementById('fileAttachPreview').style.display = 'block';
document.getElementById('postFileUploader').style.display = 'none';
}
function clearFileAttach() {
pendingAttachFile = null;
document.getElementById('fileAttachPreview').style.display = 'none';
document.getElementById('postFileUploader').style.display = '';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
// 파일 아이콘 (확장자별)
function getFileIcon(filename) {
const ext = (filename.split('.').pop() || '').toLowerCase();
const map = {
pdf: 'fa-file-pdf',
zip: 'fa-file-zipper', '7z': 'fa-file-zipper', rar: 'fa-file-zipper',
xls: 'fa-file-excel', xlsx: 'fa-file-excel',
doc: 'fa-file-word', docx: 'fa-file-word',
ppt: 'fa-file-powerpoint', pptx: 'fa-file-powerpoint',
txt: 'fa-file-lines', md: 'fa-file-lines',
csv: 'fa-file-csv',
json: 'fa-file-code'
};
return map[ext] || 'fa-file';
}
// 옵션 → 마크다운 속성 문자열
function buildMediaAttrs() {
const widthSel = document.getElementById('mediaWidth').value;
const widthCust = document.getElementById('mediaWidthCustom').value.trim();
const align = document.getElementById('mediaAlign').value;
const parts = [];
let width = '';
if (widthSel === 'custom') {
width = sanitizeMediaWidth(widthCust);
} else if (widthSel) {
width = sanitizeMediaWidth(widthSel);
}
if (width) parts.push(`w=${width}`);
if (align) parts.push(align);
return parts.join(',');
}
function buildMediaMarkdown(kind, url, alt = '') {
const attrs = buildMediaAttrs();
if (kind === 'video') {
return attrs ? `@video[${attrs}](${url})\n` : `@video(${url})\n`;
} else {
return attrs ? `![${alt}|${attrs}](${url})\n` : `![${alt}](${url})\n`;
}
}
// 파일 첨부 삽입 (마크다운 다운로드 링크 형태)
function insertAttachFile() {
if (!pendingAttachFile) {
showAlert('imageInsertAlert', '파일을 먼저 선택해주세요', 'error');
return;
}
const file = pendingAttachFile;
const localUrl = URL.createObjectURL(file);
// pendingMediaFiles에 등록 (저장 시 실제 업로드)
pendingMediaFiles.set(localUrl, { file, isImage: false, isVideo: false, isAttach: true, originalName: file.name });
// 마크다운 다운로드 링크 형태로 삽입
const icon = getFileIcon(file.name);
const size = formatFileSize(file.size);
const markdown = `[📎 ${file.name} (${size})](${localUrl})\n`;
closeImageInsert();
clearFileAttach();
setTimeout(() => {
insertAtCursor(markdown);
}, 50);
}
function insertMediaFromUrl() {
const url = sanitizeUrl(document.getElementById('imageInsertUrl').value.trim());
const alt = document.getElementById('imageInsertAlt').value.trim();
const kind = document.getElementById('imageInsertKind').value;
if (!url) {
showAlert('imageInsertAlert', 'http, https, uploads/ 경로만 사용할 수 있습니다', 'error');
return;
}
insertAtCursor(buildMediaMarkdown(kind, url, alt));
closeImageInsert();
}
function initPostImageUploader() {
const uploader = document.getElementById('postImageUploader');
const fileInput = document.getElementById('postImageFileInput');
// 업로더 클릭 시 파일 선택창 열기 (fileInput 클릭은 직접 이벤트 전파 안 함)
uploader.addEventListener('click', (e) => {
// fileInput 자체를 클릭한 경우 무시 (무한루프 방지)
if (e.target === fileInput) return;
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const f = e.target.files[0];
if (f) handlePostImageUpload(f);
// 값 초기화는 업로드 완료 후에 (handlePostImageUpload 내에서 처리)
});
['dragenter', 'dragover'].forEach(ev => {
uploader.addEventListener(ev, (e) => {
e.preventDefault(); e.stopPropagation();
uploader.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
uploader.addEventListener(ev, (e) => {
e.preventDefault(); e.stopPropagation();
uploader.classList.remove('dragover');
});
});
uploader.addEventListener('drop', (e) => {
const f = e.dataTransfer.files[0];
if (f) handlePostImageUpload(f);
});
}
// 임시 파일 목록 (글 저장 전까지 NAS 업로드 안 함)
const pendingMediaFiles = new Map(); // blobUrl → File
async function handlePostImageUpload(file) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (!isImage && !isVideo) {
showAlert('imageInsertAlert', '이미지 또는 동영상 파일만 업로드 가능합니다', 'error');
return;
}
// NAS 업로드 안 하고 로컬 URL로 미리보기 삽입
const localUrl = URL.createObjectURL(file);
pendingMediaFiles.set(localUrl, { file, isImage, isVideo });
const kind = isVideo ? 'video' : 'image';
const markdown = buildMediaMarkdown(kind, localUrl);
document.getElementById('postImageFileInput').value = '';
closeImageInsert();
setTimeout(() => {
insertAtCursor(markdown);
setTimeout(() => {
const modal = document.getElementById('imageInsertModal');
if (modal && modal.classList.contains('active')) {
modal.classList.remove('active');
}
}, 100);
}, 50);
showAlert('imageInsertAlert', `파일 선택됨 (저장 시 업로드됩니다)`, 'success');
}
// 글 저장 시 마크다운 안의 blob: URL을 실제 서버 URL로 교체
async function uploadPendingMedia(content) {
// 파일 첨부도 함께 처리 (pendingAttachFile이 있고 마커가 본문에 있으면)
if (pendingMediaFiles.size === 0 && !pendingAttachFile) return content;
let updated = content;
const errors = [];
for (const [blobUrl, { file, isVideo, isAttach, originalName }] of pendingMediaFiles.entries()) {
if (!updated.includes(blobUrl)) {
URL.revokeObjectURL(blobUrl);
continue;
}
try {
const formData = new FormData();
formData.append('file', file);
formData.append('project_title', 'learning');
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (result.success && result.url) {
if (isAttach) {
// 파일 첨부: 원본 파일명을 다운로드 링크에 반영
const origName = originalName || result.original_name || result.filename;
updated = updated.split(blobUrl).join(result.url);
} else {
updated = updated.split(blobUrl).join(result.url);
}
URL.revokeObjectURL(blobUrl);
} else {
errors.push(result.error || '업로드 실패');
}
} catch (e) {
errors.push(e.message);
}
}
pendingMediaFiles.clear();
if (errors.length > 0) {
throw new Error('일부 파일 업로드 실패: ' + errors.join(', '));
}
return updated;
}
// ===== 색상 선택 =====
const COLOR_PALETTE = [
// 세이지/민트 계열
'#00f2ff', '#3d6b50', '#8ab89a', '#B2E2D2',
// 웜 뉴트럴
'#b07a20', '#c9a050', '#c8856a', '#9e6b52',
// 레드/핑크 (핀포인트)
'#c0392b', '#e07060', '#c06080', '#8b4558',
// 블루/인디고
'#4a7fa0', '#6a9bc0', '#5a6da0', '#7a8ab8',
// 뉴트럴
'#6a7a6e', '#4a4a48', '#a0a09a', '#1a1a18'
];
function openHighlightPicker() {
const { selected } = getEditorState();
if (!selected) {
showAlert('postAlert', '먼저 형광펜을 적용할 텍스트를 선택해주세요.', 'error');
setTimeout(() => document.getElementById('postAlert').innerHTML = '', 2500);
return;
}
document.getElementById('highlightPickerModal').classList.add('active');
}
function closeHighlightPicker() {
document.getElementById('highlightPickerModal').classList.remove('active');
}
function applyHighlight(color) {
if (color === '') {
wrapSelection('==', '==', '');
} else {
wrapSelection(`==[${color}]:`, '==', '');
}
closeHighlightPicker();
}
function openColorPicker() {
// 선택된 텍스트가 없으면 안내
const { selected } = getEditorState();
if (!selected) {
showAlert('postAlert', '먼저 색상을 적용할 텍스트를 선택해주세요.', 'error');
setTimeout(() => document.getElementById('postAlert').innerHTML = '', 2500);
return;
}
const grid = document.getElementById('colorGrid');
grid.innerHTML = COLOR_PALETTE.map(c => `
<div class="color-swatch" style="background: ${c};"
onclick="applyColor('${c}')" title="${c}"></div>
`).join('');
document.getElementById('colorPickerModal').classList.add('active');
}
function closeColorPicker() {
document.getElementById('colorPickerModal').classList.remove('active');
}
function applyColor(color) {
const safeColor = sanitizeCssColor(color);
if (safeColor) wrapSelection(`{color:${safeColor}}`, '{/color}', '');
closeColorPicker();
}
// ===== 콜아웃 =====
function openCalloutModal() {
document.getElementById('calloutModal').classList.add('active');
}
function closeCalloutModal() {
document.getElementById('calloutModal').classList.remove('active');
}
function insertCallout(type) {
const samples = {
'NOTE': '여기에 참고 사항을 작성하세요.',
'TIP': '유용한 팁을 작성하세요.',
'WARNING': '주의해야 할 내용을 작성하세요.',
'DANGER': '위험한 내용을 작성하세요.'
};
const block = `> [!${type}]\n> ${samples[type]}`;
insertBlock(block);
closeCalloutModal();
}
// ===== 키보드 단축키 (textarea에서) =====
document.addEventListener('keydown', (e) => {
const ta = document.getElementById('postContent');
if (!ta || document.activeElement !== ta) return;
if (!(e.ctrlKey || e.metaKey)) return;
const key = e.key.toLowerCase();
if (key === 'b') {
e.preventDefault();
mdInsert('bold');
} else if (key === 'i') {
e.preventDefault();
mdInsert('italic');
} else if (key === 'k') {
e.preventDefault();
mdInsert('link');
}
});
// Tab 키로 들여쓰기
document.addEventListener('keydown', (e) => {
const ta = document.getElementById('postContent');
if (!ta || document.activeElement !== ta) return;
if (e.key === 'Tab') {
e.preventDefault();
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + ' ' + ta.value.substring(end);
ta.setSelectionRange(start + 2, start + 2);
}
});
async function savePost() {
const id = document.getElementById('postId').value;
const tagsStr = document.getElementById('postTags').value.trim();
const childSel = document.getElementById('postCategory');
if (childSel.disabled || !childSel.value) {
showAlert('postAlert', '서브 카테고리를 선택해주세요. 큰 카테고리 아래 서브 카테고리가 필요합니다.', 'error');
return;
}
let content = document.getElementById('postContent').value.trim();
if (!document.getElementById('postTitle').value.trim() || !content) {
showAlert('postAlert', '제목과 내용은 필수입니다', 'error');
return;
}
// 저장 버튼 비활성화
const saveBtn = document.querySelector('#postModal .btn-primary[onclick="savePost()"]');
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '저장 중...'; }
try {
// 임시 파일들 NAS에 업로드 (blob: URL → 실제 URL 교체)
content = await uploadPendingMedia(content);
const data = {
title: document.getElementById('postTitle').value.trim(),
category_id: parseInt(childSel.value) || 0,
created_at: document.getElementById('postDate').value,
tags: tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [],
content
};
if (!data.category_id) {
showAlert('postAlert', '카테고리를 선택해주세요', 'error');
return;
}
let res;
if (id) {
data.id = parseInt(id);
res = await csrfFetch('api/learning.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await csrfFetch('api/learning.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await res.json();
if (result.success) {
closePostModal();
await loadPosts();
renderCalendar();
renderCategoryList();
renderPosts();
} else {
showAlert('postAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('postAlert', '오류: ' + e.message, 'error');
} finally {
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> 저장'; }
}
}
// ===== 카테고리 모달 =====
function openCategoryModal(cat = null) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
document.getElementById('categoryAlert').innerHTML = '';
// 부모 카테고리 옵션 채우기 (수정 시 자기 자신은 제외, 자식 있는 카테고리는 자식이 될 수 없음)
const parentSel = document.getElementById('categoryParent');
const parents = getParentCategories();
let options = ['<option value="">큰 메뉴 (카테고리 자체)</option>'];
parents.forEach(p => {
// 수정 모드에서 자기 자신은 제외
if (cat && p.id === cat.id) return;
options.push(`<option value="${p.id}">${escapeHtml(p.name)} 아래 (서브)</option>`);
});
parentSel.innerHTML = options.join('');
if (cat) {
document.getElementById('categoryModalTitle').textContent = '카테고리 수정';
document.getElementById('categoryId').value = cat.id;
document.getElementById('categoryName').value = cat.name;
document.getElementById('categoryColor').value = sanitizeCssColor(cat.color, '#00f2ff');
parentSel.value = cat.parent_id || '';
// 자식이 있으면 부모 변경 비활성화
const hasChildren = categories.some(c => c.parent_id === cat.id);
parentSel.disabled = hasChildren;
if (hasChildren) {
parentSel.title = '하위 카테고리가 있어 이동할 수 없습니다';
}
} else {
document.getElementById('categoryModalTitle').textContent = '카테고리 추가';
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryColor').value = '#00f2ff';
parentSel.value = '';
parentSel.disabled = false;
}
document.getElementById('categoryModal').classList.add('active');
}
// 사이드바의 + 버튼: 부모 미리 선택해서 모달 열기
function addSubCategory(parentId) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
document.getElementById('categoryAlert').innerHTML = '';
const parentSel = document.getElementById('categoryParent');
const parents = getParentCategories();
parentSel.innerHTML = ['<option value="">큰 메뉴 (카테고리 자체)</option>']
.concat(parents.map(p => `<option value="${p.id}">${escapeHtml(p.name)} 아래 (서브)</option>`))
.join('');
parentSel.value = parentId;
parentSel.disabled = false;
document.getElementById('categoryModalTitle').textContent = '서브 카테고리 추가';
document.getElementById('categoryId').value = '';
document.getElementById('categoryName').value = '';
document.getElementById('categoryColor').value = '#ffd166';
document.getElementById('categoryModal').classList.add('active');
}
function closeCategoryModal() {
document.getElementById('categoryModal').classList.remove('active');
}
function editCategory(id) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
const cat = getCategoryById(id);
if (cat) openCategoryModal(cat);
}
async function deleteCategory(id) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
const cat = getCategoryById(id);
const isParent = cat && categories.some(c => c.parent_id === id);
const msg = isParent
? '이 카테고리와 모든 하위 카테고리를 삭제하시겠습니까?'
: '이 카테고리를 삭제하시겠습니까?';
if (!confirm(msg)) return;
try {
const res = await csrfFetch(`api/categories.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (result.success) {
// 현재 필터가 영향받으면 전체로
if ((currentFilter.type === 'category' || currentFilter.type === 'parent')
&& currentFilter.value === id) {
currentFilter = { type: 'all', value: null };
}
expandedParents.delete(id);
await loadCategories();
renderCategoryList();
renderPosts();
} else {
alert(result.error || '삭제 실패');
}
} catch (e) { alert('서버 오류'); }
}
async function saveCategory() {
const id = document.getElementById('categoryId').value;
const name = document.getElementById('categoryName').value.trim();
const color = document.getElementById('categoryColor').value;
const parentVal = document.getElementById('categoryParent').value;
const parent_id = parentVal ? parseInt(parentVal) : null;
if (!name) {
showAlert('categoryAlert', '이름을 입력하세요', 'error');
return;
}
const payload = { name, color, parent_id };
try {
let res;
if (id) {
payload.id = parseInt(id);
res = await csrfFetch('api/categories.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
res = await csrfFetch('api/categories.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const result = await res.json();
if (result.success) {
closeCategoryModal();
await loadCategories();
// 새 카테고리가 부모면 펼친 상태로 시작
if (result.category && !result.category.parent_id) {
expandedParents.add(result.category.id);
}
// 새 서브 카테고리면 부모 펼치기
if (result.category && result.category.parent_id) {
expandedParents.add(result.category.parent_id);
}
renderCategoryList();
renderPosts();
} else {
showAlert('categoryAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('categoryAlert', '서버 오류', 'error');
}
}
// ===== 유틸 =====
function showAlert(elemId, message, type) {
document.getElementById(elemId).innerHTML =
`<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
function sanitizeCssColor(color, fallback = '') {
const trimmed = String(color || '').trim();
return /^#[0-9a-fA-F]{3}$/.test(trimmed) || /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : fallback;
}
function sanitizeMediaWidth(width) {
let value = String(width || '').trim();
if (/^\d+$/.test(value)) value += 'px';
const px = value.match(/^(\d{1,4})px$/);
if (px) return Math.min(Math.max(parseInt(px[1], 10), 40), 1200) + 'px';
const pct = value.match(/^(\d{1,3})%$/);
if (pct) return Math.min(Math.max(parseInt(pct[1], 10), 1), 100) + '%';
return '';
}
function sanitizeAllowedStyles(html) {
const template = document.createElement('template');
template.innerHTML = html;
template.content.querySelectorAll('[style]').forEach(el => {
const allowed = [];
const declarations = String(el.getAttribute('style') || '').split(';');
declarations.forEach(decl => {
const [rawProp, ...rawValueParts] = decl.split(':');
if (!rawProp || rawValueParts.length === 0) return;
const prop = rawProp.trim().toLowerCase();
const value = rawValueParts.join(':').trim();
if (prop === 'width') {
const width = sanitizeMediaWidth(value);
if (width) allowed.push(`width:${width}`);
} else if (prop === 'max-width' && value === '100%') {
allowed.push('max-width:100%');
} else if (prop === 'display' && value === 'block') {
allowed.push('display:block');
} else if ((prop === 'margin-left' || prop === 'margin-right') && /^(auto|0)$/.test(value)) {
allowed.push(`${prop}:${value}`);
} else if (prop === 'color') {
const color = sanitizeCssColor(value);
if (color) allowed.push(`color:${color}`);
}
});
if (allowed.length > 0) {
el.setAttribute('style', allowed.join(';'));
} else {
el.removeAttribute('style');
}
});
return template.innerHTML;
}
// ESC / 백스페이스로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => {
m.classList.remove('active');
});
return;
}
});
init();
</script>
</body>
</html>