1936 lines
71 KiB
HTML
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>© 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, '&').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 '';
|
|
}
|
|
|
|
// ESC 키로 모달 닫기 (바깥 클릭은 무시 - 실수 방지)
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
document.querySelectorAll('.modal-overlay.active').forEach(m => {
|
|
m.classList.remove('active');
|
|
});
|
|
}
|
|
});
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|