Files

1936 lines
71 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 Developer 프로젝트 모음">
<meta property="og:title" content="이종재 | Game & XR Developer">
<meta property="og:description" content="Game & XR Developer 이종재의 프로젝트 포트폴리오">
<meta property="og:type" content="website">
<meta property="og:url" content="https://portfolio.whdwo798.synology.me">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="이종재 | Game & XR Developer">
<meta name="twitter:description" content="Game & XR Developer 이종재의 프로젝트 포트폴리오">
<title>이종재 | Game & XR Developer</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Noto+Sans+KR:wght@300;400;700&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">
<style>
/* ===== 라이트 모드 (기본) ===== */
header {
min-height: 60vh;
display: flex; flex-direction: column; justify-content: center; align-items: center;
text-align: center; padding: 80px 20px;
background:
radial-gradient(circle at 30% 50%, var(--primary-dim) 0%, transparent 50%),
radial-gradient(circle at 70% 50%, var(--border) 0%, transparent 50%);
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 3.5rem; margin-bottom: 1rem;
color: var(--text);
letter-spacing: 2px;
}
header p { font-size: 1.2rem; color: var(--text-dim); max-width: 800px; padding: 0 20px; }
.highlight { color: var(--primary); }
main { padding: 80px 8%; }
.section-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4rem; }
.section-header h2 { font-size: 2rem; letter-spacing: 1px; }
.line { flex-grow: 1; height: 1px; background: var(--border-card); }
.admin-controls { display: flex; gap: 10px; }
.admin-controls.hidden { display: none !important; }
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1.5rem;
max-width: 1600px;
margin: 0 auto;
}
.card {
background: var(--bg-card);
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--border);
transition: 0.3s ease;
position: relative;
display: flex;
flex-direction: column;
height: 100%;
container-type: inline-size; /* container query 활성화 */
container-name: card;
}
/* 카드 너비가 좁을 때 버튼 텍스트 숨기고 아이콘만 */
@container card (max-width: 200px) {
.btn-text { display: none; }
.btn-link, .btn.btn-outline, .btn.btn-danger {
padding: 0.6rem;
justify-content: center;
}
.card-stack { display: none; } /* 스택 태그도 숨김 */
.card-period { font-size: 0.65rem; }
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
}
.card-content > p {
flex: 1;
}
.card:hover {
transform: translateY(-10px); border-color: var(--primary);
box-shadow: 0 10px 40px var(--border);
}
/* ===== 카드 슬라이드쇼 ===== */
.card-media {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: linear-gradient(135deg, #1e1e30 0%, #2a1a3e 100%);
overflow: hidden;
}
.card-media-icon {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 2.8rem; color: var(--border);
}
.slideshow {
position: relative;
width: 100%; height: 100%;
}
.slideshow-slide {
position: absolute; inset: 0;
background-size: cover;
background-position: center;
background-color: var(--bg-deep);
opacity: 0;
transition: opacity 0.6s ease;
}
.slideshow-slide.active { opacity: 1; }
.slideshow-arrow {
position: absolute;
top: 50%; transform: translateY(-50%);
width: 36px; height: 36px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
border: 1px solid var(--border);
border-radius: 50%;
color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
opacity: 0;
transition: opacity 0.3s, background 0.2s;
z-index: 3;
}
.slideshow-arrow:hover {
background: var(--primary);
border-color: var(--primary);
color: var(--bg);
}
.slideshow-arrow.prev { left: 10px; }
.slideshow-arrow.next { right: 10px; }
.card:hover .slideshow-arrow { opacity: 1; }
.slideshow-dots {
position: absolute;
bottom: 10px;
left: 50%; transform: translateX(-50%);
display: flex; gap: 6px;
z-index: 3;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
border-radius: 12px;
}
.slideshow-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-dim);
cursor: pointer;
transition: 0.2s;
}
.slideshow-dot.active {
background: var(--primary);
width: 18px;
border-radius: 3px;
box-shadow: 0 0 6px var(--primary);
}
.slideshow-counter {
position: absolute;
top: 10px; right: 10px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
color: var(--primary);
font-family: 'Orbitron', sans-serif;
font-size: 0.7rem;
padding: 4px 10px;
border-radius: 10px;
border: 1px solid rgba(var(--primary-rgb, 92,138,106), 0.3);
z-index: 3;
}
.card-content { padding: 1.2rem; }
.card-label {
color: var(--primary); font-size: 0.65rem; letter-spacing: 1.5px;
margin-bottom: 0.6rem; display: block; font-weight: 700;
}
.card h3 { font-size: 1.05rem; margin-bottom: 0.6rem; line-height: 1.3; }
.card p { color: var(--text-dim); margin-bottom: 1rem; font-size: 0.85rem; }
.card-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.card-actions.admin-mode {
border-top: 1px dashed var(--border);
padding-top: 0.8rem; margin-top: 0.8rem;
}
.btn-git {
display: inline-flex; align-items: center; gap: 8px;
text-decoration: none; color: var(--bg); background: var(--primary);
padding: 0.7rem 1.2rem; border-radius: 0.6rem;
font-weight: 700; font-size: 0.85rem; transition: 0.3s;
}
.btn-git:hover { background: white; }
.empty-state {
text-align: center; padding: 60px 20px; color: var(--text-dim);
}
.empty-state i { font-size: 3rem; opacity: 0.3; margin-bottom: 1rem; }
/* ===== 모달 ===== */
/* ===== 아이콘 픽커 ===== */
.icon-picker-wrap {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px; padding: 12px;
}
.icon-search {
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px; padding: 10px 12px;
color: var(--text); font-family: 'Noto Sans KR', sans-serif;
font-size: 0.9rem; margin-bottom: 12px;
}
.icon-search:focus { outline: none; border-color: var(--primary); }
.icon-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
max-height: 220px;
overflow-y: auto;
padding: 4px;
}
.icon-grid::-webkit-scrollbar { width: 6px; }
.icon-grid::-webkit-scrollbar-track { background: transparent; }
.icon-grid::-webkit-scrollbar-thumb { background: var(--border-card); border-radius: 3px; }
.icon-cell {
aspect-ratio: 1;
display: flex; align-items: center; justify-content: center;
background: var(--bg-card);
border: 1px solid transparent;
border-radius: 6px; cursor: pointer;
font-size: 1.1rem; color: var(--text-dim);
transition: 0.15s; position: relative;
}
.icon-cell:hover {
background: var(--primary-dim);
color: var(--primary);
transform: translateY(-2px);
}
.icon-cell.selected {
background: var(--border);
border-color: var(--primary); color: var(--primary);
box-shadow: 0 0 12px rgba(var(--primary-rgb, 92,138,106), 0.3);
}
.icon-selected-info {
display: flex; align-items: center; gap: 10px;
margin-top: 10px; padding: 10px;
background: var(--primary-dim);
border: 1px solid var(--border-card);
border-radius: 6px; font-size: 0.85rem;
}
.icon-selected-info .preview {
font-size: 1.4rem; color: var(--primary);
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border-radius: 6px;
}
.icon-selected-info .label-mono {
font-family: monospace; color: var(--primary); font-size: 0.8rem;
}
.icon-custom-toggle {
margin-top: 10px; font-size: 0.8rem; color: var(--text-dim);
cursor: pointer; display: inline-block;
}
.icon-custom-toggle:hover { color: var(--primary); }
.icon-custom-input { display: none; margin-top: 8px; }
.icon-custom-input.visible { display: block; }
.icon-empty {
grid-column: 1 / -1; text-align: center; padding: 20px;
color: var(--text-dim); font-size: 0.85rem;
}
/* ===== 이미지 업로더 (다중) ===== */
.image-uploader {
background: var(--bg-deep);
border: 2px dashed rgba(var(--primary-rgb, 92,138,106), 0.3);
border-radius: 10px;
padding: 20px 16px;
text-align: center;
cursor: pointer;
transition: 0.25s;
}
.image-uploader:hover {
border-color: var(--primary);
background: var(--primary-dim);
}
.image-uploader.dragover {
border-color: var(--primary);
background: var(--primary-dim);
transform: scale(1.01);
box-shadow: 0 0 20px var(--border-card);
}
.image-uploader.uploading { pointer-events: none; opacity: 0.7; }
.image-uploader .upload-icon {
font-size: 1.6rem; color: var(--primary); margin-bottom: 8px;
}
.image-uploader .upload-text {
color: var(--text); font-size: 0.9rem; margin-bottom: 4px;
}
.image-uploader .upload-hint {
color: var(--text-dim); font-size: 0.75rem;
}
.image-uploader .upload-btn {
display: inline-block; margin-top: 10px;
padding: 6px 14px;
background: var(--border);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: 6px;
font-size: 0.8rem;
font-family: 'Orbitron', sans-serif; letter-spacing: 1px;
}
.image-uploader-hidden-input { display: none; }
.image-progress {
display: none; margin-top: 10px;
height: 4px; background: var(--bg-card);
border-radius: 2px; overflow: hidden;
}
.image-progress.active { display: block; }
.image-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--secondary), var(--primary));
width: 0%; transition: width 0.3s;
}
.image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
margin-top: 12px;
}
.image-list:empty { display: none; }
.image-item {
position: relative;
aspect-ratio: 4/3;
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: grab;
transition: 0.2s;
}
.image-item.dragging {
opacity: 0.4;
cursor: grabbing;
}
.image-item.drag-over {
border-color: var(--primary);
transform: scale(1.05);
}
.image-item img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.image-item-overlay {
position: absolute; inset: 0;
background: linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.8) 100%);
opacity: 0;
transition: 0.2s;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 6px;
}
.image-item:hover .image-item-overlay { opacity: 1; }
.image-item-badge {
align-self: flex-start;
background: var(--primary);
color: var(--bg);
font-family: 'Orbitron', sans-serif;
font-size: 0.6rem;
padding: 2px 6px;
border-radius: 4px;
font-weight: 700;
letter-spacing: 0.5px;
}
.image-item.is-cover .image-item-overlay {
opacity: 1;
background: linear-gradient(180deg, var(--border) 0%, rgba(0,0,0,0.7) 100%);
}
.image-item.is-cover { border-color: var(--primary); }
.image-item-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.image-item-btn {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
border: 1px solid var(--border);
color: #fff;
width: 26px; height: 26px;
border-radius: 4px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem;
transition: 0.2s;
}
.image-item-btn:hover {
background: var(--danger);
border-color: var(--danger);
}
.image-item-drag-handle {
position: absolute;
top: 6px; left: 6px;
width: 22px; height: 22px;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem;
opacity: 0;
transition: 0.2s;
pointer-events: none;
}
.image-item:hover .image-item-drag-handle { opacity: 1; }
.image-list-hint {
margin-top: 8px;
font-size: 0.75rem;
color: var(--text-dim);
display: none;
}
.image-list-hint.visible { display: block; }
.image-url-fallback {
margin-top: 10px; font-size: 0.75rem; color: var(--text-dim);
}
.image-url-fallback a { color: var(--primary); cursor: pointer; text-decoration: none; }
.image-url-fallback a:hover { text-decoration: underline; }
.image-url-input { display: none; margin-top: 8px; }
.image-url-input.visible { display: block; }
.image-url-input .url-row { display: flex; gap: 6px; }
.image-url-input .url-row input { flex: 1; }
.image-url-input .url-row button {
padding: 0 14px;
background: var(--primary);
color: var(--bg);
border: none;
border-radius: 6px;
cursor: pointer;
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 1px;
font-weight: 700;
}
/* ===== 프로젝트 카드 메타 ===== */
.card-period {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-dim);
font-size: 0.75rem;
font-family: 'JetBrains Mono', 'Courier New', monospace;
margin-bottom: 0.7rem;
}
.card-period i { color: var(--primary); font-size: 0.8rem; }
.card-stack {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 1.2rem;
}
.card-stack-tag {
background: var(--primary-dim);
color: var(--primary);
padding: 3px 9px;
border-radius: 12px;
font-size: 0.72rem;
border: 1px solid var(--border-card);
font-family: 'JetBrains Mono', 'Courier New', monospace;
}
.card-actions a, .card-actions button {
flex: 1;
min-width: 0;
}
.btn-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
text-decoration: none;
padding: 0.6rem 0.7rem;
border-radius: 0.6rem;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0;
transition: 0.25s;
border: 1px solid;
font-family: 'Noto Sans KR', sans-serif;
cursor: pointer;
background: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
width: 100%;
}
.btn-link .btn-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-link.git {
background: var(--primary);
color: var(--bg);
border-color: var(--primary);
}
.btn-link.git:hover { background: #fff; border-color: #fff; }
.btn-link.video {
background: var(--primary);
color: var(--bg);
border-color: var(--primary);
}
.btn-link.video:hover {
background: transparent;
color: var(--primary);
border-color: var(--primary);
}
/* ===== 페이지네이션 ===== */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 3rem;
flex-wrap: wrap;
}
.pagination button {
min-width: 40px;
height: 40px;
padding: 0 12px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
cursor: pointer;
font-family: 'Orbitron', sans-serif;
font-size: 0.85rem;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.pagination button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.pagination button.active {
background: var(--primary);
color: var(--bg);
border-color: var(--primary);
font-weight: 700;
}
.pagination button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pagination .pagination-info {
color: var(--text-dim);
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
margin: 0 8px;
}
/* ===== 비디오 업로드 탭 ===== */
.video-upload-wrap {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.video-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.video-tab {
flex: 1;
padding: 10px;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
font-family: 'Orbitron', sans-serif;
font-size: 0.75rem;
letter-spacing: 1px;
transition: 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-bottom: 2px solid transparent;
}
.video-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
background: var(--primary-dim);
}
.video-tab-pane { display: none; padding: 12px; }
.video-tab-pane.active { display: block; }
/* 임시 이미지 (저장 전 로컬 미리보기) - 노란 점으로 표시 */
.image-item.is-local {
border-color: rgba(255, 209, 102, 0.5);
}
.image-item.is-local .image-item-badge {
background: #ffd166;
color: var(--bg);
}
/* ===== 테마 토글 버튼 ===== */
.grid { grid-template-columns: repeat(4, 1fr); }
}
/* 좁은 데스크톱: 3열 */
@media (max-width: 1100px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
/* 태블릿: 2열 */
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
header { min-height: auto; padding: 60px 5%; }
header h1 { font-size: 1.9rem; letter-spacing: 1px; line-height: 1.3; }
header p { font-size: 0.95rem; padding: 0; }
main { padding: 50px 5%; }
.grid { grid-template-columns: 1fr; gap: 1.5rem; }
.section-header { flex-wrap: wrap; gap: 12px; margin-bottom: 2.5rem; }
.section-header h2 { font-size: 1.5rem; }
.admin-controls { width: 100%; flex-wrap: wrap; }
.admin-controls .btn { flex: 1; font-size: 0.7rem; padding: 0.55rem 0.8rem; justify-content: center; }
.card-media { height: 180px; }
.card-media-icon { font-size: 2.8rem; }
.slideshow-arrow { opacity: 1; width: 32px; height: 32px; } /* 모바일은 항상 표시 */
.card-content { padding: 1.4rem; }
.card h3 { font-size: 1.2rem; }
.card p { font-size: 0.9rem; }
.btn-git { width: 100%; justify-content: center; font-size: 0.8rem; }
.btn-link { font-size: 0.7rem; padding: 0.55rem 0.6rem; }
.card-actions.admin-mode .btn { flex: 1; font-size: 0.7rem; padding: 0.55rem 0.5rem; }
.modal {
background: var(--bg-card); color: var(--text); padding: 1.4rem; max-height: 92vh; border-radius: 0.8rem; }
.modal h2 { font-size: 1.15rem; margin-bottom: 1.2rem; }
.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; }
.icon-grid { grid-template-columns: repeat(6, 1fr); gap: 5px; max-height: 200px; }
.icon-cell { font-size: 1rem; }
.image-uploader { padding: 16px 12px; }
.image-list { grid-template-columns: repeat(3, 1fr); }
footer { padding: 40px 5% 20px; font-size: 0.8rem; }
}
@media (max-width: 380px) {
header h1 { font-size: 1.6rem; }
header p { font-size: 0.85rem; }
.section-header h2 { font-size: 1.3rem; }
.icon-grid { grid-template-columns: repeat(5, 1fr); }
.image-list { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<nav>
<a href="index.html" class="logo">JONGJAE.XR</a>
<div class="links">
<a href="index.html" class="nav-active">PROJECTS</a>
<a href="learning.html">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>
<header>
<h1>이종재 <span class="highlight">Portfolio</span></h1>
<p>Game & XR 개발자로서의 기술적 도전과 기록을 담은 공간입니다.<br>Gitea 서버를 통해 실제 <span class="highlight">소스 코드</span>를 확인하실 수 있습니다.</p>
</header>
<main id="work">
<div class="section-header">
<h2>DEVELOPMENT LOG</h2>
<div class="line"></div>
<div class="admin-controls hidden" id="adminControls">
<button class="btn btn-primary" onclick="openProjectModal()">
<i class="fa-solid fa-plus"></i> 새 프로젝트
</button>
<button class="btn btn-outline" onclick="logout()">
<i class="fa-solid fa-right-from-bracket"></i> 로그아웃
</button>
</div>
</div>
<div class="grid" id="projectsGrid"></div>
<div class="pagination" id="pagination"></div>
</main>
<footer>
<p>&copy; 2026 Lee Jong-jae. Hosted on Private Synology NAS.</p>
<span class="admin-toggle" onclick="openLoginModal()" title="Admin">
<i class="fa-solid fa-shield-halved"></i>
</span>
</footer>
<!-- 로그인 모달 -->
<div class="modal-overlay" id="loginModal" role="dialog" aria-modal="true" aria-label="관리자 로그인">
<div class="modal">
<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-outline" onclick="closeLoginModal()">취소</button>
<button class="btn btn-primary" onclick="doLogin()">로그인</button>
</div>
</div>
</div>
<!-- 프로젝트 등록/수정 모달 -->
<div class="modal-overlay" id="projectModal" role="dialog" aria-modal="true" aria-label="프로젝트 등록/수정">
<div class="modal">
<button class="modal-close-x" onclick="closeProjectModal()" title="닫기">
<i class="fa-solid fa-xmark"></i>
</button>
<h2 id="projectModalTitle">새 프로젝트</h2>
<div id="projectAlert"></div>
<input type="hidden" id="projectId">
<div class="form-group">
<label>제목 *</label>
<input type="text" id="projectTitle" placeholder="예: XR 인터랙티브 시뮬레이션">
</div>
<div class="form-group">
<label>라벨 *</label>
<input type="text" id="projectLabel" placeholder="예: UNITY / XR">
</div>
<div class="form-group">
<label>설명 *</label>
<textarea id="projectDescription" placeholder="프로젝트 소개"></textarea>
</div>
<div class="form-group">
<label>아이콘</label>
<div class="icon-picker-wrap">
<input type="text" class="icon-search" id="iconSearch" placeholder="🔍 검색 (예: 게임, code, server)" oninput="filterIcons()">
<div class="icon-grid" id="iconGrid"></div>
<div class="icon-selected-info">
<div class="preview" id="iconPreview"><i class="fa-solid fa-circle-question"></i></div>
<div>
<div id="iconSelectedLabel">아이콘을 선택하세요</div>
<div class="label-mono" id="iconSelectedClass"></div>
</div>
</div>
<span class="icon-custom-toggle" onclick="toggleCustomIcon()">
<i class="fa-solid fa-pen-to-square"></i> 직접 입력하기 (Font Awesome 클래스)
</span>
<div class="icon-custom-input" id="iconCustomInput">
<input type="text" id="projectIconCustom" placeholder="예: fa-solid fa-rocket" oninput="syncCustomIcon()" style="margin-top: 4px;">
</div>
</div>
<input type="hidden" id="projectIcon" value="fa-solid fa-code">
</div>
<div class="form-group">
<label>썸네일 이미지 (여러 장 가능)</label>
<div class="image-uploader" id="imageUploader" onclick="document.getElementById('imageFileInput').click()">
<input type="file" id="imageFileInput" class="image-uploader-hidden-input" accept="image/jpeg,image/png,image/gif,image/webp" multiple>
<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 · 각 5MB 이하 · 프로젝트 제목 폴더에 저장됩니다</div>
<div class="upload-btn">
<i class="fa-solid fa-folder-open"></i> 파일 선택
</div>
</div>
<div class="image-progress" id="imageProgress">
<div class="image-progress-fill" id="imageProgressFill"></div>
</div>
<div class="image-list" id="imageList"></div>
<div class="image-list-hint" id="imageListHint">
<i class="fa-solid fa-info-circle"></i> 첫 번째 이미지가 카드 대표 이미지입니다. 드래그로 순서를 바꿀 수 있어요.
</div>
<div class="image-url-fallback">
<a onclick="toggleImageUrlInput()">
<i class="fa-solid fa-link"></i> 외부 이미지 URL 추가
</a>
</div>
<div class="image-url-input" id="imageUrlInputWrap">
<div class="url-row">
<input type="text" id="projectImageUrl" placeholder="https://..." onkeypress="if(event.key==='Enter'){event.preventDefault();addUrlImage();}">
<button type="button" onclick="addUrlImage()">추가</button>
</div>
</div>
</div>
<div class="form-group">
<label>개발 기간 (선택)</label>
<div style="display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center;">
<input type="date" id="projectPeriodStart" placeholder="시작">
<span style="color: var(--text-dim);">~</span>
<input type="date" id="projectPeriodEnd" placeholder="종료 (비우면 진행 중)">
</div>
</div>
<div class="form-group">
<label>사용한 기술 스택 (쉼표로 구분)</label>
<input type="text" id="projectStack" placeholder="예: Unity, C#, URP, Meta Quest SDK">
</div>
<div class="form-group">
<label>링크 URL (Gitea 저장소 등)</label>
<input type="text" id="projectLink" placeholder="https://...">
</div>
<div class="form-group">
<label>실행 영상 (선택)</label>
<div class="video-upload-wrap">
<div class="video-tabs">
<button type="button" class="video-tab active" onclick="switchVideoTab('file')">
<i class="fa-solid fa-upload"></i> 파일 업로드
</button>
<button type="button" class="video-tab" onclick="switchVideoTab('url')">
<i class="fa-solid fa-link"></i> 외부 URL
</button>
</div>
<div class="video-tab-pane active" id="videoTabFile">
<div class="image-uploader" id="videoUploader" style="margin-top:0;">
<input type="file" id="videoFileInput" class="image-uploader-hidden-input"
accept="video/mp4,video/webm,video/ogg,video/quicktime">
<div class="upload-icon"><i class="fa-solid fa-film"></i></div>
<div class="upload-text">동영상 파일 선택</div>
<div class="upload-hint">MP4 / WebM / MOV · 저장 시 업로드됩니다</div>
<div class="upload-btn"><i class="fa-solid fa-folder-open"></i> 파일 선택</div>
</div>
<div id="videoPreviewWrap" style="display:none; margin-top:8px;">
<video id="videoPreview" controls style="width:100%; max-height:160px; border-radius:6px; background:#000;"></video>
<button type="button" onclick="clearVideo()" style="margin-top:6px; background:transparent; border:1px solid var(--danger); color:var(--danger); padding:4px 12px; border-radius:6px; cursor:pointer; font-size:0.8rem;">
<i class="fa-solid fa-trash"></i> 제거
</button>
<p style="color:var(--text-dim); font-size:0.75rem; margin-top:4px;">
<i class="fa-solid fa-info-circle"></i> 저장 버튼을 눌러야 NAS에 업로드됩니다
</p>
</div>
</div>
<div class="video-tab-pane" id="videoTabUrl">
<input type="text" id="projectVideoUrl" placeholder="https://youtu.be/... (YouTube, Vimeo, 외부 링크 등)"
style="margin-top:4px;">
</div>
</div>
<input type="hidden" id="projectVideoFinal"> <!-- 최종 저장될 video_url -->
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeProjectModal()">취소</button>
<button class="btn btn-primary" onclick="saveProject()">
<i class="fa-solid fa-floppy-disk"></i> 저장
</button>
</div>
</div>
</div>
<script>
// ===== 상태 =====
let isAdmin = false;
let csrfToken = '';
// ===== 테마 =====
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 projects = [];
let selectedIcon = 'fa-solid fa-code';
let editingImages = []; // { url: string, file?: File, isLocal: boolean }
// isLocal=true: 아직 NAS에 안 올라간 임시 파일 (File 객체 + 로컬 ObjectURL)
// isLocal=false: 이미 NAS에 있는 파일 (url은 실제 경로)
let slideshowTimers = {};
let currentPage = 1;
const ITEMS_PER_PAGE = 5;
// ===== 아이콘 라이브러리 =====
const ICON_LIBRARY = [
{ cls: 'fa-solid fa-gamepad', label: '게임패드', keywords: 'game gaming controller 게임 컨트롤러' },
{ cls: 'fa-solid fa-cube', label: '큐브', keywords: 'cube 3d block xr 큐브 3D 블록' },
{ cls: 'fa-solid fa-cubes', label: '큐브들', keywords: 'cubes blocks xr unity 유니티 블록' },
{ cls: 'fa-solid fa-vr-cardboard', label: 'VR', keywords: 'vr xr headset 가상현실 헤드셋' },
{ cls: 'fa-solid fa-dice', label: '주사위', keywords: 'dice random game 주사위 랜덤' },
{ cls: 'fa-solid fa-puzzle-piece', label: '퍼즐', keywords: 'puzzle piece 퍼즐' },
{ cls: 'fa-solid fa-dragon', label: '드래곤', keywords: 'dragon fantasy game 드래곤 판타지' },
{ cls: 'fa-solid fa-ghost', label: '유령', keywords: 'ghost halloween game 유령' },
{ cls: 'fa-solid fa-trophy', label: '트로피', keywords: 'trophy win achievement 우승 업적' },
{ cls: 'fa-solid fa-code', label: '코드', keywords: 'code dev programming 코드 개발 프로그래밍' },
{ cls: 'fa-solid fa-terminal', label: '터미널', keywords: 'terminal cli console 터미널 콘솔' },
{ cls: 'fa-solid fa-laptop-code', label: '노트북코드', keywords: 'laptop dev coding 노트북 개발' },
{ cls: 'fa-solid fa-bug', label: '버그', keywords: 'bug debug 버그 디버그' },
{ cls: 'fa-brands fa-git-alt', label: 'Git', keywords: 'git github gitea 깃' },
{ cls: 'fa-brands fa-github', label: 'GitHub', keywords: 'github 깃허브' },
{ cls: 'fa-solid fa-code-branch', label: '브랜치', keywords: 'branch fork 브랜치 포크' },
{ cls: 'fa-solid fa-code-pull-request', label: 'PR', keywords: 'pull request merge PR 풀리퀘스트' },
{ cls: 'fa-solid fa-database', label: 'DB', keywords: 'database storage 데이터베이스 저장' },
{ cls: 'fa-solid fa-server', label: '서버', keywords: 'server backend 서버 백엔드' },
{ cls: 'fa-solid fa-microchip', label: '칩', keywords: 'chip cpu hardware 칩 CPU 하드웨어' },
{ cls: 'fa-solid fa-network-wired', label: '네트워크', keywords: 'network lan 네트워크 랜' },
{ cls: 'fa-solid fa-cloud', label: '클라우드', keywords: 'cloud aws 클라우드' },
{ cls: 'fa-brands fa-docker', label: 'Docker', keywords: 'docker container 도커 컨테이너' },
{ cls: 'fa-solid fa-shield-halved', label: '보안', keywords: 'security shield 보안 방패' },
{ cls: 'fa-solid fa-lock', label: '잠금', keywords: 'lock security 잠금 보안' },
{ cls: 'fa-solid fa-hard-drive', label: '저장소', keywords: 'disk storage hdd 디스크 저장소' },
{ cls: 'fa-solid fa-paint-brush', label: '붓', keywords: 'paint brush design 디자인 붓' },
{ cls: 'fa-solid fa-palette', label: '팔레트', keywords: 'palette color 팔레트 색상' },
{ cls: 'fa-solid fa-pen-nib', label: '펜', keywords: 'pen draw 펜 그리기' },
{ cls: 'fa-solid fa-camera', label: '카메라', keywords: 'camera photo 카메라 사진' },
{ cls: 'fa-solid fa-image', label: '이미지', keywords: 'image picture 이미지 그림' },
{ cls: 'fa-solid fa-film', label: '영상', keywords: 'film movie video 영상 영화' },
{ cls: 'fa-solid fa-rocket', label: '로켓', keywords: 'rocket launch 로켓 발사' },
{ cls: 'fa-solid fa-bolt', label: '번개', keywords: 'bolt lightning fast 번개 빠름' },
{ cls: 'fa-solid fa-fire', label: '불', keywords: 'fire hot 불 핫' },
{ cls: 'fa-solid fa-star', label: '별', keywords: 'star favorite 별 즐겨찾기' },
{ cls: 'fa-solid fa-gear', label: '톱니', keywords: 'gear settings 톱니 설정' },
{ cls: 'fa-solid fa-flask', label: '플라스크', keywords: 'flask experiment lab 실험' },
{ cls: 'fa-solid fa-robot', label: '로봇', keywords: 'robot ai 로봇 인공지능' },
{ cls: 'fa-solid fa-mobile-screen', label: '모바일', keywords: 'mobile phone app 모바일 앱' },
{ cls: 'fa-solid fa-globe', label: '지구', keywords: 'globe web world 지구 웹' },
{ cls: 'fa-solid fa-brain', label: '뇌', keywords: 'brain ai think 뇌 사고' },
{ cls: 'fa-solid fa-chart-line', label: '차트', keywords: 'chart graph data 차트 그래프' },
{ cls: 'fa-solid fa-book', label: '책', keywords: 'book document 책 문서' }
];
// ===== 초기화 =====
async function init() {
await checkAuth();
await loadProjects();
renderIconGrid();
initImageUploader();
}
// ===== 인증 =====
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.getElementById('adminControls').classList.toggle('hidden', !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.getElementById('adminControls').classList.remove('hidden');
closeLoginModal();
await loadProjects();
} 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.getElementById('adminControls').classList.add('hidden');
await loadProjects();
}
// ===== 프로젝트 로드 =====
async function loadProjects() {
try {
// 기존 슬라이드쇼 타이머 정리
Object.values(slideshowTimers).forEach(t => clearInterval(t));
slideshowTimers = {};
const res = await fetch('api/projects.php');
projects = await res.json();
renderProjects();
} catch (e) {
console.error('Failed to load projects', e);
}
}
function renderProjects() {
const grid = document.getElementById('projectsGrid');
const pagination = document.getElementById('pagination');
if (!projects || projects.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<i class="fa-solid fa-folder-open"></i>
<p>아직 등록된 프로젝트가 없습니다.</p>
</div>`;
if (pagination) pagination.innerHTML = '';
return;
}
// 기존 슬라이드쇼 타이머 모두 정리 (페이지 전환 시)
Object.keys(slideshowTimers).forEach(k => {
clearInterval(slideshowTimers[k]);
delete slideshowTimers[k];
});
// 페이지네이션 계산
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
if (currentPage > totalPages) currentPage = totalPages;
if (currentPage < 1) currentPage = 1;
const startIdx = (currentPage - 1) * ITEMS_PER_PAGE;
const pageProjects = projects.slice(startIdx, startIdx + ITEMS_PER_PAGE);
grid.innerHTML = pageProjects.map(p => {
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
const hasMultiple = images.length > 1;
const safeLink = sanitizeUrl(p.link);
const safeVideoUrl = sanitizeUrl(p.video_url);
const cardId = `card-${p.id}`;
return `
<div class="card" id="${cardId}">
<div class="card-media">
${images.length === 0 ? `
<div class="card-media-icon">
<i class="${escapeHtml(p.icon || 'fa-solid fa-code')}"></i>
</div>
` : `
<div class="slideshow"
data-card-id="${cardId}"
data-index="0"
data-count="${images.length}"
onmouseenter="pauseSlideshow('${cardId}')"
onmouseleave="resumeSlideshow('${cardId}')">
${images.map((img, i) => `
<div class="slideshow-slide ${i === 0 ? 'active' : ''}"
style="background-image:url('${escapeHtml(sanitizeUrl(img))}')"></div>
`).join('')}
${hasMultiple ? `
<button class="slideshow-arrow prev" onclick="slideshowPrev('${cardId}')" aria-label="이전">
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="slideshow-arrow next" onclick="slideshowNext('${cardId}')" aria-label="다음">
<i class="fa-solid fa-chevron-right"></i>
</button>
<div class="slideshow-counter">
<span class="current">1</span> / ${images.length}
</div>
<div class="slideshow-dots">
${images.map((_, i) => `
<div class="slideshow-dot ${i === 0 ? 'active' : ''}"
onclick="slideshowGoTo('${cardId}', ${i})"></div>
`).join('')}
</div>
` : ''}
</div>
`}
</div>
<div class="card-content">
<span class="card-label">${escapeHtml(p.label)}</span>
<h3>${escapeHtml(p.title)}</h3>
${formatPeriod(p.period_start, p.period_end) ? `
<div class="card-period">
<i class="fa-solid fa-calendar-days"></i>
${formatPeriod(p.period_start, p.period_end)}
</div>
` : ''}
<p>${escapeHtml(p.description)}</p>
${(p.stack && p.stack.length > 0) ? `
<div class="card-stack">
${p.stack.map(s => `<span class="card-stack-tag">${escapeHtml(s)}</span>`).join('')}
</div>
` : ''}
<div class="card-actions">
${safeLink ? `<a href="${escapeHtml(safeLink)}" class="btn-link git" target="_blank" rel="noopener noreferrer">
<i class="fa-brands fa-git-alt"></i><span class="btn-text"> 소스 코드</span>
</a>` : ''}
${safeVideoUrl ? `<a href="${escapeHtml(safeVideoUrl)}" class="btn-link video" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-circle-play"></i><span class="btn-text"> 영상 보기</span>
</a>` : ''}
</div>
${isAdmin ? `
<div class="card-actions admin-mode">
<button class="btn btn-outline" onclick="editProject(${p.id})">
<i class="fa-solid fa-pen"></i><span class="btn-text"> 수정</span>
</button>
<button class="btn btn-danger" onclick="deleteProject(${p.id})">
<i class="fa-solid fa-trash"></i><span class="btn-text"> 삭제</span>
</button>
</div>` : ''}
</div>
</div>`;
}).join('');
// 현재 페이지의 슬라이드쇼만 시작
pageProjects.forEach(p => {
const images = (p.images && p.images.length > 0) ? p.images : (p.image ? [p.image] : []);
if (images.length > 1) {
startSlideshow(`card-${p.id}`);
}
});
// 페이지네이션 렌더링
renderPagination(totalPages);
}
function renderPagination(totalPages) {
const pagination = document.getElementById('pagination');
if (!pagination) return;
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
const buttons = [];
// 이전 버튼
buttons.push(`
<button onclick="goToPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''} title="이전">
<i class="fa-solid fa-chevron-left"></i>
</button>
`);
// 페이지 번호 (현재 페이지 주변 ±2까지 표시)
const showPages = new Set();
showPages.add(1);
showPages.add(totalPages);
for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) {
showPages.add(i);
}
const sortedPages = Array.from(showPages).sort((a, b) => a - b);
let lastShown = 0;
sortedPages.forEach(p => {
if (p - lastShown > 1) {
buttons.push(`<span class="pagination-info">···</span>`);
}
buttons.push(`
<button class="${p === currentPage ? 'active' : ''}" onclick="goToPage(${p})">${p}</button>
`);
lastShown = p;
});
// 다음 버튼
buttons.push(`
<button onclick="goToPage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''} title="다음">
<i class="fa-solid fa-chevron-right"></i>
</button>
`);
// 정보
buttons.push(`<span class="pagination-info">${projects.length} projects</span>`);
pagination.innerHTML = buttons.join('');
}
function goToPage(page) {
currentPage = page;
renderProjects();
// 부드럽게 위로 스크롤
document.getElementById('work').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ===== 슬라이드쇼 =====
function startSlideshow(cardId) {
if (slideshowTimers[cardId]) clearInterval(slideshowTimers[cardId]);
slideshowTimers[cardId] = setInterval(() => slideshowNext(cardId, true), 4000);
}
function pauseSlideshow(cardId) {
if (slideshowTimers[cardId]) {
clearInterval(slideshowTimers[cardId]);
slideshowTimers[cardId] = null;
}
}
function resumeSlideshow(cardId) {
const card = document.getElementById(cardId);
if (!card) return;
const slideshow = card.querySelector('.slideshow');
if (!slideshow) return;
const count = parseInt(slideshow.dataset.count);
if (count > 1) startSlideshow(cardId);
}
function slideshowGoTo(cardId, index) {
const card = document.getElementById(cardId);
if (!card) return;
const slideshow = card.querySelector('.slideshow');
if (!slideshow) return;
const slides = slideshow.querySelectorAll('.slideshow-slide');
const dots = slideshow.querySelectorAll('.slideshow-dot');
const counter = slideshow.querySelector('.slideshow-counter .current');
slides.forEach((s, i) => s.classList.toggle('active', i === index));
dots.forEach((d, i) => d.classList.toggle('active', i === index));
if (counter) counter.textContent = index + 1;
slideshow.dataset.index = index;
}
function slideshowNext(cardId, isAuto = false) {
const card = document.getElementById(cardId);
if (!card) return;
const slideshow = card.querySelector('.slideshow');
if (!slideshow) return;
const count = parseInt(slideshow.dataset.count);
const current = parseInt(slideshow.dataset.index);
const next = (current + 1) % count;
slideshowGoTo(cardId, next);
if (!isAuto) {
// 수동 조작 시 타이머 리셋
pauseSlideshow(cardId);
startSlideshow(cardId);
}
}
function slideshowPrev(cardId) {
const card = document.getElementById(cardId);
if (!card) return;
const slideshow = card.querySelector('.slideshow');
if (!slideshow) return;
const count = parseInt(slideshow.dataset.count);
const current = parseInt(slideshow.dataset.index);
const prev = (current - 1 + count) % count;
slideshowGoTo(cardId, prev);
pauseSlideshow(cardId);
startSlideshow(cardId);
}
// ===== 헬퍼: 개발 기간 포맷팅 =====
function formatPeriod(start, end) {
if (!start && !end) return '';
const fmt = (v) => {
if (!v) return '';
// YYYY-MM-DD 형식 → YYYY.MM.DD
const m = String(v).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[1]}.${m[2]}.${m[3]}`;
// YYYY-MM 형식 (구버전 호환) → YYYY.MM
const m2 = String(v).match(/^(\d{4})-(\d{2})/);
if (m2) return `${m2[1]}.${m2[2]}`;
return v;
};
const s = fmt(start);
const e = fmt(end);
if (s && e) return `${s} ~ ${e}`;
if (s && !end) return `${s} ~ 진행 중`;
if (!s && e) return `~ ${e}`;
return s || e;
}
// 날짜 input 호환을 위한 정규화 (YYYY-MM → YYYY-MM-01)
function normalizeDateForInput(v) {
if (!v) return '';
const s = String(v);
// 이미 YYYY-MM-DD면 그대로
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// YYYY-MM이면 1일 추가
const m = s.match(/^(\d{4}-\d{2})$/);
if (m) return m[1] + '-01';
return '';
}
// ===== 프로젝트 모달 =====
function openProjectModal(project = null) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
document.getElementById('projectAlert').innerHTML = '';
if (project) {
document.getElementById('projectModalTitle').textContent = '프로젝트 수정';
document.getElementById('projectId').value = project.id;
document.getElementById('projectTitle').value = project.title;
document.getElementById('projectLabel').value = project.label;
document.getElementById('projectDescription').value = project.description;
document.getElementById('projectLink').value = project.link || '';
// 기존 YYYY-MM 형식이면 YYYY-MM-01로 변환 (date input 호환성)
document.getElementById('projectPeriodStart').value = normalizeDateForInput(project.period_start);
document.getElementById('projectPeriodEnd').value = normalizeDateForInput(project.period_end);
document.getElementById('projectStack').value = (project.stack || []).join(', ');
// 비디오: 기존 값이 있으면 URL 탭으로 표시
const existingVideo = project.video_url || '';
if (existingVideo) {
switchVideoTab('url');
document.getElementById('projectVideoUrl').value = existingVideo;
} else {
switchVideoTab('file');
document.getElementById('projectVideoUrl').value = '';
clearVideo();
}
// 기존 이미지를 새 구조로 변환 (isLocal=false)
const imgs = (project.images && project.images.length > 0)
? project.images : (project.image ? [project.image] : []);
editingImages = imgs.map(u => ({ url: u, file: null, isLocal: false }));
selectIcon(project.icon || 'fa-solid fa-code');
} else {
document.getElementById('projectModalTitle').textContent = '새 프로젝트';
document.getElementById('projectId').value = '';
['projectTitle', 'projectLabel', 'projectDescription', 'projectLink',
'projectPeriodStart', 'projectPeriodEnd', 'projectStack'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('projectVideoUrl').value = '';
switchVideoTab('file');
clearVideo();
editingImages = [];
selectIcon('fa-solid fa-code');
}
renderImageList();
const iconSearch = document.getElementById('iconSearch');
const iconCustomInput = document.getElementById('iconCustomInput');
const iconCustomField = document.getElementById('projectIconCustom');
if (iconSearch) iconSearch.value = '';
if (iconCustomField) iconCustomField.value = '';
if (iconCustomInput) iconCustomInput.classList.remove('visible');
document.getElementById('imageUrlInputWrap').classList.remove('visible');
document.getElementById('projectImageUrl').value = '';
renderIconGrid('');
document.getElementById('projectModal').classList.add('active');
}
function closeProjectModal() {
// 사용하지 않은 임시 ObjectURL 해제
editingImages.forEach(item => {
if (typeof item === 'object' && item.isLocal && item.url) {
URL.revokeObjectURL(item.url);
}
});
editingImages = [];
clearVideo();
document.getElementById('projectModal').classList.remove('active');
}
function editProject(id) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
const project = projects.find(p => p.id === id);
if (project) openProjectModal(project);
}
// ===== 아이콘 픽커 =====
function renderIconGrid(filter = '') {
const grid = document.getElementById('iconGrid');
if (!grid) return;
const f = (filter || '').trim().toLowerCase();
const filtered = f
? ICON_LIBRARY.filter(ic =>
ic.label.toLowerCase().includes(f) ||
ic.cls.toLowerCase().includes(f) ||
ic.keywords.toLowerCase().includes(f)
)
: ICON_LIBRARY;
if (filtered.length === 0) {
grid.innerHTML = `<div class="icon-empty">검색 결과가 없습니다. "직접 입력하기"를 사용해보세요.</div>`;
return;
}
grid.innerHTML = filtered.map(ic => `
<div class="icon-cell ${ic.cls === selectedIcon ? 'selected' : ''}"
onclick="selectIcon('${ic.cls}')"
title="${escapeHtml(ic.label)}">
<i class="${ic.cls}"></i>
</div>
`).join('');
}
function filterIcons() {
renderIconGrid(document.getElementById('iconSearch').value);
}
function selectIcon(cls) {
selectedIcon = cls;
document.getElementById('projectIcon').value = cls;
const preview = document.getElementById('iconPreview');
const labelEl = document.getElementById('iconSelectedLabel');
const classEl = document.getElementById('iconSelectedClass');
if (preview) preview.innerHTML = `<i class="${escapeHtml(cls)}"></i>`;
const found = ICON_LIBRARY.find(ic => ic.cls === cls);
if (labelEl) labelEl.textContent = found ? found.label : '커스텀 아이콘';
if (classEl) classEl.textContent = cls;
document.querySelectorAll('.icon-cell').forEach(cell => {
cell.classList.remove('selected');
if (cell.querySelector('i')?.className === cls) {
cell.classList.add('selected');
}
});
}
function toggleCustomIcon() {
const wrap = document.getElementById('iconCustomInput');
wrap.classList.toggle('visible');
if (wrap.classList.contains('visible')) {
document.getElementById('projectIconCustom').focus();
}
}
function syncCustomIcon() {
const v = document.getElementById('projectIconCustom').value.trim();
if (v) selectIcon(v);
}
// ===== 이미지 업로더 =====
function initImageUploader() {
const uploader = document.getElementById('imageUploader');
const fileInput = document.getElementById('imageFileInput');
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
files.forEach(f => handleImageUpload(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 files = Array.from(e.dataTransfer.files);
files.forEach(f => handleImageUpload(f));
});
}
function handleImageUpload(file) {
if (!file.type.startsWith('image/')) {
showAlert('projectAlert', '이미지 파일만 업로드 가능합니다', 'error');
return;
}
// =====================================================
// 파일 사이즈 조정 (클라이언트 측 미리보기 검증용)
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
// =====================================================
if (file.size > MAX_IMAGE_SIZE) {
showAlert('projectAlert', `${file.name}: 5MB를 초과합니다`, 'error');
return;
}
// 즉시 NAS 업로드 X → 브라우저 메모리에 임시 보관
const localUrl = URL.createObjectURL(file);
editingImages.push({ url: localUrl, file: file, isLocal: true });
renderImageList();
showAlert('projectAlert', `'${file.name}' 추가됨 (저장 시 업로드)`, 'success');
}
function renderImageList() {
const list = document.getElementById('imageList');
const hint = document.getElementById('imageListHint');
if (editingImages.length === 0) {
list.innerHTML = '';
hint.classList.remove('visible');
return;
}
hint.classList.add('visible');
list.innerHTML = editingImages.map((item, idx) => {
// 하위 호환: 기존에 string으로 저장된 것도 지원
const url = typeof item === 'string' ? item : item.url;
const isLocal = typeof item === 'object' && item.isLocal;
return `
<div class="image-item ${idx === 0 ? 'is-cover' : ''} ${isLocal ? 'is-local' : ''}"
draggable="true"
data-index="${idx}"
ondragstart="onImageDragStart(event, ${idx})"
ondragend="onImageDragEnd(event)"
ondragover="onImageDragOver(event)"
ondragleave="onImageDragLeave(event)"
ondrop="onImageDrop(event, ${idx})">
<img src="${escapeHtml(url)}" alt="이미지 ${idx + 1}">
<div class="image-item-drag-handle">
<i class="fa-solid fa-grip-vertical"></i>
</div>
<div class="image-item-overlay">
${idx === 0 ? `<span class="image-item-badge">${isLocal ? '저장 시 업로드' : '대표'}</span>` : '<span></span>'}
<div class="image-item-actions">
<button type="button" class="image-item-btn" onclick="removeImageAt(${idx})" title="제거">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>`;
}).join('');
}
function removeImageAt(idx) {
const item = editingImages[idx];
// 로컬 ObjectURL이면 메모리 해제
if (typeof item === 'object' && item.isLocal && item.url) {
URL.revokeObjectURL(item.url);
}
editingImages.splice(idx, 1);
renderImageList();
}
// 드래그 앤 드롭 순서 변경
let dragSrcIndex = null;
function onImageDragStart(e, idx) {
dragSrcIndex = idx;
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Firefox 호환성
try { e.dataTransfer.setData('text/plain', String(idx)); } catch (_) {}
}
function onImageDragEnd(e) {
e.currentTarget.classList.remove('dragging');
document.querySelectorAll('.image-item.drag-over').forEach(el => el.classList.remove('drag-over'));
dragSrcIndex = null;
}
function onImageDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragSrcIndex !== null) {
e.currentTarget.classList.add('drag-over');
}
}
function onImageDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
function onImageDrop(e, dstIdx) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (dragSrcIndex === null || dragSrcIndex === dstIdx) return;
const [moved] = editingImages.splice(dragSrcIndex, 1);
editingImages.splice(dstIdx, 0, moved);
dragSrcIndex = null;
renderImageList();
}
function toggleImageUrlInput() {
const wrap = document.getElementById('imageUrlInputWrap');
wrap.classList.toggle('visible');
if (wrap.classList.contains('visible')) {
document.getElementById('projectImageUrl').focus();
}
}
function addUrlImage() {
const input = document.getElementById('projectImageUrl');
const url = input.value.trim();
if (!url) return;
editingImages.push({ url, file: null, isLocal: false });
input.value = '';
renderImageList();
}
// ===== 비디오 탭 =====
let pendingVideoFile = null; // 저장 전 임시 비디오 파일
function switchVideoTab(tab) {
document.querySelectorAll('.video-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.video-tab-pane').forEach(p => p.classList.remove('active'));
if (tab === 'file') {
document.querySelectorAll('.video-tab')[0].classList.add('active');
document.getElementById('videoTabFile').classList.add('active');
} else {
document.querySelectorAll('.video-tab')[1].classList.add('active');
document.getElementById('videoTabUrl').classList.add('active');
}
}
function clearVideo() {
if (pendingVideoFile) {
const preview = document.getElementById('videoPreview');
if (preview && preview.src) URL.revokeObjectURL(preview.src);
}
pendingVideoFile = null;
const wrap = document.getElementById('videoPreviewWrap');
const input = document.getElementById('videoFileInput');
if (wrap) wrap.style.display = 'none';
if (input) input.value = '';
}
// 비디오 파일 선택 이벤트 (초기화 시 1회만 등록)
(function initVideoUploader() {
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('videoFileInput');
const uploader = document.getElementById('videoUploader');
if (!input || !uploader) return;
uploader.addEventListener('click', (e) => {
if (e.target === input) return;
input.click();
});
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('video/')) {
alert('동영상 파일만 선택할 수 있습니다 (MP4/WebM/MOV 등)');
input.value = '';
return;
}
pendingVideoFile = file;
const localUrl = URL.createObjectURL(file);
const preview = document.getElementById('videoPreview');
const wrap = document.getElementById('videoPreviewWrap');
preview.src = localUrl;
wrap.style.display = 'block';
input.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 file = e.dataTransfer.files[0];
if (file && file.type.startsWith('video/')) {
input.dispatchEvent(Object.assign(new Event('change'), { target: { files: [file] } }));
}
});
});
})();
async function saveProject() {
const id = document.getElementById('projectId').value;
const title = document.getElementById('projectTitle').value.trim();
const stackStr = document.getElementById('projectStack').value.trim();
if (!title || !document.getElementById('projectLabel').value.trim() || !document.getElementById('projectDescription').value.trim()) {
showAlert('projectAlert', '제목, 라벨, 설명은 필수입니다', 'error');
return;
}
// 저장 버튼 비활성화 (중복 클릭 방지)
const saveBtn = document.querySelector('#projectModal .btn-primary[onclick="saveProject()"]');
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '업로드 중...'; }
try {
// 1) 이미지 중 isLocal=true인 것들 업로드
const uploadedImages = [];
for (let item of editingImages) {
if (typeof item === 'string') {
uploadedImages.push(item);
continue;
}
if (!item.isLocal) {
uploadedImages.push(item.url);
continue;
}
// 업로드 필요
if (!title) {
showAlert('projectAlert', '제목을 입력해야 이미지를 업로드할 수 있습니다', 'error');
return;
}
const formData = new FormData();
formData.append('file', item.file);
formData.append('project_title', title);
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '이미지 업로드 실패');
URL.revokeObjectURL(item.url); // 메모리 해제
uploadedImages.push(result.url);
}
// 2) 비디오 업로드 (파일 탭이 활성화 + pendingVideoFile 있을 때)
let videoUrl = '';
const activeVideoTab = document.querySelector('.video-tab.active');
const isFileTab = activeVideoTab && activeVideoTab.textContent.includes('파일');
if (isFileTab && pendingVideoFile) {
if (!title) {
showAlert('projectAlert', '제목을 입력해야 영상을 업로드할 수 있습니다', 'error');
return;
}
const formData = new FormData();
formData.append('file', pendingVideoFile);
formData.append('project_title', title);
const res = await csrfFetch('api/upload.php', { method: 'POST', body: formData });
const result = await res.json();
if (!result.success) throw new Error(result.error || '영상 업로드 실패');
videoUrl = result.url;
clearVideo();
} else if (!isFileTab) {
// URL 탭이면 직접 입력한 URL 사용
videoUrl = document.getElementById('projectVideoUrl').value.trim();
} else {
// 파일 탭인데 파일 없음 → 기존 video_url 유지 (수정 시)
const existingProject = projects.find(p => p.id === parseInt(id));
videoUrl = existingProject ? (existingProject.video_url || '') : '';
}
// 3) 데이터 저장
const data = {
title,
label: document.getElementById('projectLabel').value.trim(),
description: document.getElementById('projectDescription').value.trim(),
icon: document.getElementById('projectIcon').value.trim(),
images: uploadedImages,
link: document.getElementById('projectLink').value.trim(),
period_start: document.getElementById('projectPeriodStart').value.trim(),
period_end: document.getElementById('projectPeriodEnd').value.trim(),
stack: stackStr ? stackStr.split(',').map(s => s.trim()).filter(s => s) : [],
video_url: videoUrl
};
let res;
if (id) {
data.id = parseInt(id);
res = await csrfFetch('api/projects.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
res = await csrfFetch('api/projects.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await res.json();
if (result.success) {
closeProjectModal();
await loadProjects();
} else {
showAlert('projectAlert', result.error || '저장 실패', 'error');
}
} catch (e) {
showAlert('projectAlert', '오류: ' + e.message, 'error');
} finally {
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> 저장'; }
}
}
// 프로젝트 제목을 폴더명으로 변환 (서버 sanitize_folder_name과 동일 규칙)
function projectTitleToFolder(title) {
if (!title) return '';
let s = String(title).trim();
if (!s) return '';
s = s.replace(/[\/\\:*?"<>|]/gu, '');
s = s.replace(/\.\.+/gu, '');
s = s.replace(/\s+/gu, '-');
s = s.replace(/[^\p{L}\p{N}\-_]/gu, '');
s = s.replace(/^[-_]+|[-_]+$/g, '');
if (s.length > 50) s = s.substring(0, 50);
return s;
}
async function deleteProject(id) {
if (!isAdmin) {
alert('관리자 로그인이 필요합니다.');
return;
}
const project = projects.find(p => p.id === id);
const folderName = project ? projectTitleToFolder(project.title) : '';
const hasAttachments = project && (
(project.images && project.images.length > 0) ||
(project.image && project.image.startsWith('uploads/'))
);
if (!confirm('정말 이 프로젝트를 삭제하시겠습니까?')) return;
let deleteFolder = false;
if (hasAttachments && folderName) {
deleteFolder = confirm(
`이 프로젝트의 업로드 이미지 폴더(uploads/${folderName})도 함께 삭제하시겠습니까?\n\n` +
`[확인] 폴더와 안의 모든 파일을 영구 삭제\n` +
`[취소] 프로젝트만 삭제하고 파일은 보존`
);
}
try {
const res = await csrfFetch(`api/projects.php?id=${id}`, { method: 'DELETE' });
const result = await res.json();
if (!result.success) {
alert(result.error || '삭제 실패');
return;
}
if (deleteFolder && folderName) {
try {
const fileRes = await csrfFetch('api/delete_files.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: folderName })
});
const fileResult = await fileRes.json();
if (!fileResult.success || fileResult.failed_count > 0) {
alert('프로젝트는 삭제되었지만, 일부 파일 삭제에 실패했습니다.');
}
} catch (e) {
alert('프로젝트는 삭제되었지만, 폴더 삭제 중 오류: ' + e.message);
}
}
await loadProjects();
} catch (e) {
alert('서버 오류');
}
}
// ===== 유틸 =====
function showAlert(elemId, message, type) {
const elem = document.getElementById(elemId);
elem.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
if (type === 'success') {
setTimeout(() => elem.innerHTML = '', 3000);
}
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
function sanitizeUrl(url) {
if (!url) return '';
const trimmed = String(url).trim();
if (/^uploads\/[A-Za-z0-9가-힣._~!$&'()*+,;=:@%/-]+$/u.test(trimmed) &&
!trimmed.includes('..') && !/(^|\/)\./.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return trimmed;
} catch (e) {}
return '';
}
// ESC 키로 모달 닫기 (바깥 클릭은 무시 - 실수 방지)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => {
m.classList.remove('active');
});
}
});
init();
</script>
</body>
</html>