ADD : 프랍페이지 추가

This commit is contained in:
68893236+KINDNICK@users.noreply.github.com 2026-01-08 21:05:01 +09:00
parent f368cb2f6a
commit 77662aefa6
7 changed files with 951 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

432
css/props.css Normal file
View File

@ -0,0 +1,432 @@
/* ========================================
프랍 라이브러리 페이지 스타일
======================================== */
/* 페이지 헤더 */
.props-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;
}
.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;
}
/* 프랍 그리드 */
.props-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-lg);
}
.props-grid.list-view {
grid-template-columns: 1fr;
}
/* 프랍 카드 */
.prop-card {
background: var(--bg-white);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--box-shadow);
transition: var(--transition);
cursor: pointer;
}
.prop-card:hover {
transform: translateY(-5px);
box-shadow: var(--box-shadow-lg);
}
.card-thumbnail {
position: relative;
aspect-ratio: 1 / 1;
overflow: hidden;
background: linear-gradient(135deg, #2a2a2a 0%, #3a3a3a 100%);
}
.card-thumbnail img {
width: 100%;
height: 100%;
object-fit: contain;
transition: transform 0.5s ease;
padding: 10px;
}
.prop-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-badge {
position: absolute;
top: var(--spacing-sm);
right: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255, 136, 0, 0.9);
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-base);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-info {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.card-info-item {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--bg-gray);
color: var(--text-secondary);
font-size: var(--font-xs);
border-radius: var(--border-radius-sm);
}
.card-info-item .icon {
font-size: 0.8rem;
}
/* 리스트 뷰 카드 */
.props-grid.list-view .prop-card {
display: flex;
flex-direction: row;
}
.props-grid.list-view .card-thumbnail {
width: 100px;
min-width: 100px;
aspect-ratio: auto;
height: 100px;
}
.props-grid.list-view .card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.props-grid.list-view .card-title {
font-size: var(--font-lg);
}
/* 로딩 */
.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: 60vh;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow-lg);
background: #2a2a2a;
padding: 20px;
}
.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-md);
}
.modal-details {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
}
.modal-detail-item {
display: flex;
align-items: center;
gap: 8px;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-gray);
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
font-size: var(--font-sm);
}
.modal-detail-item .icon {
font-size: 1.2rem;
}
.modal-detail-item strong {
color: var(--primary-color);
}
/* 반응형 */
@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%;
}
.view-options {
justify-content: center;
}
.props-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.props-grid.list-view .prop-card {
flex-direction: column;
}
.props-grid.list-view .card-thumbnail {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
}
.stats-bar {
justify-content: center;
}
}
@media (max-width: 480px) {
.props-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
}
}

4
data/props.json Normal file
View File

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

308
js/props.js Normal file
View File

@ -0,0 +1,308 @@
/**
* 프랍 라이브러리 JavaScript
* Unity에서 업로드된 JSON 데이터를 기반으로 프랍 목록을 표시
*/
(function() {
'use strict';
// 데이터 파일 경로
const DATA_PATH = 'data/props.json';
// 상태
let propsData = [];
let filteredData = [];
let currentView = 'grid';
let searchQuery = '';
// DOM 요소
const elements = {
grid: document.getElementById('propsGrid'),
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'),
modalDetails: document.getElementById('modalDetails')
};
/**
* 초기화
*/
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();
propsData = data.props || [];
// 마지막 업데이트 시간 표시
if (data.lastUpdated) {
const date = new Date(data.lastUpdated);
elements.lastUpdated.textContent = `마지막 업데이트: ${formatDate(date)}`;
}
// 통계 업데이트
elements.totalCount.textContent = propsData.length;
// 데이터 렌더링
filterAndRender();
} catch (error) {
console.error('데이터 로드 실패:', error);
showNoData();
}
}
/**
* 이벤트 리스너 설정
*/
function setupEventListeners() {
// 검색
elements.searchInput.addEventListener('input', debounce((e) => {
searchQuery = e.target.value.toLowerCase();
filterAndRender();
}, 300));
// 뷰 전환
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 filterAndRender() {
// 필터링
filteredData = propsData.filter(prop => {
// 검색 필터
if (searchQuery) {
const searchTarget = prop.name.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 (propsData.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(prop => createCard(prop)).join('');
// 카드 클릭 이벤트
elements.grid.querySelectorAll('.prop-card').forEach((card, index) => {
card.addEventListener('click', () => openModal(filteredData[index]));
});
// 뷰 모드 적용
updateViewMode();
}
/**
* 카드 HTML 생성
*/
function createCard(prop) {
const thumbnailHtml = prop.thumbnailUrl
? `<img src="${escapeHtml(prop.thumbnailUrl)}" alt="${escapeHtml(prop.name)}" loading="lazy">`
: `<div class="no-thumbnail">🎁</div>`;
const prefabBadge = prop.prefabCount > 1
? `<span class="card-badge">${prop.prefabCount} variants</span>`
: '';
return `
<div class="prop-card" data-prop="${escapeHtml(prop.name)}">
<div class="card-thumbnail">
${thumbnailHtml}
${prefabBadge}
</div>
<div class="card-content">
<h3 class="card-title">${escapeHtml(prop.name)}</h3>
<div class="card-info">
${prop.prefabCount > 0 ? `<span class="card-info-item"><span class="icon">📦</span>${prop.prefabCount}</span>` : ''}
${prop.modelCount > 0 ? `<span class="card-info-item"><span class="icon">🎨</span>${prop.modelCount}</span>` : ''}
</div>
</div>
</div>
`;
}
/**
* 모드 업데이트
*/
function updateViewMode() {
elements.grid.classList.toggle('list-view', currentView === 'list');
}
/**
* 모달 열기
*/
function openModal(prop) {
if (prop.thumbnailUrl) {
elements.modalImage.src = prop.thumbnailUrl;
elements.modalImage.alt = prop.name;
} else {
elements.modalImage.src = '';
elements.modalImage.alt = '';
}
elements.modalTitle.textContent = prop.name;
// 상세 정보 표시
elements.modalDetails.innerHTML = `
${prop.prefabCount > 0 ? `
<div class="modal-detail-item">
<span class="icon">📦</span>
<span>프리펩</span>
<strong>${prop.prefabCount}</strong>
</div>
` : ''}
${prop.modelCount > 0 ? `
<div class="modal-detail-item">
<span class="icon">🎨</span>
<span>모델</span>
<strong>${prop.modelCount}</strong>
</div>
` : ''}
${prop.textureCount > 0 ? `
<div class="modal-detail-item">
<span class="icon">🖼</span>
<span>텍스처</span>
<strong>${prop.textureCount}</strong>
</div>
` : ''}
${prop.materialCount > 0 ? `
<div class="modal-detail-item">
<span class="icon">💎</span>
<span>머티리얼</span>
<strong>${prop.materialCount}</strong>
</div>
` : ''}
`;
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 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);
})();

121
props.html Normal file
View File

@ -0,0 +1,121 @@
<!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/props.css">
</head>
<body>
<!-- 헤더 -->
<div id="header-placeholder"></div>
<!-- 메인 콘텐츠 -->
<main class="props-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="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="props-grid" id="propsGrid">
<!-- 프랍 카드들이 동적으로 추가됨 -->
<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>
<div class="modal-details" id="modalDetails"></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/props.js"></script>
</body>
</html>

View File

@ -38,6 +38,7 @@ class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
# API 데이터 파일 경로
BACKGROUNDS_DATA_FILE = 'data/backgrounds.json'
PROPS_DATA_FILE = 'data/props.json'
def end_headers(self):
# CORS 헤더 추가 (개발용)
@ -64,6 +65,11 @@ class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_get_backgrounds()
return
# API 엔드포인트: GET /api/props
if parsed_path.path == '/api/props':
self.handle_get_props()
return
# 기본 파일 처리
if self.path == '/':
self.path = '/index.html'
@ -85,6 +91,11 @@ class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
self.handle_post_backgrounds()
return
# API 엔드포인트: POST /api/props
if parsed_path.path == '/api/props':
self.handle_post_props()
return
# 그 외 POST 요청은 405 반환
self.send_error(405, 'Method Not Allowed')
@ -160,6 +171,78 @@ class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e:
self.send_error_json(500, f'데이터 저장 실패: {str(e)}')
def handle_get_props(self):
"""프랍 데이터 조회 API"""
try:
if os.path.exists(self.PROPS_DATA_FILE):
with open(self.PROPS_DATA_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
data = {
'lastUpdated': datetime.now().isoformat(),
'props': []
}
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_props(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 'props' not in data:
self.send_error_json(400, 'props 필드가 필요합니다')
return
# lastUpdated 자동 설정
data['lastUpdated'] = datetime.now().isoformat()
# 데이터 디렉토리 확인 및 생성
data_dir = os.path.dirname(self.PROPS_DATA_FILE)
if data_dir and not os.path.exists(data_dir):
os.makedirs(data_dir)
# 파일 저장
with open(self.PROPS_DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 성공 응답
response = {
'success': True,
'message': f'{len(data["props"])}개의 프랍이 저장되었습니다',
'lastUpdated': data['lastUpdated'],
'count': len(data['props'])
}
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['props'])}개 항목")
except Exception as e:
self.send_error_json(500, f'데이터 저장 실패: {str(e)}')
def send_error_json(self, code, message):
"""JSON 형식 에러 응답"""
self.send_response(code)
@ -226,10 +309,13 @@ def main():
print(f" Contact: http://localhost:{available_port}/contact")
print(f" Q&A: http://localhost:{available_port}/qna")
print(f" Backgrounds: http://localhost:{available_port}/backgrounds")
print(f" Props: http://localhost:{available_port}/props")
print("="*60)
print("API Endpoints:")
print(f" GET /api/backgrounds - 배경 데이터 조회")
print(f" POST /api/backgrounds - 배경 데이터 업데이트")
print(f" GET /api/props - 프랍 데이터 조회")
print(f" POST /api/props - 프랍 데이터 업데이트")
print("="*60)
print("External Access Setup:")
print(f" 1. Setup port forwarding for port {available_port} on router")