ADD : 배경 일람 페이지 추가

This commit is contained in:
68893236+KINDNICK@users.noreply.github.com 2026-01-08 02:02:24 +09:00
parent 7bbdb45202
commit f368cb2f6a
6 changed files with 1050 additions and 11 deletions

View File

@ -20,7 +20,10 @@
"Bash(ipconfig)", "Bash(ipconfig)",
"Bash(ping:*)", "Bash(ping:*)",
"Bash(dir:*)", "Bash(dir:*)",
"Bash(nul)" "Bash(nul)",
"Bash(git add:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd : 포트폴리오 영상 추가 및 수정\n\n- Merry & Happy \\(트와이스\\) 치요x마늘 커버 영상 추가\n- 첫사랑 설명 수정\n- 이무지 생방송 링크 삭제\n- 크리스마스 모션캡쳐 합방 영상 추가\n- 춤짱자매즈 스트리머 이름 수정 \\(흰콕 & 호발\\)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git push)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

126
backgrounds.html Normal file
View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>배경 씬 라이브러리 - 밍글 스튜디오</title>
<!-- 파비콘 -->
<link rel="icon" type="image/x-icon" href="/mingle-logo.ico">
<link rel="shortcut icon" href="/mingle-logo.ico">
<!-- Theme Color -->
<meta name="theme-color" content="#ff8800">
<!-- SEO -->
<meta name="description" content="밍글 스튜디오 스트리밍글 서비스용 배경 씬 라이브러리">
<meta name="robots" content="noindex, nofollow">
<!-- 폰트 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" rel="stylesheet">
<!-- CSS -->
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/backgrounds.css">
</head>
<body>
<!-- 헤더 -->
<div id="header-placeholder"></div>
<!-- 메인 콘텐츠 -->
<main class="backgrounds-page">
<div class="container">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>배경 씬 라이브러리</h1>
<p class="page-description">스트리밍글 서비스에서 사용 가능한 배경 씬 목록입니다</p>
<div class="last-updated" id="lastUpdated"></div>
</div>
<!-- 필터 & 검색 -->
<div class="filter-section">
<div class="search-box">
<input type="text" id="searchInput" placeholder="배경 이름으로 검색..." class="search-input">
<span class="search-icon">🔍</span>
</div>
<div class="filter-tags" id="filterTags">
<button class="filter-tag active" data-tag="all">전체</button>
<!-- 태그 버튼들이 동적으로 추가됨 -->
</div>
<div class="view-options">
<button class="view-btn active" data-view="grid" title="그리드 보기">
<span></span>
</button>
<button class="view-btn" data-view="list" title="리스트 보기">
<span></span>
</button>
</div>
</div>
<!-- 통계 -->
<div class="stats-bar">
<span class="stat-item">
<strong id="totalCount">0</strong>개 배경
</span>
<span class="stat-item">
<strong id="filteredCount">0</strong>개 표시 중
</span>
</div>
<!-- 배경 그리드 -->
<div class="backgrounds-grid" id="backgroundsGrid">
<!-- 배경 카드들이 동적으로 추가됨 -->
<div class="loading-placeholder">
<div class="loading-spinner"></div>
<p>배경 데이터를 불러오는 중...</p>
</div>
</div>
<!-- 데이터 없음 메시지 -->
<div class="no-data" id="noData" style="display: none;">
<div class="no-data-icon">📭</div>
<h3>배경 데이터가 없습니다</h3>
<p>Unity에서 배경 데이터를 업로드해 주세요.</p>
</div>
<!-- 검색 결과 없음 -->
<div class="no-results" id="noResults" style="display: none;">
<div class="no-results-icon">🔍</div>
<h3>검색 결과가 없습니다</h3>
<p>다른 검색어나 필터를 시도해 보세요.</p>
</div>
</div>
</main>
<!-- 이미지 모달 -->
<div class="image-modal" id="imageModal">
<div class="modal-overlay"></div>
<div class="modal-content">
<button class="modal-close">&times;</button>
<img src="" alt="" class="modal-image" id="modalImage">
<div class="modal-info">
<h3 id="modalTitle"></h3>
<p id="modalCategory"></p>
<div class="modal-tags" id="modalTags"></div>
</div>
</div>
</div>
<!-- 푸터 -->
<div id="footer-placeholder"></div>
<!-- 백업 푸터 -->
<footer class="section" style="background:#222;color:#fff;padding:2.5rem 0 1.2rem;">
<div class="container" style="text-align:center;">
<div style="color:#bbb;font-size:0.98rem;">© 2025 밍글 스튜디오. All rights reserved.</div>
</div>
</footer>
<!-- JavaScript -->
<script src="js/common.js"></script>
<script src="js/backgrounds.js"></script>
</body>
</html>

441
css/backgrounds.css Normal file
View File

@ -0,0 +1,441 @@
/* ========================================
배경 라이브러리 페이지 스타일
======================================== */
/* 페이지 헤더 */
.backgrounds-page {
min-height: calc(100vh - var(--navbar-height));
padding: var(--spacing-2xl) 0;
}
.page-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
}
.page-header h1 {
font-size: var(--font-4xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.page-description {
font-size: var(--font-lg);
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.last-updated {
font-size: var(--font-sm);
color: var(--text-light);
}
/* 필터 섹션 */
.filter-section {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
align-items: center;
margin-bottom: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--bg-white);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
}
.search-box {
position: relative;
flex: 1;
min-width: 250px;
}
.search-input {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
padding-left: 3rem;
border: 2px solid #eee;
border-radius: var(--border-radius-full);
font-size: var(--font-base);
transition: var(--transition);
}
.search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(255, 136, 0, 0.1);
}
.search-icon {
position: absolute;
left: var(--spacing-md);
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
flex: 2;
}
.filter-tag {
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid #eee;
border-radius: var(--border-radius-full);
background: var(--bg-white);
cursor: pointer;
font-size: var(--font-sm);
font-weight: 500;
transition: var(--transition);
}
.filter-tag:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.filter-tag.active {
background: var(--gradient-main);
color: white;
border-color: transparent;
}
.view-options {
display: flex;
gap: var(--spacing-xs);
}
.view-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #eee;
border-radius: var(--border-radius-sm);
background: var(--bg-white);
cursor: pointer;
font-size: 1.2rem;
transition: var(--transition);
}
.view-btn:hover {
border-color: var(--primary-color);
}
.view-btn.active {
background: var(--primary-color);
color: white;
border-color: transparent;
}
/* 통계 바 */
.stats-bar {
display: flex;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
font-size: var(--font-sm);
color: var(--text-secondary);
}
.stat-item strong {
color: var(--primary-color);
font-weight: 700;
}
/* 배경 그리드 */
.backgrounds-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-lg);
}
.backgrounds-grid.list-view {
grid-template-columns: 1fr;
}
/* 배경 카드 */
.background-card {
background: var(--bg-white);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--box-shadow);
transition: var(--transition);
cursor: pointer;
}
.background-card:hover {
transform: translateY(-5px);
box-shadow: var(--box-shadow-lg);
}
.card-thumbnail {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #f0f0f0;
}
.card-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.background-card:hover .card-thumbnail img {
transform: scale(1.05);
}
.card-thumbnail .no-thumbnail {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e0e0e0 0%, #f5f5f5 100%);
color: var(--text-light);
font-size: 3rem;
}
.card-category {
position: absolute;
top: var(--spacing-sm);
left: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: var(--font-xs);
font-weight: 500;
border-radius: var(--border-radius-sm);
}
.card-content {
padding: var(--spacing-md);
}
.card-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.card-tag {
padding: 2px 8px;
background: var(--bg-gray);
color: var(--text-secondary);
font-size: var(--font-xs);
border-radius: var(--border-radius-sm);
}
/* 리스트 뷰 카드 */
.backgrounds-grid.list-view .background-card {
display: flex;
flex-direction: row;
}
.backgrounds-grid.list-view .card-thumbnail {
width: 200px;
min-width: 200px;
aspect-ratio: auto;
height: 112px;
}
.backgrounds-grid.list-view .card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
/* 로딩 */
.loading-placeholder {
grid-column: 1 / -1;
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-secondary);
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #f0f0f0;
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 데이터 없음 / 검색 결과 없음 */
.no-data,
.no-results {
text-align: center;
padding: var(--spacing-3xl);
color: var(--text-secondary);
}
.no-data-icon,
.no-results-icon {
font-size: 4rem;
margin-bottom: var(--spacing-md);
}
.no-data h3,
.no-results h3 {
font-size: var(--font-2xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
/* 모달 */
.image-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
}
.image-modal.active {
display: flex;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
cursor: pointer;
}
.modal-content {
position: relative;
max-width: 90%;
max-height: 90%;
z-index: 1;
}
.modal-close {
position: absolute;
top: -40px;
right: 0;
width: 40px;
height: 40px;
background: transparent;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
transition: var(--transition);
}
.modal-close:hover {
color: var(--primary-color);
}
.modal-image {
max-width: 100%;
max-height: 70vh;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow-lg);
}
.modal-info {
background: var(--bg-white);
padding: var(--spacing-lg);
border-radius: 0 0 var(--border-radius) var(--border-radius);
margin-top: -5px;
}
.modal-info h3 {
font-size: var(--font-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.modal-info p {
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.modal-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.modal-tags .card-tag {
background: var(--primary-color);
color: white;
}
/* 반응형 */
@media (max-width: 768px) {
.page-header h1 {
font-size: var(--font-3xl);
}
.filter-section {
flex-direction: column;
align-items: stretch;
}
.search-box {
min-width: 100%;
}
.filter-tags {
justify-content: center;
}
.view-options {
justify-content: center;
}
.backgrounds-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.backgrounds-grid.list-view .background-card {
flex-direction: column;
}
.backgrounds-grid.list-view .card-thumbnail {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
.stats-bar {
justify-content: center;
}
}
@media (max-width: 480px) {
.backgrounds-grid {
grid-template-columns: 1fr;
}
}

4
data/backgrounds.json Normal file
View File

@ -0,0 +1,4 @@
{
"lastUpdated": "2025-01-08T12:00:00",
"backgrounds": []
}

349
js/backgrounds.js Normal file
View File

@ -0,0 +1,349 @@
/**
* 배경 라이브러리 JavaScript
* Unity에서 업로드된 JSON 데이터를 기반으로 배경 목록을 표시
*/
(function() {
'use strict';
// 데이터 파일 경로
const DATA_PATH = 'data/backgrounds.json';
// 상태
let backgroundsData = [];
let filteredData = [];
let currentTag = 'all';
let currentView = 'grid';
let searchQuery = '';
// DOM 요소
const elements = {
grid: document.getElementById('backgroundsGrid'),
filterTags: document.getElementById('filterTags'),
searchInput: document.getElementById('searchInput'),
totalCount: document.getElementById('totalCount'),
filteredCount: document.getElementById('filteredCount'),
lastUpdated: document.getElementById('lastUpdated'),
noData: document.getElementById('noData'),
noResults: document.getElementById('noResults'),
imageModal: document.getElementById('imageModal'),
modalImage: document.getElementById('modalImage'),
modalTitle: document.getElementById('modalTitle'),
modalCategory: document.getElementById('modalCategory'),
modalTags: document.getElementById('modalTags')
};
/**
* 초기화
*/
async function init() {
await loadData();
setupEventListeners();
}
/**
* 데이터 로드
*/
async function loadData() {
try {
const response = await fetch(DATA_PATH + '?t=' + Date.now());
if (!response.ok) {
throw new Error('데이터를 찾을 수 없습니다');
}
const data = await response.json();
backgroundsData = data.backgrounds || [];
// 마지막 업데이트 시간 표시
if (data.lastUpdated) {
const date = new Date(data.lastUpdated);
elements.lastUpdated.textContent = `마지막 업데이트: ${formatDate(date)}`;
}
// 태그 필터 생성
createTagFilters();
// 통계 업데이트
elements.totalCount.textContent = backgroundsData.length;
// 데이터 렌더링
filterAndRender();
} catch (error) {
console.error('데이터 로드 실패:', error);
showNoData();
}
}
/**
* 태그 필터 버튼 생성
*/
function createTagFilters() {
// 모든 태그 수집
const allTags = new Set();
backgroundsData.forEach(bg => {
if (bg.tags && Array.isArray(bg.tags)) {
bg.tags.forEach(tag => allTags.add(tag));
}
});
// 기존 태그 버튼 제거 (전체 버튼 제외)
const existingTags = elements.filterTags.querySelectorAll('.filter-tag:not([data-tag="all"])');
existingTags.forEach(tag => tag.remove());
// 태그 버튼 생성
allTags.forEach(tag => {
const btn = document.createElement('button');
btn.className = 'filter-tag';
btn.dataset.tag = tag;
btn.textContent = tag;
btn.addEventListener('click', () => setTagFilter(tag));
elements.filterTags.appendChild(btn);
});
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 검색
elements.searchInput.addEventListener('input', debounce((e) => {
searchQuery = e.target.value.toLowerCase();
filterAndRender();
}, 300));
// 전체 태그 필터
const allTagBtn = elements.filterTags.querySelector('[data-tag="all"]');
if (allTagBtn) {
allTagBtn.addEventListener('click', () => setTagFilter('all'));
}
// 뷰 전환
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentView = btn.dataset.view;
updateViewMode();
});
});
// 모달 닫기
elements.imageModal.querySelector('.modal-overlay').addEventListener('click', closeModal);
elements.imageModal.querySelector('.modal-close').addEventListener('click', closeModal);
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
}
/**
* 태그 필터 설정
*/
function setTagFilter(tag) {
currentTag = tag;
// 버튼 활성화 상태 업데이트
document.querySelectorAll('.filter-tag').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tag === tag);
});
filterAndRender();
}
/**
* 필터링 렌더링
*/
function filterAndRender() {
// 필터링
filteredData = backgroundsData.filter(bg => {
// 태그 필터
if (currentTag !== 'all') {
if (!bg.tags || !bg.tags.includes(currentTag)) {
return false;
}
}
// 검색 필터
if (searchQuery) {
const searchTarget = `${bg.sceneName} ${bg.categoryName} ${(bg.tags || []).join(' ')}`.toLowerCase();
if (!searchTarget.includes(searchQuery)) {
return false;
}
}
return true;
});
// 통계 업데이트
elements.filteredCount.textContent = filteredData.length;
// 렌더링
render();
}
/**
* 카드 렌더링
*/
function render() {
// 로딩/에러 상태 숨기기
const loadingPlaceholder = elements.grid.querySelector('.loading-placeholder');
if (loadingPlaceholder) {
loadingPlaceholder.remove();
}
elements.noData.style.display = 'none';
elements.noResults.style.display = 'none';
// 데이터 없음
if (backgroundsData.length === 0) {
elements.grid.innerHTML = '';
showNoData();
return;
}
// 검색 결과 없음
if (filteredData.length === 0) {
elements.grid.innerHTML = '';
elements.noResults.style.display = 'block';
return;
}
// 카드 생성
elements.grid.innerHTML = filteredData.map(bg => createCard(bg)).join('');
// 카드 클릭 이벤트
elements.grid.querySelectorAll('.background-card').forEach((card, index) => {
card.addEventListener('click', () => openModal(filteredData[index]));
});
// 뷰 모드 적용
updateViewMode();
}
/**
* 카드 HTML 생성
*/
function createCard(bg) {
const thumbnailHtml = bg.thumbnailUrl
? `<img src="${escapeHtml(bg.thumbnailUrl)}" alt="${escapeHtml(bg.sceneName)}" loading="lazy">`
: `<div class="no-thumbnail">🏞️</div>`;
const tagsHtml = (bg.tags || [])
.map(tag => `<span class="card-tag">${escapeHtml(tag)}</span>`)
.join('');
const category = bg.category || extractCategory(bg.categoryName);
return `
<div class="background-card" data-scene="${escapeHtml(bg.sceneName)}">
<div class="card-thumbnail">
${thumbnailHtml}
<span class="card-category">${escapeHtml(category)}</span>
</div>
<div class="card-content">
<h3 class="card-title">${escapeHtml(bg.sceneName)}</h3>
<div class="card-tags">${tagsHtml}</div>
</div>
</div>
`;
}
/**
* 모드 업데이트
*/
function updateViewMode() {
elements.grid.classList.toggle('list-view', currentView === 'list');
}
/**
* 모달 열기
*/
function openModal(bg) {
if (bg.thumbnailUrl) {
elements.modalImage.src = bg.thumbnailUrl;
elements.modalImage.alt = bg.sceneName;
} else {
elements.modalImage.src = '';
elements.modalImage.alt = '';
}
elements.modalTitle.textContent = bg.sceneName;
elements.modalCategory.textContent = bg.categoryName;
elements.modalTags.innerHTML = (bg.tags || [])
.map(tag => `<span class="card-tag">${escapeHtml(tag)}</span>`)
.join('');
elements.imageModal.classList.add('active');
document.body.style.overflow = 'hidden';
}
/**
* 모달 닫기
*/
function closeModal() {
elements.imageModal.classList.remove('active');
document.body.style.overflow = '';
}
/**
* 데이터 없음 표시
*/
function showNoData() {
elements.grid.innerHTML = '';
elements.noData.style.display = 'block';
}
/**
* 카테고리 추출 (폴더명에서)
*/
function extractCategory(folderName) {
if (!folderName) return '기타';
const match = folderName.match(/\[([^\]]+)\]/);
return match ? match[1] : folderName;
}
/**
* 날짜 포맷
*/
function formatDate(date) {
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* HTML 이스케이프
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* 디바운스
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', init);
})();

136
server.py
View File

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
밍글 스튜디오 웹사이트 로컬 개발 서버 밍글 스튜디오 웹사이트 개발 서버
Python 기반 HTTP 서버로 개발 테스트 지원 Python 기반 HTTP 서버로 개발, 테스트 API 지원
""" """
import http.server import http.server
@ -11,7 +11,10 @@ import webbrowser
import os import os
import sys import sys
import socket import socket
import json
from pathlib import Path from pathlib import Path
from datetime import datetime
from urllib.parse import parse_qs, urlparse
# 한글 출력을 위한 인코딩 설정 # 한글 출력을 위한 인코딩 설정
if os.name == 'nt': # Windows if os.name == 'nt': # Windows
@ -32,34 +35,142 @@ HOST = '0.0.0.0' # 모든 인터페이스에서 접근 가능
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
"""커스텀 HTTP 요청 핸들러""" """커스텀 HTTP 요청 핸들러"""
# API 데이터 파일 경로
BACKGROUNDS_DATA_FILE = 'data/backgrounds.json'
def end_headers(self): def end_headers(self):
# CORS 헤더 추가 (개발용) # CORS 헤더 추가 (개발용)
self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key')
# 캐시 비활성화 (개발용) # 캐시 비활성화 (개발용)
self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
self.send_header('Pragma', 'no-cache') self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0') self.send_header('Expires', '0')
super().end_headers() super().end_headers()
def do_OPTIONS(self):
"""OPTIONS 요청 처리 (CORS preflight)"""
self.send_response(200)
self.end_headers()
def do_GET(self): def do_GET(self):
"""GET 요청 처리""" """GET 요청 처리"""
original_path = self.path parsed_path = urlparse(self.path)
# API 엔드포인트: GET /api/backgrounds
if parsed_path.path == '/api/backgrounds':
self.handle_get_backgrounds()
return
# 기본 파일 처리 # 기본 파일 처리
if self.path == '/': if self.path == '/':
self.path = '/index.html' self.path = '/index.html'
# .html 확장자 없이 접근 시 자동으로 .html 추가 # .html 확장자 없이 접근 시 자동으로 .html 추가
elif not self.path.endswith('/') and '.' not in os.path.basename(self.path): elif not self.path.endswith('/') and '.' not in os.path.basename(self.path):
html_path = self.path + '.html' html_path = self.path + '.html'
if os.path.exists(html_path.lstrip('/')): if os.path.exists(html_path.lstrip('/')):
self.path = html_path self.path = html_path
super().do_GET() super().do_GET()
def do_POST(self):
"""POST 요청 처리"""
parsed_path = urlparse(self.path)
# API 엔드포인트: POST /api/backgrounds
if parsed_path.path == '/api/backgrounds':
self.handle_post_backgrounds()
return
# 그 외 POST 요청은 405 반환
self.send_error(405, 'Method Not Allowed')
def handle_get_backgrounds(self):
"""배경 데이터 조회 API"""
try:
if os.path.exists(self.BACKGROUNDS_DATA_FILE):
with open(self.BACKGROUNDS_DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
data = {
'lastUpdated': datetime.now().isoformat(),
'backgrounds': []
}
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8'))
except Exception as e:
self.send_error_json(500, f'데이터 조회 실패: {str(e)}')
def handle_post_backgrounds(self):
"""배경 데이터 업데이트 API"""
try:
# Content-Length 확인
content_length = int(self.headers.get('Content-Length', 0))
if content_length == 0:
self.send_error_json(400, '요청 본문이 비어있습니다')
return
# JSON 데이터 파싱
post_data = self.rfile.read(content_length)
try:
data = json.loads(post_data.decode('utf-8'))
except json.JSONDecodeError as e:
self.send_error_json(400, f'잘못된 JSON 형식: {str(e)}')
return
# 데이터 유효성 검사
if 'backgrounds' not in data:
self.send_error_json(400, 'backgrounds 필드가 필요합니다')
return
# lastUpdated 자동 설정
data['lastUpdated'] = datetime.now().isoformat()
# 데이터 디렉토리 확인 및 생성
data_dir = os.path.dirname(self.BACKGROUNDS_DATA_FILE)
if data_dir and not os.path.exists(data_dir):
os.makedirs(data_dir)
# 파일 저장
with open(self.BACKGROUNDS_DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 성공 응답
response = {
'success': True,
'message': f'{len(data["backgrounds"])}개의 배경이 저장되었습니다',
'lastUpdated': data['lastUpdated'],
'count': len(data['backgrounds'])
}
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
self.wfile.write(json.dumps(response, ensure_ascii=False).encode('utf-8'))
print(f"[API] 배경 데이터 업데이트: {len(data['backgrounds'])}개 항목")
except Exception as e:
self.send_error_json(500, f'데이터 저장 실패: {str(e)}')
def send_error_json(self, code, message):
"""JSON 형식 에러 응답"""
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
error_response = {
'success': False,
'error': message
}
self.wfile.write(json.dumps(error_response, ensure_ascii=False).encode('utf-8'))
def log_message(self, format, *args): def log_message(self, format, *args):
"""로그 메시지 포맷팅""" """로그 메시지 포맷팅"""
print(f"[{self.log_date_time_string()}] {format % args}") print(f"[{self.log_date_time_string()}] {format % args}")
@ -114,6 +225,11 @@ def main():
print(f" Gallery: http://localhost:{available_port}/gallery") print(f" Gallery: http://localhost:{available_port}/gallery")
print(f" Contact: http://localhost:{available_port}/contact") print(f" Contact: http://localhost:{available_port}/contact")
print(f" Q&A: http://localhost:{available_port}/qna") print(f" Q&A: http://localhost:{available_port}/qna")
print(f" Backgrounds: http://localhost:{available_port}/backgrounds")
print("="*60)
print("API Endpoints:")
print(f" GET /api/backgrounds - 배경 데이터 조회")
print(f" POST /api/backgrounds - 배경 데이터 업데이트")
print("="*60) print("="*60)
print("External Access Setup:") print("External Access Setup:")
print(f" 1. Setup port forwarding for port {available_port} on router") print(f" 1. Setup port forwarding for port {available_port} on router")