Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
from fastapi import FastAPI, BackgroundTasks, UploadFile, File, Form
|
| 2 |
-
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
-
import pathlib, os, uvicorn, base64, json, shutil
|
| 5 |
-
from typing import Dict, List, Any
|
| 6 |
import asyncio
|
| 7 |
import logging
|
| 8 |
import threading
|
|
@@ -31,6 +31,12 @@ CACHE_DIR = BASE / "cache"
|
|
| 31 |
if not CACHE_DIR.exists():
|
| 32 |
CACHE_DIR.mkdir(parents=True)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# 관리자 비밀번호
|
| 35 |
ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # 환경 변수에서 가져오기, 기본값은 테스트용
|
| 36 |
|
|
@@ -38,6 +44,42 @@ ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # 환경 변수에서 가져오
|
|
| 38 |
pdf_cache: Dict[str, Dict[str, Any]] = {}
|
| 39 |
# 캐싱 락
|
| 40 |
cache_locks = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
# PDF 파일 목록 가져오기 (메인 디렉토리용)
|
| 43 |
def get_pdf_files():
|
|
@@ -53,7 +95,6 @@ def get_permanent_pdf_files():
|
|
| 53 |
pdf_files = [f for f in PERMANENT_PDF_DIR.glob("*.pdf")]
|
| 54 |
return pdf_files
|
| 55 |
|
| 56 |
-
# PDF 썸네일 생성 및 프로젝트 데이터 준비
|
| 57 |
# PDF 썸네일 생성 및 프로젝트 데이터 준비
|
| 58 |
def generate_pdf_projects():
|
| 59 |
projects_data = []
|
|
@@ -75,15 +116,28 @@ def generate_pdf_projects():
|
|
| 75 |
|
| 76 |
# 중복 제거된 파일들로 프로젝트 데이터 생성
|
| 77 |
for pdf_file in unique_files.values():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
projects_data.append({
|
| 79 |
"path": str(pdf_file),
|
| 80 |
"name": pdf_file.stem,
|
|
|
|
| 81 |
"cached": pdf_file.stem in pdf_cache and pdf_cache[pdf_file.stem].get("status") == "completed"
|
| 82 |
})
|
| 83 |
|
| 84 |
return projects_data
|
| 85 |
|
| 86 |
-
|
| 87 |
# 캐시 파일 경로 생성
|
| 88 |
def get_cache_path(pdf_name: str):
|
| 89 |
return CACHE_DIR / f"{pdf_name}_cache.json"
|
|
@@ -242,10 +296,40 @@ async def cache_pdf(pdf_path: str):
|
|
| 242 |
pdf_cache[pdf_name]["status"] = "error"
|
| 243 |
pdf_cache[pdf_name]["error"] = str(e)
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
# 시작 시 모든 PDF 파일 캐싱
|
| 246 |
async def init_cache_all_pdfs():
|
| 247 |
logger.info("PDF 캐싱 작업 시작")
|
| 248 |
|
|
|
|
|
|
|
|
|
|
| 249 |
# 메인 및 영구 디렉토리에서 PDF 파일 모두 가져오기
|
| 250 |
pdf_files = get_pdf_files() + get_permanent_pdf_files()
|
| 251 |
|
|
@@ -253,6 +337,25 @@ async def init_cache_all_pdfs():
|
|
| 253 |
unique_pdf_paths = set(str(p) for p in pdf_files)
|
| 254 |
pdf_files = [pathlib.Path(p) for p in unique_pdf_paths]
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
# 이미 캐시된 PDF 파일 로드 (빠른 시작을 위해 먼저 수행)
|
| 257 |
for cache_file in CACHE_DIR.glob("*_cache.json"):
|
| 258 |
try:
|
|
@@ -290,14 +393,43 @@ async def get_permanent_pdf_projects_api():
|
|
| 290 |
projects_data = []
|
| 291 |
|
| 292 |
for pdf_file in pdf_files:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
projects_data.append({
|
| 294 |
"path": str(pdf_file),
|
| 295 |
"name": pdf_file.stem,
|
|
|
|
| 296 |
"cached": pdf_file.stem in pdf_cache and pdf_cache[pdf_file.stem].get("status") == "completed"
|
| 297 |
})
|
| 298 |
|
| 299 |
return projects_data
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
# API 엔드포인트: PDF 썸네일 제공 (최적화)
|
| 302 |
@app.get("/api/pdf-thumbnail")
|
| 303 |
async def get_pdf_thumbnail(path: str):
|
|
@@ -422,7 +554,6 @@ async def get_pdf_content(path: str, background_tasks: BackgroundTasks):
|
|
| 422 |
logger.error(f"PDF 콘텐츠 로드 오류: {str(e)}\n{error_details}")
|
| 423 |
return JSONResponse(content={"error": str(e)}, status_code=500)
|
| 424 |
|
| 425 |
-
|
| 426 |
# PDF 업로드 엔드포인트 - 영구 저장소에 저장 및 메인 화면에 자동 표시
|
| 427 |
@app.post("/api/upload-pdf")
|
| 428 |
async def upload_pdf(file: UploadFile = File(...)):
|
|
@@ -446,11 +577,22 @@ async def upload_pdf(file: UploadFile = File(...)):
|
|
| 446 |
with open(PDF_DIR / file.filename, "wb") as buffer:
|
| 447 |
buffer.write(content)
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
# 백그라운드에서 캐싱 시작
|
| 450 |
asyncio.create_task(cache_pdf(str(file_path)))
|
| 451 |
|
| 452 |
return JSONResponse(
|
| 453 |
-
content={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
status_code=200
|
| 455 |
)
|
| 456 |
except Exception as e:
|
|
@@ -462,7 +604,6 @@ async def upload_pdf(file: UploadFile = File(...)):
|
|
| 462 |
status_code=500
|
| 463 |
)
|
| 464 |
|
| 465 |
-
|
| 466 |
# 관리자 인증 엔드포인트
|
| 467 |
@app.post("/api/admin-login")
|
| 468 |
async def admin_login(password: str = Form(...)):
|
|
@@ -491,6 +632,17 @@ async def delete_pdf(path: str):
|
|
| 491 |
if pdf_name in pdf_cache:
|
| 492 |
del pdf_cache[pdf_name]
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
return {"success": True}
|
| 495 |
except Exception as e:
|
| 496 |
logger.error(f"PDF 삭제 오류: {str(e)}")
|
|
@@ -528,13 +680,67 @@ async def unfeature_pdf(path: str):
|
|
| 528 |
logger.error(f"PDF 표시 해제 오류: {str(e)}")
|
| 529 |
return {"success": False, "message": str(e)}
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
# HTML 파일 읽기 함수
|
| 532 |
-
def get_html_content():
|
| 533 |
html_path = BASE / "flipbook_template.html"
|
|
|
|
| 534 |
if html_path.exists():
|
| 535 |
with open(html_path, "r", encoding="utf-8") as f:
|
| 536 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
# HTML 문자열 (UI 수정 버전)
|
| 540 |
HTML = """
|
|
@@ -1298,6 +1504,9 @@ HTML = """
|
|
| 1298 |
// 현재 페이지 로딩 상태
|
| 1299 |
let currentLoadingPdfPath = null;
|
| 1300 |
let pageLoadingInterval = null;
|
|
|
|
|
|
|
|
|
|
| 1301 |
|
| 1302 |
/* 🔊 오디오 unlock – 내장 Audio 와 같은 MP3 경로 사용 */
|
| 1303 |
['click','touchstart'].forEach(evt=>{
|
|
@@ -1371,7 +1580,7 @@ HTML = """
|
|
| 1371 |
await loadServerPDFs();
|
| 1372 |
|
| 1373 |
// 성공 메시지
|
| 1374 |
-
showMessage("PDF가 성공적으로 업로드되었습니다!");
|
| 1375 |
} else {
|
| 1376 |
hideLoading();
|
| 1377 |
showError("업로드 실패: " + (result.message || "알 수 없는 오류"));
|
|
@@ -1383,26 +1592,31 @@ HTML = """
|
|
| 1383 |
}
|
| 1384 |
}
|
| 1385 |
|
| 1386 |
-
function addCard(i, thumb, title, isCached = false) {
|
| 1387 |
const d = document.createElement('div');
|
| 1388 |
d.className = 'card fade-in';
|
| 1389 |
d.onclick = () => open(i);
|
| 1390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1391 |
// 제목 처리
|
| 1392 |
const displayTitle = title ?
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
|
| 1396 |
// 캐시 상태 뱃지 추가
|
| 1397 |
const cachedBadge = isCached ?
|
| 1398 |
-
|
| 1399 |
|
| 1400 |
d.innerHTML = `
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
`;
|
| 1407 |
grid.appendChild(d);
|
| 1408 |
|
|
@@ -1411,9 +1625,9 @@ HTML = """
|
|
| 1411 |
}
|
| 1412 |
|
| 1413 |
/* ── 프로젝트 저장 ── */
|
| 1414 |
-
function save(pages, title, isCached = false){
|
| 1415 |
-
const id=projects.push(pages)-1;
|
| 1416 |
-
addCard(id, pages[0].thumb, title, isCached);
|
| 1417 |
}
|
| 1418 |
|
| 1419 |
/* ── 서버 PDF 로드 및 캐시 상태 확인 ── */
|
|
@@ -1465,7 +1679,8 @@ HTML = """
|
|
| 1465 |
return {
|
| 1466 |
pages,
|
| 1467 |
name: project.name,
|
| 1468 |
-
isCached
|
|
|
|
| 1469 |
};
|
| 1470 |
}
|
| 1471 |
} catch (err) {
|
|
@@ -1480,7 +1695,7 @@ HTML = """
|
|
| 1480 |
|
| 1481 |
// 성공적으로 가져온 결과만 표시
|
| 1482 |
results.filter(result => result !== null).forEach(result => {
|
| 1483 |
-
save(result.pages, result.name, result.isCached);
|
| 1484 |
});
|
| 1485 |
|
| 1486 |
hideLoading();
|
|
@@ -1565,11 +1780,120 @@ HTML = """
|
|
| 1565 |
}
|
| 1566 |
}
|
| 1567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1568 |
/* ── 카드 → FlipBook ── */
|
| 1569 |
async function open(i) {
|
| 1570 |
toggle(false);
|
| 1571 |
const pages = projects[i];
|
| 1572 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1573 |
// 기존 FlipBook 정리
|
| 1574 |
if(fb) {
|
| 1575 |
fb.destroy();
|
|
@@ -1833,7 +2157,7 @@ HTML = """
|
|
| 1833 |
enableDownload: false,
|
| 1834 |
enablePrint: false,
|
| 1835 |
enableZoom: true,
|
| 1836 |
-
enableShare:
|
| 1837 |
enableSearch: true,
|
| 1838 |
enableAutoPlay: true,
|
| 1839 |
enableAnnotation: false,
|
|
@@ -1850,7 +2174,8 @@ HTML = """
|
|
| 1850 |
pageTextureSize: 1024, // 페이지 텍스처 크기
|
| 1851 |
thumbnails: true, // 섬네일 활성화
|
| 1852 |
autoHideControls: false, // 자동 숨김 비활성화
|
| 1853 |
-
controlsTimeout: 8000 // 컨트롤 표시 시간 연장
|
|
|
|
| 1854 |
}
|
| 1855 |
});
|
| 1856 |
|
|
@@ -1905,6 +2230,7 @@ HTML = """
|
|
| 1905 |
}
|
| 1906 |
$id('loadingPages').style.display = 'none';
|
| 1907 |
currentLoadingPdfPath = null;
|
|
|
|
| 1908 |
};
|
| 1909 |
|
| 1910 |
function toggle(showHome){
|
|
@@ -2019,12 +2345,18 @@ HTML = """
|
|
| 2019 |
const card = document.createElement('div');
|
| 2020 |
card.className = 'admin-card card fade-in';
|
| 2021 |
|
|
|
|
|
|
|
|
|
|
| 2022 |
// 썸네일 및 정보
|
| 2023 |
card.innerHTML = `
|
| 2024 |
<div class="card-inner">
|
| 2025 |
${pdf.cached ? '<div class="cached-status">캐시됨</div>' : ''}
|
| 2026 |
<img src="${thumbData.thumbnail || ''}" alt="${pdf.name}" loading="lazy">
|
| 2027 |
<p title="${pdf.name}">${pdf.name.length > 15 ? pdf.name.substring(0, 15) + '...' : pdf.name}</p>
|
|
|
|
|
|
|
|
|
|
| 2028 |
${isMainDisplayed ?
|
| 2029 |
`<button class="unfeature-btn" data-path="${pdf.path}">메인에서 제거</button>` :
|
| 2030 |
`<button class="feature-btn" data-path="${pdf.path}">메인에 표시</button>`}
|
|
@@ -2279,9 +2611,5 @@ HTML = """
|
|
| 2279 |
</html>
|
| 2280 |
"""
|
| 2281 |
|
| 2282 |
-
@app.get("/", response_class=HTMLResponse)
|
| 2283 |
-
async def root():
|
| 2284 |
-
return get_html_content()
|
| 2285 |
-
|
| 2286 |
if __name__ == "__main__":
|
| 2287 |
uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
|
|
|
|
| 1 |
+
from fastapi import FastAPI, BackgroundTasks, UploadFile, File, Form, Request, Query
|
| 2 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
import pathlib, os, uvicorn, base64, json, shutil, uuid, time, urllib.parse
|
| 5 |
+
from typing import Dict, List, Any, Optional
|
| 6 |
import asyncio
|
| 7 |
import logging
|
| 8 |
import threading
|
|
|
|
| 31 |
if not CACHE_DIR.exists():
|
| 32 |
CACHE_DIR.mkdir(parents=True)
|
| 33 |
|
| 34 |
+
# PDF 메타데이터 디렉토리 및 파일 설정
|
| 35 |
+
METADATA_DIR = pathlib.Path("/data/metadata") if os.path.exists("/data") else BASE / "metadata"
|
| 36 |
+
if not METADATA_DIR.exists():
|
| 37 |
+
METADATA_DIR.mkdir(parents=True)
|
| 38 |
+
PDF_METADATA_FILE = METADATA_DIR / "pdf_metadata.json"
|
| 39 |
+
|
| 40 |
# 관리자 비밀번호
|
| 41 |
ADMIN_PASSWORD = os.getenv("PASSWORD", "admin") # 환경 변수에서 가져오기, 기본값은 테스트용
|
| 42 |
|
|
|
|
| 44 |
pdf_cache: Dict[str, Dict[str, Any]] = {}
|
| 45 |
# 캐싱 락
|
| 46 |
cache_locks = {}
|
| 47 |
+
# PDF 메타데이터 (ID to 경로 매핑)
|
| 48 |
+
pdf_metadata: Dict[str, str] = {}
|
| 49 |
+
|
| 50 |
+
# PDF 메타데이터 로드
|
| 51 |
+
def load_pdf_metadata():
|
| 52 |
+
global pdf_metadata
|
| 53 |
+
if PDF_METADATA_FILE.exists():
|
| 54 |
+
try:
|
| 55 |
+
with open(PDF_METADATA_FILE, "r") as f:
|
| 56 |
+
pdf_metadata = json.load(f)
|
| 57 |
+
logger.info(f"PDF 메타데이터 로드 완료: {len(pdf_metadata)} 항목")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"메타데이터 로드 오류: {e}")
|
| 60 |
+
pdf_metadata = {}
|
| 61 |
+
else:
|
| 62 |
+
pdf_metadata = {}
|
| 63 |
+
|
| 64 |
+
# PDF 메타데이터 저장
|
| 65 |
+
def save_pdf_metadata():
|
| 66 |
+
try:
|
| 67 |
+
with open(PDF_METADATA_FILE, "w") as f:
|
| 68 |
+
json.dump(pdf_metadata, f)
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"메타데이터 저장 오류: {e}")
|
| 71 |
+
|
| 72 |
+
# PDF ID 생성 (파일명 + 타임스탬프 기반)
|
| 73 |
+
def generate_pdf_id(filename: str) -> str:
|
| 74 |
+
# 파일명에서 확장자 제거
|
| 75 |
+
base_name = os.path.splitext(filename)[0]
|
| 76 |
+
# URL 안전 문자열로 변환
|
| 77 |
+
safe_name = urllib.parse.quote(base_name, safe='')
|
| 78 |
+
# 타임스탬프 추가로 고유성 보장
|
| 79 |
+
timestamp = int(time.time())
|
| 80 |
+
# 짧은 임의 문자열 추가
|
| 81 |
+
random_suffix = uuid.uuid4().hex[:6]
|
| 82 |
+
return f"{safe_name}_{timestamp}_{random_suffix}"
|
| 83 |
|
| 84 |
# PDF 파일 목록 가져오기 (메인 디렉토리용)
|
| 85 |
def get_pdf_files():
|
|
|
|
| 95 |
pdf_files = [f for f in PERMANENT_PDF_DIR.glob("*.pdf")]
|
| 96 |
return pdf_files
|
| 97 |
|
|
|
|
| 98 |
# PDF 썸네일 생성 및 프로젝트 데이터 준비
|
| 99 |
def generate_pdf_projects():
|
| 100 |
projects_data = []
|
|
|
|
| 116 |
|
| 117 |
# 중복 제거된 파일들로 프로젝트 데이터 생성
|
| 118 |
for pdf_file in unique_files.values():
|
| 119 |
+
# 해당 파일의 PDF ID 찾기
|
| 120 |
+
pdf_id = None
|
| 121 |
+
for pid, path in pdf_metadata.items():
|
| 122 |
+
if os.path.basename(path) == pdf_file.name:
|
| 123 |
+
pdf_id = pid
|
| 124 |
+
break
|
| 125 |
+
|
| 126 |
+
# ID가 없으면 새로 생성하고 메타데이터에 추가
|
| 127 |
+
if not pdf_id:
|
| 128 |
+
pdf_id = generate_pdf_id(pdf_file.name)
|
| 129 |
+
pdf_metadata[pdf_id] = str(pdf_file)
|
| 130 |
+
save_pdf_metadata()
|
| 131 |
+
|
| 132 |
projects_data.append({
|
| 133 |
"path": str(pdf_file),
|
| 134 |
"name": pdf_file.stem,
|
| 135 |
+
"id": pdf_id,
|
| 136 |
"cached": pdf_file.stem in pdf_cache and pdf_cache[pdf_file.stem].get("status") == "completed"
|
| 137 |
})
|
| 138 |
|
| 139 |
return projects_data
|
| 140 |
|
|
|
|
| 141 |
# 캐시 파일 경로 생성
|
| 142 |
def get_cache_path(pdf_name: str):
|
| 143 |
return CACHE_DIR / f"{pdf_name}_cache.json"
|
|
|
|
| 296 |
pdf_cache[pdf_name]["status"] = "error"
|
| 297 |
pdf_cache[pdf_name]["error"] = str(e)
|
| 298 |
|
| 299 |
+
# PDF ID로 PDF 경로 찾기
|
| 300 |
+
def get_pdf_path_by_id(pdf_id: str) -> str:
|
| 301 |
+
if pdf_id in pdf_metadata:
|
| 302 |
+
path = pdf_metadata[pdf_id]
|
| 303 |
+
# 파일 존재 확인
|
| 304 |
+
if os.path.exists(path):
|
| 305 |
+
return path
|
| 306 |
+
|
| 307 |
+
# 영구 저장소에서 파일명으로 찾기
|
| 308 |
+
filename = os.path.basename(path)
|
| 309 |
+
perm_path = PERMANENT_PDF_DIR / filename
|
| 310 |
+
if perm_path.exists():
|
| 311 |
+
# 메타데이터 업데이트
|
| 312 |
+
pdf_metadata[pdf_id] = str(perm_path)
|
| 313 |
+
save_pdf_metadata()
|
| 314 |
+
return str(perm_path)
|
| 315 |
+
|
| 316 |
+
# 메인 디렉토리에서 파일명으로 찾기
|
| 317 |
+
main_path = PDF_DIR / filename
|
| 318 |
+
if main_path.exists():
|
| 319 |
+
# 메타데이터 업데이트
|
| 320 |
+
pdf_metadata[pdf_id] = str(main_path)
|
| 321 |
+
save_pdf_metadata()
|
| 322 |
+
return str(main_path)
|
| 323 |
+
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
# 시작 시 모든 PDF 파일 캐싱
|
| 327 |
async def init_cache_all_pdfs():
|
| 328 |
logger.info("PDF 캐싱 작업 시작")
|
| 329 |
|
| 330 |
+
# PDF 메타데이터 로드
|
| 331 |
+
load_pdf_metadata()
|
| 332 |
+
|
| 333 |
# 메인 및 영구 디렉토리에서 PDF 파일 모두 가져오기
|
| 334 |
pdf_files = get_pdf_files() + get_permanent_pdf_files()
|
| 335 |
|
|
|
|
| 337 |
unique_pdf_paths = set(str(p) for p in pdf_files)
|
| 338 |
pdf_files = [pathlib.Path(p) for p in unique_pdf_paths]
|
| 339 |
|
| 340 |
+
# 파일 기반 메타데이터 업데이트
|
| 341 |
+
for pdf_file in pdf_files:
|
| 342 |
+
# ID가 없는 파일에 대해 ID 생성
|
| 343 |
+
found = False
|
| 344 |
+
for pid, path in pdf_metadata.items():
|
| 345 |
+
if os.path.basename(path) == pdf_file.name:
|
| 346 |
+
found = True
|
| 347 |
+
# 경로 업데이트 필요한 경우
|
| 348 |
+
if not os.path.exists(path):
|
| 349 |
+
pdf_metadata[pid] = str(pdf_file)
|
| 350 |
+
break
|
| 351 |
+
|
| 352 |
+
if not found:
|
| 353 |
+
pdf_id = generate_pdf_id(pdf_file.name)
|
| 354 |
+
pdf_metadata[pdf_id] = str(pdf_file)
|
| 355 |
+
|
| 356 |
+
# 메타데이터 저장
|
| 357 |
+
save_pdf_metadata()
|
| 358 |
+
|
| 359 |
# 이미 캐시된 PDF 파일 로드 (빠른 시작을 위해 먼저 수행)
|
| 360 |
for cache_file in CACHE_DIR.glob("*_cache.json"):
|
| 361 |
try:
|
|
|
|
| 393 |
projects_data = []
|
| 394 |
|
| 395 |
for pdf_file in pdf_files:
|
| 396 |
+
# PDF ID 찾기
|
| 397 |
+
pdf_id = None
|
| 398 |
+
for pid, path in pdf_metadata.items():
|
| 399 |
+
if os.path.basename(path) == pdf_file.name:
|
| 400 |
+
pdf_id = pid
|
| 401 |
+
break
|
| 402 |
+
|
| 403 |
+
# ID가 없으면 생성
|
| 404 |
+
if not pdf_id:
|
| 405 |
+
pdf_id = generate_pdf_id(pdf_file.name)
|
| 406 |
+
pdf_metadata[pdf_id] = str(pdf_file)
|
| 407 |
+
save_pdf_metadata()
|
| 408 |
+
|
| 409 |
projects_data.append({
|
| 410 |
"path": str(pdf_file),
|
| 411 |
"name": pdf_file.stem,
|
| 412 |
+
"id": pdf_id,
|
| 413 |
"cached": pdf_file.stem in pdf_cache and pdf_cache[pdf_file.stem].get("status") == "completed"
|
| 414 |
})
|
| 415 |
|
| 416 |
return projects_data
|
| 417 |
|
| 418 |
+
# API 엔드포인트: PDF ID로 정보 가져오기
|
| 419 |
+
@app.get("/api/pdf-info-by-id/{pdf_id}")
|
| 420 |
+
async def get_pdf_info_by_id(pdf_id: str):
|
| 421 |
+
pdf_path = get_pdf_path_by_id(pdf_id)
|
| 422 |
+
if pdf_path:
|
| 423 |
+
pdf_file = pathlib.Path(pdf_path)
|
| 424 |
+
return {
|
| 425 |
+
"path": pdf_path,
|
| 426 |
+
"name": pdf_file.stem,
|
| 427 |
+
"id": pdf_id,
|
| 428 |
+
"exists": True,
|
| 429 |
+
"cached": pdf_file.stem in pdf_cache and pdf_cache[pdf_file.stem].get("status") == "completed"
|
| 430 |
+
}
|
| 431 |
+
return {"exists": False, "error": "PDF를 찾을 수 없습니다"}
|
| 432 |
+
|
| 433 |
# API 엔드포인트: PDF 썸네일 제공 (최적화)
|
| 434 |
@app.get("/api/pdf-thumbnail")
|
| 435 |
async def get_pdf_thumbnail(path: str):
|
|
|
|
| 554 |
logger.error(f"PDF 콘텐츠 로드 오류: {str(e)}\n{error_details}")
|
| 555 |
return JSONResponse(content={"error": str(e)}, status_code=500)
|
| 556 |
|
|
|
|
| 557 |
# PDF 업로드 엔드포인트 - 영구 저장소에 저장 및 메인 화면에 자동 표시
|
| 558 |
@app.post("/api/upload-pdf")
|
| 559 |
async def upload_pdf(file: UploadFile = File(...)):
|
|
|
|
| 577 |
with open(PDF_DIR / file.filename, "wb") as buffer:
|
| 578 |
buffer.write(content)
|
| 579 |
|
| 580 |
+
# PDF ID 생성 및 메타데이터 저장
|
| 581 |
+
pdf_id = generate_pdf_id(file.filename)
|
| 582 |
+
pdf_metadata[pdf_id] = str(file_path)
|
| 583 |
+
save_pdf_metadata()
|
| 584 |
+
|
| 585 |
# 백그라운드에서 캐싱 시작
|
| 586 |
asyncio.create_task(cache_pdf(str(file_path)))
|
| 587 |
|
| 588 |
return JSONResponse(
|
| 589 |
+
content={
|
| 590 |
+
"success": True,
|
| 591 |
+
"path": str(file_path),
|
| 592 |
+
"name": file_path.stem,
|
| 593 |
+
"id": pdf_id,
|
| 594 |
+
"viewUrl": f"/view/{pdf_id}"
|
| 595 |
+
},
|
| 596 |
status_code=200
|
| 597 |
)
|
| 598 |
except Exception as e:
|
|
|
|
| 604 |
status_code=500
|
| 605 |
)
|
| 606 |
|
|
|
|
| 607 |
# 관리자 인증 엔드포인트
|
| 608 |
@app.post("/api/admin-login")
|
| 609 |
async def admin_login(password: str = Form(...)):
|
|
|
|
| 632 |
if pdf_name in pdf_cache:
|
| 633 |
del pdf_cache[pdf_name]
|
| 634 |
|
| 635 |
+
# 메타데이터에서 해당 파일 ID 제거
|
| 636 |
+
to_remove = []
|
| 637 |
+
for pid, fpath in pdf_metadata.items():
|
| 638 |
+
if os.path.basename(fpath) == pdf_file.name:
|
| 639 |
+
to_remove.append(pid)
|
| 640 |
+
|
| 641 |
+
for pid in to_remove:
|
| 642 |
+
del pdf_metadata[pid]
|
| 643 |
+
|
| 644 |
+
save_pdf_metadata()
|
| 645 |
+
|
| 646 |
return {"success": True}
|
| 647 |
except Exception as e:
|
| 648 |
logger.error(f"PDF 삭제 오류: {str(e)}")
|
|
|
|
| 680 |
logger.error(f"PDF 표시 해제 오류: {str(e)}")
|
| 681 |
return {"success": False, "message": str(e)}
|
| 682 |
|
| 683 |
+
# 직접 PDF 뷰어 URL 접근용 라우트
|
| 684 |
+
@app.get("/view/{pdf_id}")
|
| 685 |
+
async def view_pdf_by_id(pdf_id: str):
|
| 686 |
+
# PDF ID 유효한지 확인
|
| 687 |
+
pdf_path = get_pdf_path_by_id(pdf_id)
|
| 688 |
+
if not pdf_path:
|
| 689 |
+
return HTMLResponse(
|
| 690 |
+
content=f"<html><body><h1>PDF를 찾을 수 없습니다</h1><p>ID: {pdf_id}</p><a href='/'>홈으로 돌아가기</a></body></html>",
|
| 691 |
+
status_code=404
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
# 메인 페이지로 리다이렉트하되, PDF ID 파라미터 추가
|
| 695 |
+
return get_html_content(pdf_id=pdf_id)
|
| 696 |
+
|
| 697 |
# HTML 파일 읽기 함수
|
| 698 |
+
def get_html_content(pdf_id: str = None):
|
| 699 |
html_path = BASE / "flipbook_template.html"
|
| 700 |
+
content = ""
|
| 701 |
if html_path.exists():
|
| 702 |
with open(html_path, "r", encoding="utf-8") as f:
|
| 703 |
+
content = f.read()
|
| 704 |
+
else:
|
| 705 |
+
content = HTML # 기본 HTML 사용
|
| 706 |
+
|
| 707 |
+
# PDF ID가 제공된 경우, 자동 로드 스크립트 추가
|
| 708 |
+
if pdf_id:
|
| 709 |
+
auto_load_script = f"""
|
| 710 |
+
<script>
|
| 711 |
+
// 페이지 로드 시 자동으로 해당 PDF 열기
|
| 712 |
+
document.addEventListener('DOMContentLoaded', async function() {{
|
| 713 |
+
try {{
|
| 714 |
+
// PDF 정보 가져오기
|
| 715 |
+
const response = await fetch('/api/pdf-info-by-id/{pdf_id}');
|
| 716 |
+
const pdfInfo = await response.json();
|
| 717 |
+
|
| 718 |
+
if (pdfInfo.exists && pdfInfo.path) {{
|
| 719 |
+
// 약간의 지연 후 PDF 뷰어 열기 (UI가 준비된 후)
|
| 720 |
+
setTimeout(() => {{
|
| 721 |
+
openPdfById('{pdf_id}', pdfInfo.path, pdfInfo.cached);
|
| 722 |
+
}}, 500);
|
| 723 |
+
}} else {{
|
| 724 |
+
showError("요청한 PDF를 찾을 수 없습니다.");
|
| 725 |
+
}}
|
| 726 |
+
}} catch (e) {{
|
| 727 |
+
console.error("자동 PDF 로드 오류:", e);
|
| 728 |
+
}}
|
| 729 |
+
}});
|
| 730 |
+
</script>
|
| 731 |
+
"""
|
| 732 |
+
|
| 733 |
+
# body 종료 태그 전에 스크립트 삽입
|
| 734 |
+
content = content.replace("</body>", auto_load_script + "</body>")
|
| 735 |
+
|
| 736 |
+
return HTMLResponse(content=content)
|
| 737 |
+
|
| 738 |
+
@app.get("/", response_class=HTMLResponse)
|
| 739 |
+
async def root(request: Request, pdf_id: Optional[str] = Query(None)):
|
| 740 |
+
# PDF ID가 쿼리 파라미터로 제공된 경우 /view/{pdf_id}로 리다이렉트
|
| 741 |
+
if pdf_id:
|
| 742 |
+
return RedirectResponse(url=f"/view/{pdf_id}")
|
| 743 |
+
return get_html_content()
|
| 744 |
|
| 745 |
# HTML 문자열 (UI 수정 버전)
|
| 746 |
HTML = """
|
|
|
|
| 1504 |
// 현재 페이지 로딩 상태
|
| 1505 |
let currentLoadingPdfPath = null;
|
| 1506 |
let pageLoadingInterval = null;
|
| 1507 |
+
|
| 1508 |
+
// 현재 열린 PDF의 ID
|
| 1509 |
+
let currentPdfId = null;
|
| 1510 |
|
| 1511 |
/* 🔊 오디오 unlock – 내장 Audio 와 같은 MP3 경로 사용 */
|
| 1512 |
['click','touchstart'].forEach(evt=>{
|
|
|
|
| 1580 |
await loadServerPDFs();
|
| 1581 |
|
| 1582 |
// 성공 메시지
|
| 1583 |
+
showMessage("PDF가 성공적으로 업로드되었습니다! 공유 URL: " + result.viewUrl);
|
| 1584 |
} else {
|
| 1585 |
hideLoading();
|
| 1586 |
showError("업로드 실패: " + (result.message || "알 수 없는 오류"));
|
|
|
|
| 1592 |
}
|
| 1593 |
}
|
| 1594 |
|
| 1595 |
+
function addCard(i, thumb, title, isCached = false, pdfId = null) {
|
| 1596 |
const d = document.createElement('div');
|
| 1597 |
d.className = 'card fade-in';
|
| 1598 |
d.onclick = () => open(i);
|
| 1599 |
|
| 1600 |
+
// PDF ID가 있으면 데이터 속성으로 저장
|
| 1601 |
+
if (pdfId) {
|
| 1602 |
+
d.dataset.pdfId = pdfId;
|
| 1603 |
+
}
|
| 1604 |
+
|
| 1605 |
// 제목 처리
|
| 1606 |
const displayTitle = title ?
|
| 1607 |
+
(title.length > 15 ? title.substring(0, 15) + '...' : title) :
|
| 1608 |
+
'프로젝트 ' + (i+1);
|
| 1609 |
|
| 1610 |
// 캐시 상태 뱃지 추가
|
| 1611 |
const cachedBadge = isCached ?
|
| 1612 |
+
'<div class="cached-status">캐시됨</div>' : '';
|
| 1613 |
|
| 1614 |
d.innerHTML = `
|
| 1615 |
+
<div class="card-inner">
|
| 1616 |
+
${cachedBadge}
|
| 1617 |
+
<img src="${thumb}" alt="${displayTitle}" loading="lazy">
|
| 1618 |
+
<p title="${title || '프로젝트 ' + (i+1)}">${displayTitle}</p>
|
| 1619 |
+
</div>
|
| 1620 |
`;
|
| 1621 |
grid.appendChild(d);
|
| 1622 |
|
|
|
|
| 1625 |
}
|
| 1626 |
|
| 1627 |
/* ── 프로젝트 저장 ── */
|
| 1628 |
+
function save(pages, title, isCached = false, pdfId = null) {
|
| 1629 |
+
const id = projects.push(pages) - 1;
|
| 1630 |
+
addCard(id, pages[0].thumb, title, isCached, pdfId);
|
| 1631 |
}
|
| 1632 |
|
| 1633 |
/* ── 서버 PDF 로드 및 캐시 상태 확인 ── */
|
|
|
|
| 1679 |
return {
|
| 1680 |
pages,
|
| 1681 |
name: project.name,
|
| 1682 |
+
isCached,
|
| 1683 |
+
id: project.id
|
| 1684 |
};
|
| 1685 |
}
|
| 1686 |
} catch (err) {
|
|
|
|
| 1695 |
|
| 1696 |
// 성공적으로 가져온 결과만 표시
|
| 1697 |
results.filter(result => result !== null).forEach(result => {
|
| 1698 |
+
save(result.pages, result.name, result.isCached, result.id);
|
| 1699 |
});
|
| 1700 |
|
| 1701 |
hideLoading();
|
|
|
|
| 1780 |
}
|
| 1781 |
}
|
| 1782 |
|
| 1783 |
+
/* ── PDF ID로 PDF 열기 ── */
|
| 1784 |
+
async function openPdfById(pdfId, pdfPath, isCached = false) {
|
| 1785 |
+
try {
|
| 1786 |
+
// 먼저 홈 화면에서 카드를 찾아서 클릭하는 방법 시도
|
| 1787 |
+
let foundCard = false;
|
| 1788 |
+
const cards = document.querySelectorAll('.card');
|
| 1789 |
+
|
| 1790 |
+
for (let i = 0; i < cards.length; i++) {
|
| 1791 |
+
if (cards[i].dataset.pdfId === pdfId) {
|
| 1792 |
+
cards[i].click();
|
| 1793 |
+
foundCard = true;
|
| 1794 |
+
break;
|
| 1795 |
+
}
|
| 1796 |
+
}
|
| 1797 |
+
|
| 1798 |
+
// 카드를 찾지 못한 경우 직접 오픈
|
| 1799 |
+
if (!foundCard) {
|
| 1800 |
+
toggle(false);
|
| 1801 |
+
showLoading("PDF 준비 중...");
|
| 1802 |
+
|
| 1803 |
+
let pages = [];
|
| 1804 |
+
|
| 1805 |
+
// 이미 캐시된 경우 캐시된 데이터 사용
|
| 1806 |
+
if (isCached) {
|
| 1807 |
+
try {
|
| 1808 |
+
const response = await fetch(`/api/cached-pdf?path=${encodeURIComponent(pdfPath)}`);
|
| 1809 |
+
const cachedData = await response.json();
|
| 1810 |
+
|
| 1811 |
+
if (cachedData.status === "completed" && cachedData.pages) {
|
| 1812 |
+
hideLoading();
|
| 1813 |
+
createFlipBook(cachedData.pages);
|
| 1814 |
+
// 현재 열린 PDF의 ID 저장
|
| 1815 |
+
currentPdfId = pdfId;
|
| 1816 |
+
return;
|
| 1817 |
+
}
|
| 1818 |
+
} catch (error) {
|
| 1819 |
+
console.error("캐시 데이터 로드 실패:", error);
|
| 1820 |
+
}
|
| 1821 |
+
}
|
| 1822 |
+
|
| 1823 |
+
// 썸네일 가져오기
|
| 1824 |
+
try {
|
| 1825 |
+
const thumbResponse = await fetch(`/api/pdf-thumbnail?path=${encodeURIComponent(pdfPath)}`);
|
| 1826 |
+
const thumbData = await thumbResponse.json();
|
| 1827 |
+
|
| 1828 |
+
if (thumbData.thumbnail) {
|
| 1829 |
+
pages = [{
|
| 1830 |
+
src: thumbData.thumbnail,
|
| 1831 |
+
thumb: thumbData.thumbnail,
|
| 1832 |
+
path: pdfPath,
|
| 1833 |
+
cached: isCached
|
| 1834 |
+
}];
|
| 1835 |
+
}
|
| 1836 |
+
} catch (error) {
|
| 1837 |
+
console.error("썸네일 로드 실패:", error);
|
| 1838 |
+
}
|
| 1839 |
+
|
| 1840 |
+
// 일단 기본 페이지 추가
|
| 1841 |
+
if (pages.length === 0) {
|
| 1842 |
+
pages = [{
|
| 1843 |
+
path: pdfPath,
|
| 1844 |
+
cached: isCached
|
| 1845 |
+
}];
|
| 1846 |
+
}
|
| 1847 |
+
|
| 1848 |
+
// 프로젝트에 추가하고 뷰어 실행
|
| 1849 |
+
const projectId = projects.push(pages) - 1;
|
| 1850 |
+
hideLoading();
|
| 1851 |
+
open(projectId);
|
| 1852 |
+
|
| 1853 |
+
// 현재 열린 PDF의 ID 저장
|
| 1854 |
+
currentPdfId = pdfId;
|
| 1855 |
+
}
|
| 1856 |
+
} catch (error) {
|
| 1857 |
+
console.error("PDF ID로 열기 실패:", error);
|
| 1858 |
+
hideLoading();
|
| 1859 |
+
showError("PDF를 열 수 없습니다. 다시 시도해주세요.");
|
| 1860 |
+
}
|
| 1861 |
+
}
|
| 1862 |
+
|
| 1863 |
+
/* ── 현재 PDF의 고유 URL 생성 및 복사 ── */
|
| 1864 |
+
function copyPdfShareUrl() {
|
| 1865 |
+
if (!currentPdfId) {
|
| 1866 |
+
showError("공유할 PDF가 없습니다.");
|
| 1867 |
+
return;
|
| 1868 |
+
}
|
| 1869 |
+
|
| 1870 |
+
// 현재 도메인 기반 전체 URL 생성
|
| 1871 |
+
const shareUrl = `${window.location.origin}/view/${currentPdfId}`;
|
| 1872 |
+
|
| 1873 |
+
// 클립보드에 복사
|
| 1874 |
+
navigator.clipboard.writeText(shareUrl)
|
| 1875 |
+
.then(() => {
|
| 1876 |
+
showMessage("PDF 링크가 복사되었습니다!");
|
| 1877 |
+
})
|
| 1878 |
+
.catch(err => {
|
| 1879 |
+
console.error("클립보드 복사 실패:", err);
|
| 1880 |
+
showError("링크 복사에 실패했습니다.");
|
| 1881 |
+
});
|
| 1882 |
+
}
|
| 1883 |
+
|
| 1884 |
/* ── 카드 → FlipBook ── */
|
| 1885 |
async function open(i) {
|
| 1886 |
toggle(false);
|
| 1887 |
const pages = projects[i];
|
| 1888 |
|
| 1889 |
+
// PDF ID 찾기 및 저장
|
| 1890 |
+
const card = document.querySelectorAll('.card')[i];
|
| 1891 |
+
if (card && card.dataset.pdfId) {
|
| 1892 |
+
currentPdfId = card.dataset.pdfId;
|
| 1893 |
+
} else {
|
| 1894 |
+
currentPdfId = null;
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
// 기존 FlipBook 정리
|
| 1898 |
if(fb) {
|
| 1899 |
fb.destroy();
|
|
|
|
| 2157 |
enableDownload: false,
|
| 2158 |
enablePrint: false,
|
| 2159 |
enableZoom: true,
|
| 2160 |
+
enableShare: true, // 공유 버튼 활성화
|
| 2161 |
enableSearch: true,
|
| 2162 |
enableAutoPlay: true,
|
| 2163 |
enableAnnotation: false,
|
|
|
|
| 2174 |
pageTextureSize: 1024, // 페이지 텍스처 크기
|
| 2175 |
thumbnails: true, // 섬네일 활성화
|
| 2176 |
autoHideControls: false, // 자동 숨김 비활성화
|
| 2177 |
+
controlsTimeout: 8000, // 컨트롤 표시 시간 연장
|
| 2178 |
+
shareHandler: copyPdfShareUrl // 공유 핸들러 설정
|
| 2179 |
}
|
| 2180 |
});
|
| 2181 |
|
|
|
|
| 2230 |
}
|
| 2231 |
$id('loadingPages').style.display = 'none';
|
| 2232 |
currentLoadingPdfPath = null;
|
| 2233 |
+
currentPdfId = null;
|
| 2234 |
};
|
| 2235 |
|
| 2236 |
function toggle(showHome){
|
|
|
|
| 2345 |
const card = document.createElement('div');
|
| 2346 |
card.className = 'admin-card card fade-in';
|
| 2347 |
|
| 2348 |
+
// 고유 URL 생성
|
| 2349 |
+
const viewUrl = `${window.location.origin}/view/${pdf.id}`;
|
| 2350 |
+
|
| 2351 |
// 썸네일 및 정보
|
| 2352 |
card.innerHTML = `
|
| 2353 |
<div class="card-inner">
|
| 2354 |
${pdf.cached ? '<div class="cached-status">캐시됨</div>' : ''}
|
| 2355 |
<img src="${thumbData.thumbnail || ''}" alt="${pdf.name}" loading="lazy">
|
| 2356 |
<p title="${pdf.name}">${pdf.name.length > 15 ? pdf.name.substring(0, 15) + '...' : pdf.name}</p>
|
| 2357 |
+
<div style="position: absolute; bottom: 130px; left: 50%; transform: translateX(-50%); z-index:10;">
|
| 2358 |
+
<a href="${viewUrl}" target="_blank" style="color:#4a6ee0; font-size:12px;">바로가기 링크</a>
|
| 2359 |
+
</div>
|
| 2360 |
${isMainDisplayed ?
|
| 2361 |
`<button class="unfeature-btn" data-path="${pdf.path}">메인에서 제거</button>` :
|
| 2362 |
`<button class="feature-btn" data-path="${pdf.path}">메인에 표시</button>`}
|
|
|
|
| 2611 |
</html>
|
| 2612 |
"""
|
| 2613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2614 |
if __name__ == "__main__":
|
| 2615 |
uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
|