2026-05-31 21:05:59 +09:00
<!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 : 60 px 8 % 40 px ;
background : var ( - - bg - card ) ;
border-bottom : 1 px solid var ( - - border ) ;
}
. page-header h1 {
font-size : 2.5 rem ;
margin-bottom : 0.5 rem ;
letter-spacing : 1.5 px ;
}
. page-header p {
color : var ( - - text - dim ) ;
font-size : 1 rem ;
max-width : 700 px ;
}
/* ===== 캘린더 (잔디) ===== */
. activity-calendar {
margin-top : 2 rem ;
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 12 px ;
padding : 1.2 rem 1.4 rem ;
overflow-x : auto ;
}
. calendar-header {
display : flex ;
justify-content : space-between ;
align-items : center ;
margin-bottom : 1 rem ;
flex-wrap : wrap ;
gap : 12 px ;
}
. calendar-title {
font-family : 'Orbitron' , sans-serif ;
font-size : 0.9 rem ;
color : var ( - - text - dim ) ;
letter-spacing : 1.5 px ;
}
. calendar-stats {
color : var ( - - primary ) ;
font-size : 0.85 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
. cal-clear-btn {
display : inline-flex ;
align-items : center ;
gap : 6 px ;
background : rgba ( 255 , 209 , 102 , 0.1 ) ;
border : 1 px solid var ( - - learning - yellow ) ;
color : var ( - - learning - yellow ) ;
padding : 5 px 12 px ;
border-radius : 14 px ;
font-size : 0.75 rem ;
font-family : 'JetBrains Mono' , monospace ;
cursor : pointer ;
transition : 0.2 s ;
}
. cal-clear-btn : hover {
background : var ( - - learning - yellow ) ;
color : var ( - - bg ) ;
}
. calendar-grid {
display : grid ;
grid-template-columns : auto 1 fr ;
gap : 8 px ;
min-width : 720 px ;
}
. calendar-month-labels {
grid-column : 2 ;
display : flex ;
justify-content : space-between ;
color : var ( - - text - dim ) ;
font-size : 0.7 rem ;
font-family : 'JetBrains Mono' , monospace ;
margin-bottom : 4 px ;
padding : 0 4 px ;
}
. calendar-day-labels {
display : grid ;
grid-template-rows : repeat ( 7 , 1 fr ) ;
gap : 4 px ;
font-size : 0.7 rem ;
color : var ( - - text - dim ) ;
font-family : 'JetBrains Mono' , monospace ;
padding-top : 22 px ;
}
. calendar-day-labels span {
height : 16 px ;
line-height : 16 px ;
}
. calendar-cells {
display : grid ;
grid-template-rows : repeat ( 7 , 1 fr ) ;
grid-auto-flow : column ;
grid-auto-columns : 1 fr ;
gap : 4 px ;
min-height : 140 px ;
}
. cal-cell {
width : 100 % ;
aspect-ratio : 1 ;
min-width : 14 px ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
border-radius : 3 px ;
cursor : pointer ;
transition : 0.15 s ;
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 : 1 px solid rgba ( 255 , 255 , 255 , 0.5 ) ;
}
. cal-cell . today {
border : 1 px solid var ( - - learning - yellow ) ;
}
. cal-cell . selected {
transform : scale ( 1.5 ) ;
border : 2 px solid var ( - - learning - yellow ) ;
box-shadow : 0 0 12 px rgba ( 255 , 209 , 102 , 0.8 ) ;
z-index : 4 ;
}
/* 활성 필터 표시 (날짜 필터 등) */
. active-filters {
display : flex ;
flex-wrap : wrap ;
gap : 8 px ;
margin-bottom : 1 rem ;
}
. active-filters : empty {
display : none ;
}
. filter-chip {
display : inline-flex ;
align-items : center ;
gap : 8 px ;
padding : 6 px 12 px ;
background : rgba ( 255 , 209 , 102 , 0.1 ) ;
border : 1 px solid rgba ( 255 , 209 , 102 , 0.4 ) ;
color : var ( - - learning - yellow ) ;
border-radius : 16 px ;
font-size : 0.8 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
. filter-chip button {
background : transparent ;
border : none ;
color : var ( - - learning - yellow ) ;
cursor : pointer ;
padding : 0 ;
width : 16 px ; height : 16 px ;
display : flex ;
align-items : center ;
justify-content : center ;
border-radius : 50 % ;
font-size : 0.7 rem ;
transition : 0.2 s ;
}
. 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 ( 8 px ) ;
color : #ffffff ;
padding : 6 px 10 px ;
border-radius : 6 px ;
font-size : 0.75 rem ;
border : 1 px solid var ( - - border - card ) ;
pointer-events : none ;
z-index : 100 ;
display : none ;
white-space : nowrap ;
}
/* ===== 본문 레이아웃 ===== */
. layout {
display : grid ;
grid-template-columns : 260 px 1 fr ;
gap : 2 rem ;
padding : 40 px 8 % ;
align-items : start ;
}
/* 좌측 사이드바 */
. sidebar {
position : sticky ;
top : 90 px ;
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 12 px ;
padding : 1.2 rem ;
}
. sidebar-title {
font-family : 'Orbitron' , sans-serif ;
color : var ( - - primary ) ;
font-size : 0.8 rem ;
letter-spacing : 1.5 px ;
margin-bottom : 1 rem ;
padding-bottom : 0.7 rem ;
border-bottom : 1 px dashed var ( - - border - card ) ;
display : flex ;
justify-content : space-between ;
align-items : center ;
}
. sidebar-title . add-cat-btn {
background : var ( - - border ) ;
border : 1 px solid var ( - - primary ) ;
color : var ( - - primary ) ;
width : 22 px ; height : 22 px ;
border-radius : 4 px ;
cursor : pointer ;
display : none ;
align-items : center ;
justify-content : center ;
font-size : 0.7 rem ;
transition : 0.2 s ;
}
. 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 : 2 px ;
}
. cat-item {
padding : 9 px 12 px ;
border-radius : 6 px ;
cursor : pointer ;
color : var ( - - text ) ;
font-size : 0.9 rem ;
transition : 0.2 s ;
display : flex ;
justify-content : space-between ;
align-items : center ;
gap : 8 px ;
border : 1 px 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 : 8 px ;
flex : 1 ;
min-width : 0 ;
}
. cat-item . cat-dot {
width : 8 px ; height : 8 px ;
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.75 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
. cat-item . active . cat-count { color : var ( - - primary ) ; }
/* 큰 메뉴(parent) 스타일 */
. cat-parent {
margin-top : 6 px ;
}
. cat-parent : first-child { margin-top : 0 ; }
. cat-parent . cat-item {
font-family : 'Orbitron' , sans-serif ;
font-size : 0.85 rem ;
letter-spacing : 0.5 px ;
font-weight : 700 ;
}
. cat-parent . cat-item . cat-toggle {
color : var ( - - text - dim ) ;
font-size : 0.7 rem ;
transition : transform 0.2 s ;
width : 12 px ;
text-align : center ;
}
. cat-parent . expanded . cat-item . cat-toggle {
transform : rotate ( 90 deg ) ;
color : var ( - - primary ) ;
}
/* 서브 카테고리 컨테이너 */
. cat-children {
display : none ;
flex-direction : column ;
gap : 2 px ;
padding-left : 16 px ;
margin-top : 2 px ;
margin-bottom : 4 px ;
position : relative ;
}
. cat-parent . expanded . cat-children {
display : flex ;
}
. cat-children :: before {
content : '' ;
position : absolute ;
left : 14 px ;
top : 4 px ;
bottom : 4 px ;
width : 1 px ;
background : var ( - - border ) ;
}
. cat-children . cat-item {
padding : 7 px 12 px 7 px 16 px ;
font-size : 0.85 rem ;
}
/* 카테고리 액션 버튼 */
. cat-actions {
display : none ;
gap : 4 px ;
}
. admin-on . cat-item : hover . cat-actions { display : flex ; }
. cat-action-btn {
background : transparent ;
border : none ;
color : var ( - - text - dim ) ;
cursor : pointer ;
padding : 2 px 4 px ;
font-size : 0.75 rem ;
transition : 0.2 s ;
}
. 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.8 rem ;
padding : 12 px ;
text-align : center ;
font-style : italic ;
}
. cat-divider {
height : 1 px ;
background : var ( - - border ) ;
margin : 8 px 0 ;
}
/* 메인 영역 */
. main-area {
min-width : 0 ;
}
. main-toolbar {
display : flex ;
justify-content : space-between ;
align-items : center ;
margin-bottom : 1.5 rem ;
flex-wrap : wrap ;
gap : 12 px ;
}
. main-toolbar . current-section {
display : flex ;
align-items : center ;
gap : 12 px ;
}
. main-toolbar h2 {
font-size : 1.6 rem ;
letter-spacing : 1 px ;
}
. main-toolbar . post-count {
color : var ( - - text - dim ) ;
font-size : 0.85 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
. main-toolbar . actions {
display : flex ;
gap : 8 px ;
}
. 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 : 10 px ;
margin-bottom : 1.5 rem ;
flex-wrap : wrap ;
align-items : center ;
}
. search-row input {
flex : 1 ;
min-width : 160 px ;
padding : 10 px 14 px ;
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 0 8 px 8 px 0 ;
border-left : none ;
color : var ( - - text ) ;
font-family : 'Noto Sans KR' , sans-serif ;
font-size : 0.9 rem ;
transition : 0.2 s ;
}
. search-row input : focus {
outline : none ;
border-color : var ( - - primary ) ;
box-shadow : 0 0 0 3 px var ( - - border ) ;
}
. search-scope-select {
padding : 10 px 10 px 10 px 14 px ;
background : var ( - - primary - dim ) ;
border : 1 px solid var ( - - border - card ) ;
border-right : none ;
border-radius : 8 px 0 0 8 px ;
color : var ( - - primary ) ;
font-family : 'Orbitron' , sans-serif ;
font-size : 0.75 rem ;
letter-spacing : 0.5 px ;
cursor : pointer ;
transition : 0.2 s ;
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 10 px center ;
padding-right : 28 px ;
}
. 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.5 px ;
}
. search-row select # sortSelect {
padding : 10 px 14 px ;
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
color : var ( - - text ) ;
font-family : 'Noto Sans KR' , sans-serif ;
font-size : 0.9 rem ;
cursor : pointer ;
flex-shrink : 0 ;
}
/* 글 카드 */
. post-list {
display : flex ;
flex-direction : column ;
gap : 1 rem ;
}
. post-card {
background : var ( - - bg - card ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 12 px ;
padding : 1.4 rem 1.6 rem ;
cursor : pointer ;
transition : 0.25 s ;
position : relative ;
overflow : hidden ;
}
. post-card :: before {
content : '' ;
position : absolute ;
left : 0 ; top : 0 ; bottom : 0 ;
width : 3 px ;
background : var ( - - cat - color , var ( - - primary ) ) ;
opacity : 0.5 ;
transition : 0.2 s ;
}
. post-card : hover {
transform : translateY ( -3 px ) ;
border-color : var ( - - border - card ) ;
box-shadow : 0 6 px 20 px var ( - - primary - dim ) ;
}
. post-card : hover :: before { opacity : 1 ; }
. post-card-header {
display : flex ;
justify-content : space-between ;
align-items : flex-start ;
gap : 1 rem ;
margin-bottom : 0.6 rem ;
flex-wrap : wrap ;
}
. post-card h3 {
font-family : 'Noto Sans KR' , sans-serif ;
font-size : 1.15 rem ;
font-weight : 700 ;
color : var ( - - text ) ;
line-height : 1.4 ;
flex : 1 ;
min-width : 0 ;
}
. post-meta {
display : flex ;
align-items : center ;
gap : 10 px ;
font-size : 0.75 rem ;
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 : 1 px solid rgba ( 255 , 209 , 102 , 0.4 ) ;
padding : 2 px 8 px ;
border-radius : 10 px ;
font-size : 0.7 rem ;
font-family : 'Orbitron' , sans-serif ;
letter-spacing : 1 px ;
display : inline-flex ;
align-items : center ;
gap : 4 px ;
}
. post-cat-pill {
display : inline-flex ;
align-items : center ;
gap : 6 px ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
padding : 3 px 10 px ;
border-radius : 12 px ;
font-size : 0.75 rem ;
color : var ( - - text - dim ) ;
margin-bottom : 0.8 rem ;
}
. post-cat-pill . cat-dot {
width : 6 px ; height : 6 px ;
border-radius : 50 % ;
}
. post-preview {
color : var ( - - text - dim ) ;
font-size : 0.9 rem ;
line-height : 1.6 ;
margin-bottom : 0.9 rem ;
display : -webkit- box ;
-webkit- line-clamp : 2 ;
-webkit- box-orient : vertical ;
overflow : hidden ;
}
. post-tags {
display : flex ;
flex-wrap : wrap ;
gap : 6 px ;
}
. post-tag {
background : var ( - - bg - deep ) ;
color : var ( - - text - dim ) ;
padding : 3 px 10 px ;
border-radius : 10 px ;
font-size : 0.72 rem ;
border : 1 px solid var ( - - border ) ;
font-family : 'JetBrains Mono' , monospace ;
}
. empty-state {
text-align : center ;
padding : 60 px 20 px ;
color : var ( - - text - dim ) ;
background : var ( - - bg - card ) ;
border : 1 px dashed var ( - - border ) ;
border-radius : 12 px ;
}
. empty-state i { font-size : 2.5 rem ; opacity : 0.3 ; margin-bottom : 1 rem ; }
/* ===== 모달 (공통) ===== */
. checkbox-row {
display : flex ; align-items : center ; gap : 10 px ;
padding : 10 px 12 px ;
background : var ( - - bg - deep ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 6 px ;
cursor : pointer ;
}
. checkbox-row input { width : auto ; }
. checkbox-row label {
margin : 0 ; font-family : 'Noto Sans KR' , sans-serif ;
font-size : 0.9 rem ; color : var ( - - text ) ; letter-spacing : 0 ;
cursor : pointer ;
}
/* 마크다운 에디터 */
. editor-tabs {
display : flex ;
gap : 4 px ;
margin-bottom : -1 px ;
}
. editor-tab {
padding : 8 px 16 px ;
background : var ( - - bg - deep ) ;
color : var ( - - text - dim ) ;
border : 1 px solid var ( - - border ) ;
border-bottom : none ;
border-radius : 6 px 6 px 0 0 ;
cursor : pointer ;
font-family : 'Orbitron' , sans-serif ;
font-size : 0.75 rem ;
letter-spacing : 1 px ;
transition : 0.2 s ;
}
. 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 : 320 px ;
font-family : 'JetBrains Mono' , monospace ;
font-size : 0.88 rem ;
line-height : 1.6 ;
}
. editor-preview {
min-height : 320 px ;
padding : 16 px ;
background : var ( - - bg - deep ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 6 px ;
overflow-y : auto ;
}
. editor-hint {
margin-top : 6 px ;
font-size : 0.75 rem ;
color : var ( - - text - dim ) ;
}
. editor-hint code {
background : var ( - - bg - deep ) ;
padding : 2 px 6 px ;
border-radius : 3 px ;
color : var ( - - primary ) ;
font-size : 0.75 rem ;
}
/* ===== 마크다운 에디터 툴바 ===== */
. md-toolbar {
display : flex ;
flex-wrap : wrap ;
gap : 2 px ;
padding : 6 px 8 px ;
background : var ( - - bg - deep ) ;
border : 1 px solid var ( - - border ) ;
border-bottom : none ;
border-radius : 6 px 6 px 0 0 ;
align-items : center ;
}
. md-tool-btn {
background : transparent ;
border : 1 px solid transparent ;
color : var ( - - text - dim ) ;
width : 32 px ;
height : 32 px ;
border-radius : 4 px ;
cursor : pointer ;
font-size : 0.85 rem ;
display : inline-flex ;
align-items : center ;
justify-content : center ;
transition : 0.15 s ;
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.55 rem ;
margin-left : 1 px ;
}
. md-tool-divider {
width : 1 px ;
height : 20 px ;
background : var ( - - border ) ;
margin : 0 4 px ;
}
/* 텍스트에리어 상단 모서리 제거 (툴바와 연결) */
. editor-pane textarea {
border-top-left-radius : 0 ;
border-top-right-radius : 0 ;
}
/* ===== 이미지 삽입 모달 ===== */
. img-insert-tabs {
display : flex ;
gap : 4 px ;
margin-bottom : 16 px ;
border-bottom : 1 px solid var ( - - border ) ;
}
. img-insert-tab {
padding : 10 px 18 px ;
background : transparent ;
color : var ( - - text - dim ) ;
border : none ;
border-bottom : 2 px solid transparent ;
cursor : pointer ;
font-family : 'Orbitron' , sans-serif ;
font-size : 0.78 rem ;
letter-spacing : 1 px ;
transition : 0.2 s ;
display : inline-flex ;
align-items : center ;
gap : 6 px ;
}
. 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 : 10 px 8 px ;
border-radius : 8 px ;
border : 1 px solid var ( - - border ) ;
cursor : pointer ;
font-family : 'Noto Sans KR' , sans-serif ;
font-size : 0.85 rem ;
font-weight : 700 ;
transition : 0.2 s ;
display : flex ;
align-items : center ;
justify-content : center ;
gap : 6 px ;
}
. hl-pick-btn : hover { filter : brightness ( 1.1 ) ; transform : translateY ( -1 px ) ; }
. color-grid {
display : grid ;
grid-template-columns : repeat ( 6 , 1 fr ) ;
gap : 8 px ;
margin-top : 8 px ;
}
. color-swatch {
aspect-ratio : 1 ;
border-radius : 6 px ;
cursor : pointer ;
border : 2 px solid transparent ;
transition : 0.15 s ;
position : relative ;
}
. color-swatch : hover {
transform : scale ( 1.1 ) ;
border-color : rgba ( 255 , 255 , 255 , 0.4 ) ;
box-shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.4 ) ;
}
/* ===== 콜아웃 옵션 리스트 ===== */
. callout-list {
display : flex ;
flex-direction : column ;
gap : 8 px ;
margin-top : 8 px ;
}
. callout-option {
display : flex ;
align-items : center ;
gap : 14 px ;
padding : 14 px 16 px ;
background : var ( - - bg - deep ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
color : var ( - - text ) ;
cursor : pointer ;
text-align : left ;
transition : 0.2 s ;
font-family : 'Noto Sans KR' , sans-serif ;
width : 100 % ;
}
. callout-option : hover {
transform : translateX ( 4 px ) ;
}
. callout-option i {
font-size : 1.3 rem ;
width : 30 px ;
text-align : center ;
}
. callout-option . callout-name {
font-family : 'Orbitron' , sans-serif ;
font-size : 0.85 rem ;
letter-spacing : 1 px ;
margin-bottom : 2 px ;
}
. callout-option . callout-desc {
color : var ( - - text - dim ) ;
font-size : 0.78 rem ;
}
. 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 : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
padding : 12 px 14 px ;
margin : 16 px 0 ;
}
. media-options-title {
font-family : 'Orbitron' , sans-serif ;
color : var ( - - primary ) ;
font-size : 0.75 rem ;
letter-spacing : 1.5 px ;
margin-bottom : 12 px ;
}
. media-options . form-row {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 10 px ;
margin-bottom : 0 ;
}
. media-options . form-group {
margin-bottom : 0 ;
}
. media-options code {
background : var ( - - bg ) ;
padding : 1 px 5 px ;
border-radius : 3 px ;
color : var ( - - primary ) ;
font-size : 0.7 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
/* ===== 커스텀 비디오 플레이어 ===== */
. markdown-body . md-video-wrap {
position : relative ;
background : #000 ;
border-radius : 8 px ;
overflow : hidden ;
margin : 1 rem 0 ;
max-width : 100 % ;
outline : none ;
display : block ; /* inline-block 아니라 block으로 - 너비 제어 용이 */
}
. markdown-body . md-video-wrap : focus {
box-shadow : 0 0 0 2 px 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 : 70 px ; height : 70 px ;
border-radius : 50 % ;
background : rgba ( 0 , 0 , 0 , 0.65 ) ;
backdrop-filter : blur ( 6 px ) ;
border : 2 px solid var ( - - primary ) ;
color : var ( - - primary ) ;
cursor : pointer ;
display : flex ;
align-items : center ;
justify-content : center ;
font-size : 1.5 rem ;
transition : 0.25 s ;
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 ( 180 deg , transparent 0 % , rgba ( 0 , 0 , 0 , 0.85 ) 100 % ) ;
padding : 30 px 12 px 10 px ;
display : flex ;
align-items : center ;
gap : 8 px ;
opacity : 0 ;
transform : translateY ( 8 px ) ;
transition : 0.25 s ;
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 : 32 px ;
height : 32 px ;
display : flex ;
align-items : center ;
justify-content : center ;
border-radius : 4 px ;
transition : 0.15 s ;
font-size : 0.85 rem ;
flex-shrink : 0 ;
}
. vc-btn : hover {
background : var ( - - border ) ;
color : var ( - - primary ) ;
}
. vc-time {
color : #fff ;
font-family : 'JetBrains Mono' , monospace ;
font-size : 0.75 rem ;
margin : 0 6 px ;
flex-shrink : 0 ;
min-width : 80 px ;
}
. vc-progress {
flex : 1 ;
height : 5 px ;
background : var ( - - border ) ;
border-radius : 3 px ;
cursor : pointer ;
position : relative ;
margin : 0 6 px ;
transition : height 0.15 s ;
}
. vc-progress : hover {
height : 7 px ;
}
. vc-progress-fill {
position : absolute ;
left : 0 ; top : 0 ; bottom : 0 ;
background : var ( - - primary ) ;
border-radius : 3 px ;
width : 0 % ;
transition : width 0.1 s linear ;
box-shadow : 0 0 4 px rgba ( 0 , 242 , 255 , 0.5 ) ;
}
. vc-progress-thumb {
position : absolute ;
top : 50 % ;
width : 12 px ; height : 12 px ;
border-radius : 50 % ;
background : var ( - - primary ) ;
transform : translate ( -50 % , -50 % ) ;
left : 0 % ;
opacity : 0 ;
transition : opacity 0.15 s ;
box-shadow : 0 0 8 px 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 : 100 vh ;
width : auto ;
max-width : 100 vw ;
}
/* ===== 마크다운 본문 - 추가 스타일 (콜아웃, 형광펜, 색상) ===== */
. markdown-body mark {
background : rgba ( 176 , 122 , 32 , 0.18 ) ;
color : var ( - - learning - yellow ) ;
padding : 1 px 4 px ;
border-radius : 3 px ;
}
. 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 : 6 px ;
accent-color : var ( - - primary ) ;
width : 16 px ;
height : 16 px ;
vertical-align : middle ;
}
. markdown-body ul . contains-task-list {
list-style : none ;
padding-left : 1 rem ;
}
. markdown-body li . task-list-item {
list-style : none ;
margin-left : -1 rem ;
}
. markdown-body . callout {
border-left : 4 px solid ;
background : rgba ( 255 , 255 , 255 , 0.02 ) ;
padding : 12 px 16 px ;
margin : 1 rem 0 ;
border-radius : 0 8 px 8 px 0 ;
}
. markdown-body . callout-title {
font-family : 'Orbitron' , sans-serif ;
font-size : 0.8 rem ;
letter-spacing : 1.5 px ;
margin-bottom : 6 px ;
display : flex ;
align-items : center ;
gap : 8 px ;
}
. markdown-body . callout-title i {
font-size : 0.95 rem ;
}
. 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.5 rem ;
padding-bottom : 1.5 rem ;
border-bottom : 1 px solid var ( - - border ) ;
}
. post-detail-header h1 {
font-family : 'Noto Sans KR' , sans-serif ;
font-size : 1.7 rem ;
line-height : 1.4 ;
margin-bottom : 1 rem ;
padding-right : 40 px ;
}
. post-detail-meta {
display : flex ;
flex-wrap : wrap ;
gap : 12 px ;
align-items : center ;
color : var ( - - text - dim ) ;
font-size : 0.85 rem ;
}
. post-detail-meta . post-cat-pill { margin : 0 ; }
/* 마크다운 콘텐츠 */
. markdown-body {
color : var ( - - text ) ;
font-size : 0.95 rem ;
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.6 rem 0 0.8 rem ;
color : var ( - - primary ) ;
letter-spacing : 0 ;
line-height : 1.4 ;
}
. markdown-body h1 { font-size : 1.5 rem ; border-bottom : 1 px solid var ( - - border - card ) ; padding-bottom : 0.5 rem ; }
. markdown-body h2 { font-size : 1.3 rem ; }
. markdown-body h3 { font-size : 1.1 rem ; color : var ( - - text ) ; }
. markdown-body h4 { font-size : 1 rem ; color : var ( - - text ) ; }
. markdown-body p { margin : 0.8 rem 0 ; color : var ( - - text ) ; }
. markdown-body ul , . markdown-body ol { margin : 0.8 rem 0 ; padding-left : 1.6 rem ; }
. markdown-body li { margin : 0.3 rem 0 ; }
. markdown-body a {
color : var ( - - primary ) ;
text-decoration : none ;
border-bottom : 1 px 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 : 6 px ;
padding : 6 px 14 px ;
background : var ( - - primary - dim ) ;
border : 1 px solid var ( - - border - card ) ;
border-radius : 6 px ;
font-size : 0.85 rem ;
margin : 4 px 0 ;
}
. markdown-body strong { color : var ( - - primary ) ; }
. markdown-body em { color : var ( - - text - dim ) ; }
. markdown-body blockquote {
border-left : 3 px solid var ( - - primary ) ;
background : var ( - - primary - dim ) ;
padding : 0.6 rem 1 rem ;
margin : 1 rem 0 ;
color : var ( - - text - dim ) ;
border-radius : 0 6 px 6 px 0 ;
}
. markdown-body code {
background : var ( - - primary - dim ) ;
color : var ( - - primary ) ;
padding : 2 px 6 px ;
border-radius : 3 px ;
font-size : 0.85 em ;
font-family : 'JetBrains Mono' , monospace ;
}
. markdown-body pre {
background : var ( - - bg - deep ) ;
border : 1 px solid var ( - - border ) ;
border-radius : 8 px ;
padding : 1 rem ;
margin : 1 rem 0 ;
overflow-x : auto ;
line-height : 1.5 ;
}
. markdown-body pre code {
background : transparent ;
color : inherit ;
padding : 0 ;
font-size : 0.85 rem ;
font-family : 'JetBrains Mono' , monospace ;
}
. markdown-body hr {
border : none ;
border-top : 1 px solid var ( - - border ) ;
margin : 1.5 rem 0 ;
}
. markdown-body table {
border-collapse : collapse ;
margin : 1 rem 0 ;
width : 100 % ;
}
. markdown-body th , . markdown-body td {
border : 1 px solid var ( - - border ) ;
padding : 8 px 12 px ;
}
. markdown-body th {
background : var ( - - primary - dim ) ;
color : var ( - - primary ) ;
}
. markdown-body img {
max-width : 100 % ;
border-radius : 6 px ;
margin : 1 rem 0 ;
}
/* ===== 미디어 크기/위치 조절 (글 상세 뷰) ===== */
. md-media-wrapper {
position : relative ;
display : inline-block ;
margin : 1 rem 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 : 6 px ;
margin : 0 ;
user-select : none ;
}
/* 비디오 래퍼는 md-video-wrap이 이미 있어서 거기에 추가 클래스만 */
. md-media-wrapper . md-video-wrap {
margin : 0 ;
border-radius : 6 px ;
width : 100 % ;
display : block ;
}
. md-media-wrapper . md-video-wrap video {
width : 100 % ;
height : auto ;
}
/* 크기 조절 핸들 */
. md-resize-handle {
position : absolute ;
right : -5 px ;
bottom : -5 px ;
width : 16 px ; height : 16 px ;
background : var ( - - primary ) ;
border-radius : 3 px ;
cursor : se-resize ;
opacity : 0 ;
transition : opacity 0.2 s ;
z-index : 10 ;
display : flex ; align-items : center ; justify-content : center ;
}
. md-resize-handle :: after {
content : '' ;
display : block ;
width : 7 px ; height : 7 px ;
border-right : 2 px solid #000 ;
border-bottom : 2 px solid #000 ;
}
. md-media-wrapper : hover . md-resize-handle {
opacity : 1 ;
}
/* 정렬 + 크기 조절 툴바 */
. md-media-toolbar {
position : absolute ;
top : -34 px ;
left : 50 % ;
transform : translateX ( -50 % ) ;
display : flex ;
gap : 4 px ;
background : rgba ( 0 , 0 , 0 , 0.85 ) ;
backdrop-filter : blur ( 8 px ) ;
border : 1 px solid var ( - - border - card ) ;
border-radius : 8 px ;
padding : 4 px 6 px ;
opacity : 0 ;
transition : opacity 0.2 s ;
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 : 1 px solid transparent ;
color : var ( - - text - dim ) ;
width : 26 px ; height : 26 px ;
border-radius : 4 px ;
cursor : pointer ;
font-size : 0.75 rem ;
display : flex ; align-items : center ; justify-content : center ;
transition : 0.15 s ;
}
. 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 : 1 px ;
background : var ( - - border ) ;
margin : 2 px 2 px ;
}
. md-toolbar-size-input {
width : 60 px ;
background : transparent ;
border : 1 px solid var ( - - border ) ;
border-radius : 4 px ;
color : var ( - - text ) ;
font-family : 'JetBrains Mono' , monospace ;
font-size : 0.7 rem ;
text-align : center ;
padding : 0 4 px ;
height : 26 px ;
}
. md-toolbar-size-input : focus {
outline : none ;
border-color : var ( - - primary ) ;
}
footer {
padding : 60 px 8 % 30 px ;
text-align : center ;
color : var ( - - text - dim ) ;
font-size : 0.9 rem ;
}
@ media ( max-width : 900px ) {
. layout {
grid-template-columns : 1 fr ;
gap : 1 rem ;
}
. sidebar {
position : static ;
}
}
@ media ( max-width : 768px ) {
. page-header { padding : 40 px 5 % 30 px ; }
. page-header h1 { font-size : 1.7 rem ; letter-spacing : 1 px ; }
. page-header p { font-size : 0.9 rem ; }
. activity-calendar { padding : 1 rem ; }
. calendar-grid { min-width : 600 px ; }
. layout { padding : 30 px 5 % ; }
. main-toolbar h2 { font-size : 1.3 rem ; }
. post-card { padding : 1.2 rem ; }
. post-card h3 { font-size : 1 rem ; }
. post-card-header { gap : 0.5 rem ; }
. post-meta { font-size : 0.7 rem ; }
. modal { padding : 1.4 rem ; max-height : 92 vh ; border-radius : 0.8 rem ; }
. modal h2 { font-size : 1.15 rem ; }
. form-row , . form-row-3 { grid-template-columns : 1 fr ; gap : 0 ; }
. form-group label { font-size : 0.75 rem ; }
. form-group input , . form-group textarea , . form-group select {
padding : 10 px ; font-size : 0.9 rem ;
}
. modal-actions { flex-direction : column-reverse ; gap : 8 px ; }
. modal-actions . btn { width : 100 % ; justify-content : center ; padding : 0.8 rem ; font-size : 0.8 rem ; }
. post-detail-header h1 { font-size : 1.3 rem ; }
. markdown-body { font-size : 0.9 rem ; }
. markdown-body h1 { font-size : 1.3 rem ; }
. markdown-body h2 { font-size : 1.15 rem ; }
footer { padding : 40 px 5 % 20 px ; font-size : 0.8 rem ; }
}
< / 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 > © 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 >
2026-05-31 22:23:51 +09:00
< input type = "color" id = "categoryColor" value = "#00f2ff" style = "height: 44px;" >
2026-05-31 21:05:59 +09:00
< / 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 = "## 배운 것 여기에 학습 내용을 자유롭게 작성하세요. ```javascript // 코드 블록도 가능합니다 console.log('hello'); ```" > < / 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 ;
2026-05-31 22:23:51 +09:00
let csrfToken = '' ;
2026-05-31 21:05:59 +09:00
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 ) {
2026-05-31 22:23:51 +09:00
result . width = sanitizeMediaWidth ( m [ 1 ] . trim ( ) ) ;
2026-05-31 21:05:59 +09:00
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 ) {
2026-05-31 22:23:51 +09:00
const safeUrl = sanitizeUrl ( url ) ;
if ( ! safeUrl ) return '' ;
2026-05-31 21:05:59 +09:00
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 ( ';' ) } " ` ;
2026-05-31 22:23:51 +09:00
return ` <div class="md-video-wrap" ${ wrapStyle } ><video class="md-video" preload="metadata" src=" ${ escapeHtml ( safeUrl ) } "></video></div> ` ;
2026-05-31 21:05:59 +09:00
}
) ;
// ===== 2) 이미지에 |속성 적용:  =====
// marked가 이미지를 변환하기 전에 미리 속성을 빼서 처리
src = src . replace (
/!\[([^\]]*?)\|([^\]]+)\]\(([^)]+)\)/g ,
function ( match , alt , attrStr , url ) {
2026-05-31 22:23:51 +09:00
const safeUrl = sanitizeUrl ( url ) ;
if ( ! safeUrl ) return '' ;
2026-05-31 21:05:59 +09:00
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 ( /;$/ , '' ) ) ;
2026-05-31 22:23:51 +09:00
return ` <img src=" ${ escapeHtml ( safeUrl ) } " alt=" ${ escapeHtml ( alt ) } " style=" ${ styles . join ( ';' ) } ;"> ` ;
2026-05-31 21:05:59 +09:00
}
) ;
// ===== 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} =====
2026-05-31 22:23:51 +09:00
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 ;
} ) ;
2026-05-31 21:05:59 +09:00
2026-05-31 22:23:51 +09:00
return sanitizeAllowedStyles ( DOMPurify . sanitize ( marked . parse ( src ) ) ) ;
2026-05-31 21:05:59 +09:00
}
// 렌더 후 비디오에 커스텀 컨트롤 부착
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 ) {
2026-05-31 22:23:51 +09:00
widthVal = sanitizeMediaWidth ( widthVal ) ;
2026-05-31 21:05:59 +09:00
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' ) {
//  또는 
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 ;
2026-05-31 22:23:51 +09:00
csrfToken = data . csrf _token || '' ;
2026-05-31 21:05:59 +09:00
document . body . classList . toggle ( 'admin-on' , isAdmin ) ;
} catch ( e ) { isAdmin = false ; }
}
2026-05-31 22:23:51 +09:00
function csrfHeaders ( headers = { } ) {
return csrfToken ? { ... headers , 'X-CSRF-Token' : csrfToken } : headers ;
}
function csrfFetch ( url , options = { } ) {
return fetch ( url , { ... options , headers : csrfHeaders ( options . headers || { } ) } ) ;
}
2026-05-31 21:05:59 +09:00
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 ) {
2026-05-31 22:23:51 +09:00
csrfToken = data . csrf _token || csrfToken ;
2026-05-31 21:05:59 +09:00
isAdmin = true ;
document . body . classList . add ( 'admin-on' ) ;
closeLoginModal ( ) ;
} else {
showAlert ( 'loginAlert' , data . error || '로그인 실패' , 'error' ) ;
}
} catch ( e ) {
showAlert ( 'loginAlert' , '서버 오류' , 'error' ) ;
}
}
async function logout ( ) {
2026-05-31 22:23:51 +09:00
await csrfFetch ( 'api/auth.php?action=logout' , { method : 'POST' } ) ;
csrfToken = '' ;
2026-05-31 21:05:59 +09:00
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>
2026-05-31 22:23:51 +09:00
<span class="cat-dot" style="background: ${ escapeHtml ( sanitizeCssColor ( parent . color , '#00f2ff' ) ) } ;"></span>
2026-05-31 21:05:59 +09:00
<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">
2026-05-31 22:23:51 +09:00
<span class="cat-dot" style="background: ${ escapeHtml ( sanitizeCssColor ( child . color , '#00f2ff' ) ) } ;"></span>
2026-05-31 21:05:59 +09:00
<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 ;
2026-05-31 22:23:51 +09:00
const catColor = cat ? sanitizeCssColor ( cat . color , '#00f2ff' ) : '#00f2ff' ;
2026-05-31 21:05:59 +09:00
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">
2026-05-31 22:23:51 +09:00
<span class="cat-dot" style="background: ${ escapeHtml ( sanitizeCssColor ( cat . color , '#00f2ff' ) ) } ;"></span>
2026-05-31 21:05:59 +09:00
${ 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">
2026-05-31 22:23:51 +09:00
<span class="cat-dot" style="background: ${ escapeHtml ( sanitizeCssColor ( cat . color , '#00f2ff' ) ) } ;"></span>
2026-05-31 21:05:59 +09:00
${ 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 ( ) ;
// 이미지 마크다운:  또는 
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 {
// 글 삭제
2026-05-31 22:23:51 +09:00
const res = await csrfFetch ( ` api/learning.php?id= ${ currentDetailId } ` , { method : 'DELETE' } ) ;
2026-05-31 21:05:59 +09:00
const result = await res . json ( ) ;
if ( ! result . success ) {
alert ( result . error || '글 삭제 실패' ) ;
return ;
}
// 첨부 파일 삭제 (선택했을 때)
if ( deleteFiles && attachedUrls . length > 0 ) {
try {
2026-05-31 22:23:51 +09:00
const fileRes = await csrfFetch ( 'api/delete_files.php' , {
2026-05-31 21:05:59 +09:00
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' ) {
2026-05-31 22:23:51 +09:00
width = sanitizeMediaWidth ( widthCust ) ;
2026-05-31 21:05:59 +09:00
} else if ( widthSel ) {
2026-05-31 22:23:51 +09:00
width = sanitizeMediaWidth ( widthSel ) ;
2026-05-31 21:05:59 +09:00
}
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 ? `  \n ` : `  \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 ( ) {
2026-05-31 22:23:51 +09:00
const url = sanitizeUrl ( document . getElementById ( 'imageInsertUrl' ) . value . trim ( ) ) ;
2026-05-31 21:05:59 +09:00
const alt = document . getElementById ( 'imageInsertAlt' ) . value . trim ( ) ;
const kind = document . getElementById ( 'imageInsertKind' ) . value ;
if ( ! url ) {
2026-05-31 22:23:51 +09:00
showAlert ( 'imageInsertAlert' , 'http, https, uploads/ 경로만 사용할 수 있습니다' , 'error' ) ;
2026-05-31 21:05:59 +09:00
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' ) ;
2026-05-31 22:23:51 +09:00
const res = await csrfFetch ( 'api/upload.php' , { method : 'POST' , body : formData } ) ;
2026-05-31 21:05:59 +09:00
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 = [
// 세이지/민트 계열
2026-05-31 22:23:51 +09:00
'#00f2ff' , '#3d6b50' , '#8ab89a' , '#B2E2D2' ,
2026-05-31 21:05:59 +09:00
// 웜 뉴트럴
'#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 ) {
2026-05-31 22:23:51 +09:00
const safeColor = sanitizeCssColor ( color ) ;
if ( safeColor ) wrapSelection ( ` {color: ${ safeColor } } ` , '{/color}' , '' ) ;
2026-05-31 21:05:59 +09:00
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 ) ;
2026-05-31 22:23:51 +09:00
res = await csrfFetch ( 'api/learning.php' , {
2026-05-31 21:05:59 +09:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( data )
} ) ;
} else {
2026-05-31 22:23:51 +09:00
res = await csrfFetch ( 'api/learning.php' , {
2026-05-31 21:05:59 +09:00
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 ;
2026-05-31 22:23:51 +09:00
document . getElementById ( 'categoryColor' ) . value = sanitizeCssColor ( cat . color , '#00f2ff' ) ;
2026-05-31 21:05:59 +09:00
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 = '' ;
2026-05-31 22:23:51 +09:00
document . getElementById ( 'categoryColor' ) . value = '#00f2ff' ;
2026-05-31 21:05:59 +09:00
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 {
2026-05-31 22:23:51 +09:00
const res = await csrfFetch ( ` api/categories.php?id= ${ id } ` , { method : 'DELETE' } ) ;
2026-05-31 21:05:59 +09:00
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 ) ;
2026-05-31 22:23:51 +09:00
res = await csrfFetch ( 'api/categories.php' , {
2026-05-31 21:05:59 +09:00
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( payload )
} ) ;
} else {
2026-05-31 22:23:51 +09:00
res = await csrfFetch ( 'api/categories.php' , {
2026-05-31 21:05:59 +09:00
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 , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' )
. replace ( /"/g , '"' ) . replace ( /'/g , ''' ) ;
}
2026-05-31 22:23:51 +09:00
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 ;
}
2026-05-31 21:05:59 +09:00
// 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 >