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