|
|
<!DOCTYPE html> |
|
|
<html lang="ko"> |
|
|
<head> |
|
|
<script type="text/javascript"> |
|
|
(function(c,l,a,r,i,t,y){ |
|
|
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; |
|
|
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; |
|
|
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); |
|
|
})(window, document, "clarity", "script", "ujskfvh0bu"); |
|
|
</script> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>์
๋ก๋๋ ์น์์ค - SOY NV AI</title> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
|
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script> |
|
|
<style> |
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
:root { |
|
|
--bg-primary: #ffffff; |
|
|
--bg-secondary: #f8f9fa; |
|
|
--bg-tertiary: #f1f3f4; |
|
|
--text-primary: #202124; |
|
|
--text-secondary: #5f6368; |
|
|
--accent: #1a73e8; |
|
|
--accent-hover: #1557b0; |
|
|
--border: #dadce0; |
|
|
--ai-bg: #e8f0fe; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
|
background: var(--bg-secondary); |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.header { |
|
|
background: var(--bg-primary); |
|
|
border-bottom: 1px solid var(--border); |
|
|
padding: 16px 24px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.header-title { |
|
|
font-size: 20px; |
|
|
font-weight: 500; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 12px; |
|
|
} |
|
|
|
|
|
.header-actions { |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.menu-toggle { |
|
|
display: none; |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
padding: 8px; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.mobile-menu { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
right: 0; |
|
|
bottom: 0; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
z-index: 1000; |
|
|
} |
|
|
|
|
|
.mobile-menu.active { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.mobile-menu-content { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
right: -100%; |
|
|
width: 280px; |
|
|
max-width: 80%; |
|
|
height: 100%; |
|
|
background: var(--bg-primary); |
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); |
|
|
transition: right 0.3s ease; |
|
|
overflow-y: auto; |
|
|
z-index: 1001; |
|
|
} |
|
|
|
|
|
.mobile-menu.active .mobile-menu-content { |
|
|
right: 0; |
|
|
} |
|
|
|
|
|
.mobile-menu-header { |
|
|
padding: 16px 20px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
background: var(--bg-primary); |
|
|
position: sticky; |
|
|
top: 0; |
|
|
z-index: 10; |
|
|
} |
|
|
|
|
|
.mobile-menu-title { |
|
|
font-size: 18px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.mobile-menu-close { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
color: var(--text-primary); |
|
|
padding: 0; |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.mobile-menu-items { |
|
|
padding: 8px 0; |
|
|
} |
|
|
|
|
|
.mobile-menu-item { |
|
|
display: block; |
|
|
padding: 12px 20px; |
|
|
color: var(--text-primary); |
|
|
text-decoration: none; |
|
|
border-bottom: 1px solid var(--bg-tertiary); |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
.mobile-menu-item:hover { |
|
|
background: var(--bg-secondary); |
|
|
} |
|
|
|
|
|
.mobile-menu-user { |
|
|
padding: 16px 20px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
color: var(--text-secondary); |
|
|
font-size: 14px; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.header { |
|
|
padding: 12px 16px; |
|
|
} |
|
|
|
|
|
.header-title { |
|
|
font-size: 18px; |
|
|
} |
|
|
|
|
|
.header-title span:first-child { |
|
|
display: none; |
|
|
} |
|
|
|
|
|
.menu-toggle { |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.header-actions { |
|
|
display: none; |
|
|
} |
|
|
} |
|
|
|
|
|
.btn { |
|
|
padding: 8px 16px; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
text-decoration: none; |
|
|
display: inline-block; |
|
|
} |
|
|
|
|
|
.btn-primary { |
|
|
background: var(--accent); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.btn-primary:hover { |
|
|
background: var(--accent-hover); |
|
|
} |
|
|
|
|
|
.btn-secondary { |
|
|
background: var(--bg-tertiary); |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.btn-secondary:hover { |
|
|
background: var(--border); |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1200px; |
|
|
margin: 0 auto; |
|
|
padding: 24px; |
|
|
} |
|
|
|
|
|
.page-header { |
|
|
margin-bottom: 24px; |
|
|
} |
|
|
|
|
|
.page-header h1 { |
|
|
font-size: 28px; |
|
|
font-weight: 600; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.page-header p { |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.card { |
|
|
background: var(--bg-primary); |
|
|
border-radius: 8px; |
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
|
|
padding: 24px; |
|
|
margin-bottom: 24px; |
|
|
} |
|
|
|
|
|
.filter-section { |
|
|
margin-bottom: 16px; |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.filter-section select { |
|
|
padding: 8px 12px; |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 6px; |
|
|
font-size: 14px; |
|
|
background: var(--bg-primary); |
|
|
color: var(--text-primary); |
|
|
flex: 1; |
|
|
max-width: 300px; |
|
|
} |
|
|
|
|
|
.webnovel-item { |
|
|
padding: 16px; |
|
|
margin-bottom: 12px; |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 8px; |
|
|
background: var(--bg-secondary); |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.webnovel-item:hover { |
|
|
background: var(--bg-tertiary); |
|
|
border-color: var(--accent); |
|
|
} |
|
|
|
|
|
.webnovel-item-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.webnovel-item-title { |
|
|
font-size: 16px; |
|
|
font-weight: 500; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.webnovel-item-meta { |
|
|
font-size: 12px; |
|
|
color: var(--text-secondary); |
|
|
display: flex; |
|
|
gap: 12px; |
|
|
flex-wrap: wrap; |
|
|
margin-bottom: 12px; |
|
|
} |
|
|
|
|
|
.webnovel-item-actions { |
|
|
display: flex; |
|
|
gap: 8px; |
|
|
} |
|
|
|
|
|
.webnovel-item-btn { |
|
|
padding: 6px 16px; |
|
|
background: var(--accent); |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 6px; |
|
|
font-size: 13px; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
.webnovel-item-btn:hover { |
|
|
background: var(--accent-hover); |
|
|
transform: translateY(-1px); |
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
|
|
|
.modal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(0, 0, 0, 0.5); |
|
|
z-index: 1000; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
.modal.active { |
|
|
display: flex; |
|
|
} |
|
|
|
|
|
.modal-content { |
|
|
background: var(--bg-primary); |
|
|
border-radius: 8px; |
|
|
padding: 0; |
|
|
width: 90%; |
|
|
max-width: 1000px; |
|
|
max-height: 90vh; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.modal-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 20px 24px; |
|
|
border-bottom: 1px solid var(--border); |
|
|
} |
|
|
|
|
|
.modal-title { |
|
|
font-size: 20px; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.modal-close { |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 24px; |
|
|
cursor: pointer; |
|
|
color: var(--text-secondary); |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
border-radius: 50%; |
|
|
transition: background 0.2s; |
|
|
} |
|
|
|
|
|
.modal-close:hover { |
|
|
background: var(--bg-tertiary); |
|
|
} |
|
|
|
|
|
.modal-body { |
|
|
padding: 24px; |
|
|
overflow-y: auto; |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
mark { |
|
|
background: #ffeb3b; |
|
|
padding: 2px 0; |
|
|
border-radius: 2px; |
|
|
} |
|
|
|
|
|
|
|
|
.markdown-content { |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
.markdown-content h1, |
|
|
.markdown-content h2, |
|
|
.markdown-content h3, |
|
|
.markdown-content h4, |
|
|
.markdown-content h5, |
|
|
.markdown-content h6 { |
|
|
margin-top: 24px; |
|
|
margin-bottom: 16px; |
|
|
font-weight: 600; |
|
|
line-height: 1.25; |
|
|
} |
|
|
|
|
|
.markdown-content h1 { |
|
|
font-size: 2em; |
|
|
border-bottom: 1px solid var(--border); |
|
|
padding-bottom: 8px; |
|
|
} |
|
|
|
|
|
.markdown-content h2 { |
|
|
font-size: 1.5em; |
|
|
border-bottom: 1px solid var(--border); |
|
|
padding-bottom: 8px; |
|
|
} |
|
|
|
|
|
.markdown-content h3 { |
|
|
font-size: 1.25em; |
|
|
} |
|
|
|
|
|
.markdown-content p { |
|
|
margin-bottom: 16px; |
|
|
} |
|
|
|
|
|
.markdown-content ul, |
|
|
.markdown-content ol { |
|
|
margin-bottom: 16px; |
|
|
padding-left: 30px; |
|
|
} |
|
|
|
|
|
.markdown-content li { |
|
|
margin-bottom: 8px; |
|
|
} |
|
|
|
|
|
.markdown-content code { |
|
|
background: var(--bg-tertiary); |
|
|
padding: 2px 6px; |
|
|
border-radius: 3px; |
|
|
font-family: 'Courier New', monospace; |
|
|
font-size: 0.9em; |
|
|
} |
|
|
|
|
|
.markdown-content pre { |
|
|
background: var(--bg-tertiary); |
|
|
padding: 16px; |
|
|
border-radius: 6px; |
|
|
overflow-x: auto; |
|
|
margin-bottom: 16px; |
|
|
} |
|
|
|
|
|
.markdown-content pre code { |
|
|
background: none; |
|
|
padding: 0; |
|
|
} |
|
|
|
|
|
.markdown-content blockquote { |
|
|
border-left: 4px solid var(--accent); |
|
|
padding-left: 16px; |
|
|
margin-left: 0; |
|
|
margin-bottom: 16px; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.markdown-content table { |
|
|
border-collapse: collapse; |
|
|
width: 100%; |
|
|
margin-bottom: 16px; |
|
|
} |
|
|
|
|
|
.markdown-content table th, |
|
|
.markdown-content table td { |
|
|
border: 1px solid var(--border); |
|
|
padding: 8px 12px; |
|
|
text-align: left; |
|
|
} |
|
|
|
|
|
.markdown-content table th { |
|
|
background: var(--bg-tertiary); |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.markdown-content strong { |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.markdown-content em { |
|
|
font-style: italic; |
|
|
} |
|
|
|
|
|
.markdown-content a { |
|
|
color: var(--accent); |
|
|
text-decoration: none; |
|
|
} |
|
|
|
|
|
.markdown-content a:hover { |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
|
|
|
.content-modal-wrapper { |
|
|
display: flex; |
|
|
height: calc(90vh - 100px); |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.content-sidebar { |
|
|
width: 125px; |
|
|
background: var(--bg-secondary); |
|
|
border-right: 1px solid var(--border); |
|
|
overflow-y: auto; |
|
|
padding: 12px; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.content-sidebar-title { |
|
|
font-size: 12px; |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
margin-bottom: 8px; |
|
|
padding-bottom: 6px; |
|
|
border-bottom: 2px solid var(--accent); |
|
|
} |
|
|
|
|
|
.content-toc { |
|
|
list-style: none; |
|
|
padding: 0; |
|
|
margin: 0; |
|
|
} |
|
|
|
|
|
.content-toc-item { |
|
|
margin-bottom: 4px; |
|
|
} |
|
|
|
|
|
.content-toc-link { |
|
|
display: block; |
|
|
padding: 6px 8px; |
|
|
color: var(--text-primary); |
|
|
text-decoration: none; |
|
|
border-radius: 4px; |
|
|
font-size: 11px; |
|
|
transition: all 0.2s; |
|
|
cursor: pointer; |
|
|
line-height: 1.4; |
|
|
} |
|
|
|
|
|
.content-toc-link:hover { |
|
|
background: var(--bg-tertiary); |
|
|
color: var(--accent); |
|
|
} |
|
|
|
|
|
.content-toc-link.active { |
|
|
background: var(--accent); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.content-main { |
|
|
flex: 1; |
|
|
overflow-y: auto; |
|
|
padding: 24px; |
|
|
} |
|
|
|
|
|
.content-section { |
|
|
scroll-margin-top: 20px; |
|
|
} |
|
|
|
|
|
|
|
|
.episode-sidebar-item { |
|
|
padding: 10px 12px; |
|
|
border-radius: 6px; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
font-size: 14px; |
|
|
color: var(--text-primary); |
|
|
margin-bottom: 4px; |
|
|
border: 1px solid transparent; |
|
|
} |
|
|
|
|
|
.episode-sidebar-item:hover { |
|
|
background: var(--ai-bg); |
|
|
border-color: var(--accent); |
|
|
} |
|
|
|
|
|
.episode-sidebar-item.active { |
|
|
background: var(--accent); |
|
|
color: white; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.episode-sidebar-item.active:hover { |
|
|
background: var(--accent-hover); |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="header"> |
|
|
<div class="header-title"> |
|
|
<span>๐</span> |
|
|
<span>์
๋ก๋๋ ์น์์ค</span> |
|
|
</div> |
|
|
<button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ์ด๊ธฐ">โฐ</button> |
|
|
<div class="header-actions"> |
|
|
<span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span> |
|
|
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">์ฐฝ์ ์ด์์คํดํธ</a> |
|
|
{% if current_user.is_admin %} |
|
|
<a href="{{ url_for('main.admin') }}" class="btn btn-secondary">๊ด๋ฆฌ์ ํ์ด์ง</a> |
|
|
{% endif %} |
|
|
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋ก๊ทธ์์</a> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)"> |
|
|
<div class="mobile-menu-content" onclick="event.stopPropagation()"> |
|
|
<div class="mobile-menu-header"> |
|
|
<div class="mobile-menu-title">๋ฉ๋ด</div> |
|
|
<button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="๋ฉ๋ด ๋ซ๊ธฐ">×</button> |
|
|
</div> |
|
|
<div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div> |
|
|
<div class="mobile-menu-items"> |
|
|
<a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">์ฐฝ์ ์ด์์คํดํธ</a> |
|
|
{% if current_user.is_admin %} |
|
|
<a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๊ด๋ฆฌ์ ํ์ด์ง</a> |
|
|
{% endif %} |
|
|
<a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">๋ก๊ทธ์์</a> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="container"> |
|
|
<div class="page-header"> |
|
|
<h1>์
๋ก๋๋ ์น์์ค</h1> |
|
|
<p>์
๋ก๋๋ ์น์์ค ๋ชฉ๋ก์ ํ์ธํ๊ณ ๋ด์ฉ์ ๋ณผ ์ ์์ต๋๋ค.</p> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<div class="filter-section" style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 250px;"> |
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);"> |
|
|
<circle cx="11" cy="11" r="8"></circle> |
|
|
<path d="m21 21-4.35-4.35"></path> |
|
|
</svg> |
|
|
<input type="text" id="webnovelTitleSearch" placeholder="์ ๋ชฉ์ผ๋ก ๊ฒ์..." |
|
|
style="flex: 1; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;" |
|
|
oninput="filterWebnovelsByTitle()" |
|
|
onkeydown="if(event.key === 'Escape') { document.getElementById('webnovelTitleSearch').value = ''; filterWebnovelsByTitle(); }"> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 8px;"> |
|
|
<label for="webnovelModelFilter" style="font-size: 14px; font-weight: 500; color: var(--text-primary);">๋ชจ๋ธ ํํฐ:</label> |
|
|
<select id="webnovelModelFilter" onchange="loadWebnovels()" style="min-width: 200px;"> |
|
|
<option value="">๋ชจ๋ ๋ชจ๋ธ</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
<div id="webnovelsList" style="min-height: 400px;"> |
|
|
<div style="text-align: center; color: var(--text-secondary); padding: 24px;"> |
|
|
์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="webnovelSummaryModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h2 class="modal-title" id="webnovelSummaryTitle">์์ฝ ๋ด์ฉ</h2> |
|
|
<button class="modal-close" onclick="closeWebnovelSummaryModal()">×</button> |
|
|
</div> |
|
|
|
|
|
<div class="content-modal-wrapper"> |
|
|
|
|
|
<div class="content-sidebar"> |
|
|
<div class="content-sidebar-title">๐ ๋ชฉ์ฐจ</div> |
|
|
<ul class="content-toc" id="webnovelSummaryTOC"> |
|
|
<li class="content-toc-item"> |
|
|
<div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...</div> |
|
|
</li> |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
<div class="content-main" id="webnovelSummaryContentContainer" style="height: calc(90vh - 100px); overflow-y: auto;"> |
|
|
<div id="webnovelSummaryContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;"> |
|
|
๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="graphRAGModal" class="modal"> |
|
|
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh; display: flex; flex-direction: column; padding: 0;"> |
|
|
<div class="modal-header" style="flex-shrink: 0; padding: 24px 24px 16px 24px; margin-bottom: 0;"> |
|
|
<div class="modal-title" id="graphRAGModalTitle">ํ์ฐจ๋ณ ์บ๋ฆญํฐ ๊ด๊ณ ๋ถ์</div> |
|
|
<button class="modal-close" onclick="closeGraphRAGModal()">×</button> |
|
|
</div> |
|
|
<div style="display: flex; flex: 1; overflow: hidden;"> |
|
|
|
|
|
<div id="graphRAGSidebar" style="width: 250px; background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; padding: 16px;"> |
|
|
<div style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border);"> |
|
|
ํ์ฐจ ๋ชฉ๋ก |
|
|
</div> |
|
|
<div id="graphRAGEpisodeList" style="display: flex; flex-direction: column; gap: 4px;"> |
|
|
<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;"> |
|
|
ํ์ฐจ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="graphRAGContent" style="flex: 1; overflow-y: auto; padding: 24px;"> |
|
|
<div style="text-align: center; padding: 24px; color: var(--text-secondary);"> |
|
|
GraphRAG ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div id="graphRAGVisualizationModal" class="modal"> |
|
|
<div class="modal-content" style="max-width: 1600px; width: 95%; height: 90vh;"> |
|
|
<div class="modal-header"> |
|
|
<div class="modal-title" id="graphRAGVisualizationModalTitle">์บ๋ฆญํฐ ๊ด๊ณ๋ ์๊ฐํ</div> |
|
|
<button class="modal-close" onclick="closeGraphRAGVisualizationModal()">×</button> |
|
|
</div> |
|
|
<div style="padding: 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap;"> |
|
|
<div style="position: relative;"> |
|
|
<button id="episodeFilterToggle" onclick="toggleWebnovelEpisodeFilter()" style="padding: 8px 16px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; color: var(--text-primary); font-weight: 500;"> |
|
|
<span>ํ์ฐจ ํํฐ</span> |
|
|
<span id="episodeFilterToggleIcon" style="font-size: 12px; transition: transform 0.2s;">โผ</span> |
|
|
</button> |
|
|
<div id="episodeFilterDropdown" style="display: none; position: absolute; top: 100%; left: 0; margin-top: 4px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; min-width: 250px; max-width: 400px; max-height: 400px; overflow-y: auto;"> |
|
|
<div style="padding: 12px; border-bottom: 1px solid var(--border);"> |
|
|
<label style="font-size: 13px; cursor: pointer; padding: 6px 8px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;" onmouseover="this.style.background='var(--bg-secondary)'" onmouseout="this.style.background='transparent'"> |
|
|
<input type="checkbox" id="episodeFilterAll" onchange="handleWebnovelEpisodeFilterAllChange()" style="margin-right: 8px;"> |
|
|
<span style="font-weight: 500;">์ ์ฒด ํ์ฐจ</span> |
|
|
</label> |
|
|
</div> |
|
|
<div id="episodeFilterList" style="padding: 8px; display: flex; flex-direction: column; gap: 2px;"> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<label style="font-size: 14px; font-weight: 500; margin-left: 16px;">๋
ธ๋ ํ์
:</label> |
|
|
<label style="font-size: 13px; margin-left: 8px;"> |
|
|
<input type="checkbox" id="showCharacters" checked onchange="updateGraphVisualization()" style="margin-right: 4px;"> |
|
|
์ธ๋ฌผ |
|
|
</label> |
|
|
<label style="font-size: 13px; margin-left: 8px;"> |
|
|
<input type="checkbox" id="showLocations" checked onchange="updateGraphVisualization()" style="margin-right: 4px;"> |
|
|
์ฅ์ |
|
|
</label> |
|
|
<label style="font-size: 13px; margin-left: 8px;"> |
|
|
<input type="checkbox" id="showEvents" checked onchange="updateGraphVisualization()" style="margin-right: 4px;"> |
|
|
์ฌ๊ฑด |
|
|
</label> |
|
|
<button onclick="resetGraphView()" style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; margin-left: auto;"> |
|
|
๋ทฐ ๋ฆฌ์
|
|
|
</button> |
|
|
</div> |
|
|
<div id="graphRAGVisualizationContent" style="height: calc(90vh - 120px); position: relative; background: var(--bg-primary);"> |
|
|
<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);"> |
|
|
๊ทธ๋ํ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script> |
|
|
|
|
|
|
|
|
<div id="webnovelContentModal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h2 class="modal-title" id="webnovelContentTitle">์น์์ค ๋ด์ฉ</h2> |
|
|
<button class="modal-close" onclick="closeWebnovelContentModal()">×</button> |
|
|
</div> |
|
|
|
|
|
<div style="padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; flex-wrap: wrap;"> |
|
|
<div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;"> |
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);"> |
|
|
<circle cx="11" cy="11" r="8"></circle> |
|
|
<path d="m21 21-4.35-4.35"></path> |
|
|
</svg> |
|
|
<input type="text" id="webnovelSearchInput" placeholder="๊ฒ์์ด๋ฅผ ์
๋ ฅํ์ธ์..." |
|
|
style="flex: 1; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;" |
|
|
onkeydown="if(event.key === 'Enter') performWebnovelSearch()"> |
|
|
</div> |
|
|
<button onclick="performWebnovelSearch()" |
|
|
style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;"> |
|
|
๊ฒ์ |
|
|
</button> |
|
|
<button onclick="clearWebnovelSearch()" |
|
|
style="padding: 6px 16px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 14px;"> |
|
|
์ด๊ธฐํ |
|
|
</button> |
|
|
<div id="webnovelSearchInfo" style="font-size: 12px; color: var(--text-secondary); min-width: 120px; text-align: right;"> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
<div style="padding: 8px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; justify-content: center;"> |
|
|
<button onclick="scrollToPreviousMatch()" id="prevMatchBtn" |
|
|
style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;" |
|
|
disabled> |
|
|
โ ์ด์ |
|
|
</button> |
|
|
<button onclick="scrollToNextMatch()" id="nextMatchBtn" |
|
|
style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;" |
|
|
disabled> |
|
|
๋ค์ โ |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<div class="content-modal-wrapper"> |
|
|
|
|
|
<div class="content-sidebar"> |
|
|
<div class="content-sidebar-title">๐ ๋ชฉ์ฐจ</div> |
|
|
<ul class="content-toc" id="webnovelTOC"> |
|
|
<li class="content-toc-item"> |
|
|
<div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...</div> |
|
|
</li> |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
<div class="content-main" id="webnovelContentContainer"> |
|
|
<div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary);"> |
|
|
๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค... |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
function toggleMobileMenu() { |
|
|
const menu = document.getElementById('mobileMenu'); |
|
|
menu.classList.toggle('active'); |
|
|
document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : ''; |
|
|
} |
|
|
|
|
|
function closeMobileMenu() { |
|
|
const menu = document.getElementById('mobileMenu'); |
|
|
menu.classList.remove('active'); |
|
|
document.body.style.overflow = ''; |
|
|
} |
|
|
|
|
|
function closeMobileMenuOnBackdrop(event) { |
|
|
if (event.target.id === 'mobileMenu') { |
|
|
closeMobileMenu(); |
|
|
} |
|
|
} |
|
|
|
|
|
function escapeHtml(text) { |
|
|
const div = document.createElement('div'); |
|
|
div.textContent = text; |
|
|
return div.innerHTML; |
|
|
} |
|
|
|
|
|
function renderMarkdown(text) { |
|
|
if (!text) return ''; |
|
|
try { |
|
|
|
|
|
if (typeof marked !== 'undefined') { |
|
|
return marked.parse(text); |
|
|
} else { |
|
|
|
|
|
return escapeHtml(text).replace(/\n/g, '<br>'); |
|
|
} |
|
|
} catch (e) { |
|
|
console.error('๋งํฌ๋ค์ด ๋ ๋๋ง ์ค๋ฅ:', e); |
|
|
return escapeHtml(text).replace(/\n/g, '<br>'); |
|
|
} |
|
|
} |
|
|
|
|
|
function formatFileSize(bytes) { |
|
|
if (bytes === 0) return '0 Bytes'; |
|
|
const k = 1024; |
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; |
|
|
} |
|
|
|
|
|
async function loadWebnovelModelFilter() { |
|
|
try { |
|
|
const response = await fetch('/api/ollama/models'); |
|
|
if (!response.ok) throw new Error('๋ชจ๋ธ ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
const data = await response.json(); |
|
|
const models = data.models || []; |
|
|
|
|
|
const filter = document.getElementById('webnovelModelFilter'); |
|
|
filter.innerHTML = '<option value="">๋ชจ๋ ๋ชจ๋ธ</option>'; |
|
|
models.forEach(model => { |
|
|
const option = document.createElement('option'); |
|
|
option.value = model.name; |
|
|
option.textContent = model.name; |
|
|
filter.appendChild(option); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('๋ชจ๋ธ ํํฐ ๋ก๋ ์ค๋ฅ:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
let allWebnovels = []; |
|
|
|
|
|
async function loadWebnovels() { |
|
|
const listContainer = document.getElementById('webnovelsList'); |
|
|
const modelFilter = document.getElementById('webnovelModelFilter'); |
|
|
const modelName = modelFilter ? modelFilter.value : ''; |
|
|
|
|
|
listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค...</div>'; |
|
|
|
|
|
try { |
|
|
|
|
|
const url = modelName |
|
|
? `/api/files?model_name=${encodeURIComponent(modelName)}&public_only=true` |
|
|
: '/api/files?public_only=true'; |
|
|
const response = await fetch(url, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
if (!response.ok) throw new Error('์น์์ค ๋ชฉ๋ก์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
allWebnovels = data.files || []; |
|
|
|
|
|
|
|
|
allWebnovels.sort((a, b) => { |
|
|
return a.original_filename.localeCompare(b.original_filename, 'ko'); |
|
|
}); |
|
|
|
|
|
if (!Array.isArray(allWebnovels)) { |
|
|
console.error('์์์น ๋ชปํ ์๋ต ํ์:', data); |
|
|
throw new Error('์น์์ค ๋ชฉ๋ก ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.'); |
|
|
} |
|
|
|
|
|
|
|
|
filterWebnovelsByTitle(); |
|
|
} catch (error) { |
|
|
console.error('์น์์ค ๋ชฉ๋ก ๋ก๋ ์ค๋ฅ:', error); |
|
|
listContainer.innerHTML = `<div style="text-align: center; color: #ea4335; padding: 24px;">์ค๋ฅ: ${error.message}</div>`; |
|
|
} |
|
|
} |
|
|
|
|
|
function filterWebnovelsByTitle() { |
|
|
const listContainer = document.getElementById('webnovelsList'); |
|
|
const searchInput = document.getElementById('webnovelTitleSearch'); |
|
|
const searchTerm = searchInput ? searchInput.value.trim().toLowerCase() : ''; |
|
|
|
|
|
if (allWebnovels.length === 0) { |
|
|
listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">์
๋ก๋๋ ์น์์ค์ด ์์ต๋๋ค.</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const filteredFiles = searchTerm |
|
|
? allWebnovels.filter(file => file.original_filename.toLowerCase().includes(searchTerm)) |
|
|
: allWebnovels; |
|
|
|
|
|
if (filteredFiles.length === 0) { |
|
|
listContainer.innerHTML = `<div style="text-align: center; color: var(--text-secondary); padding: 24px;">๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค. (๊ฒ์์ด: "${escapeHtml(searchTerm)}")</div>`; |
|
|
return; |
|
|
} |
|
|
|
|
|
listContainer.innerHTML = ''; |
|
|
filteredFiles.forEach(file => { |
|
|
const fileItem = document.createElement('div'); |
|
|
fileItem.className = 'webnovel-item'; |
|
|
|
|
|
const uploadedDate = new Date(file.uploaded_at); |
|
|
const formattedDate = uploadedDate.toLocaleDateString('ko-KR', { |
|
|
year: 'numeric', |
|
|
month: 'long', |
|
|
day: 'numeric' |
|
|
}); |
|
|
|
|
|
|
|
|
let displayTitle = escapeHtml(file.original_filename); |
|
|
if (searchTerm) { |
|
|
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi'); |
|
|
displayTitle = displayTitle.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 4px; border-radius: 2px;">$1</mark>'); |
|
|
} |
|
|
|
|
|
fileItem.innerHTML = ` |
|
|
<div class="webnovel-item-header"> |
|
|
<div class="webnovel-item-title">${displayTitle}</div> |
|
|
</div> |
|
|
<div class="webnovel-item-meta"> |
|
|
<span>๐
${formattedDate}</span> |
|
|
<span>๐ฆ ${formatFileSize(file.file_size)}</span> |
|
|
<span>๐งฉ ์ฒญํฌ: ${file.chunk_count || 0}๊ฐ</span> |
|
|
${file.model_name ? `<span>๐ค ${escapeHtml(file.model_name)}</span>` : ''} |
|
|
</div> |
|
|
<div class="webnovel-item-actions"> |
|
|
<button class="webnovel-item-btn" onclick="viewWebnovelSummary(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #17a2b8; color: white; margin-right: 8px;">๐ ์์ฝ ๋ณด๊ธฐ</button> |
|
|
<button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')" style="margin-right: 8px;">๐ ๋ด์ฉ ๋ณด๊ธฐ</button> |
|
|
<button class="webnovel-item-btn" onclick="viewGraphRAG(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #9c27b0; color: white; margin-right: 8px;">๐ ํ์ฐจ๋ณ ์บ๋ฆญํฐ ๊ด๊ณ ๋ถ์</button> |
|
|
<button class="webnovel-item-btn" onclick="viewGraphRAGVisualization(${file.id}, '${escapeHtml(file.original_filename)}')" style="background: #28a745; color: white;">๐ ์บ๋ฆญํฐ ๊ด๊ณ๋ ์๊ฐํ</button> |
|
|
</div> |
|
|
`; |
|
|
listContainer.appendChild(fileItem); |
|
|
}); |
|
|
} |
|
|
|
|
|
let webnovelOriginalContent = ''; |
|
|
let webnovelSearchMatches = []; |
|
|
let webnovelCurrentMatchIndex = -1; |
|
|
let webnovelTOC = []; |
|
|
|
|
|
function parseTableOfContents(content) { |
|
|
const toc = []; |
|
|
const lines = content.split('\n'); |
|
|
const episodePattern = /^#\s*(์ํ์ค๋ช
|\d+ํ)/; |
|
|
|
|
|
lines.forEach((line, index) => { |
|
|
const match = line.match(episodePattern); |
|
|
if (match) { |
|
|
const title = match[1]; |
|
|
const id = `section-${toc.length}`; |
|
|
toc.push({ |
|
|
id: id, |
|
|
title: title === '์ํ์ค๋ช
' ? '์ํ์ค๋ช
' : title, |
|
|
lineIndex: index, |
|
|
element: null |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
return toc; |
|
|
} |
|
|
|
|
|
function renderContentWithSections(content, toc) { |
|
|
const lines = content.split('\n'); |
|
|
let html = ''; |
|
|
let currentSectionIndex = 0; |
|
|
|
|
|
lines.forEach((line, index) => { |
|
|
if (currentSectionIndex < toc.length && index === toc[currentSectionIndex].lineIndex) { |
|
|
|
|
|
if (currentSectionIndex > 0) { |
|
|
html += '</div>'; |
|
|
} |
|
|
html += `<div id="${toc[currentSectionIndex].id}" class="content-section">`; |
|
|
html += `<h2 style="font-size: 20px; font-weight: 600; margin-top: 24px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid var(--accent);">${escapeHtml(line)}</h2>`; |
|
|
currentSectionIndex++; |
|
|
} else { |
|
|
html += escapeHtml(line) + '\n'; |
|
|
} |
|
|
}); |
|
|
|
|
|
if (currentSectionIndex > 0) { |
|
|
html += '</div>'; |
|
|
} |
|
|
|
|
|
return html; |
|
|
} |
|
|
|
|
|
function renderTableOfContents(toc) { |
|
|
const tocContainer = document.getElementById('webnovelTOC'); |
|
|
if (toc.length === 0) { |
|
|
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๊ฐ ์์ต๋๋ค.</div></li>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
tocContainer.innerHTML = ''; |
|
|
toc.forEach((item, index) => { |
|
|
const li = document.createElement('li'); |
|
|
li.className = 'content-toc-item'; |
|
|
const link = document.createElement('a'); |
|
|
link.className = 'content-toc-link'; |
|
|
link.href = `#${item.id}`; |
|
|
link.textContent = item.title; |
|
|
link.onclick = (e) => { |
|
|
e.preventDefault(); |
|
|
scrollToSection(item.id); |
|
|
|
|
|
document.querySelectorAll('.content-toc-link').forEach(l => l.classList.remove('active')); |
|
|
link.classList.add('active'); |
|
|
}; |
|
|
li.appendChild(link); |
|
|
tocContainer.appendChild(li); |
|
|
}); |
|
|
} |
|
|
|
|
|
function scrollToSection(sectionId) { |
|
|
const section = document.getElementById(sectionId); |
|
|
if (section) { |
|
|
const container = document.getElementById('webnovelContentContainer'); |
|
|
const containerRect = container.getBoundingClientRect(); |
|
|
const sectionRect = section.getBoundingClientRect(); |
|
|
const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20; |
|
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' }); |
|
|
} |
|
|
} |
|
|
|
|
|
async function viewWebnovelContent(fileId, filename) { |
|
|
const modal = document.getElementById('webnovelContentModal'); |
|
|
const title = document.getElementById('webnovelContentTitle'); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
|
|
|
modal.classList.add('active'); |
|
|
title.textContent = filename; |
|
|
content.textContent = '๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค...'; |
|
|
searchInput.value = ''; |
|
|
webnovelOriginalContent = ''; |
|
|
webnovelSearchMatches = []; |
|
|
webnovelCurrentMatchIndex = -1; |
|
|
webnovelTOC = []; |
|
|
updateWebnovelSearchInfo(); |
|
|
|
|
|
|
|
|
const tocContainer = document.getElementById('webnovelTOC'); |
|
|
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...</div></li>'; |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/files/${fileId}/content`, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
if (!response.ok) throw new Error('์น์์ค ๋ด์ฉ์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
webnovelOriginalContent = data.content; |
|
|
|
|
|
|
|
|
webnovelTOC = parseTableOfContents(webnovelOriginalContent); |
|
|
|
|
|
|
|
|
renderTableOfContents(webnovelTOC); |
|
|
|
|
|
|
|
|
content.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC); |
|
|
|
|
|
|
|
|
searchInput.focus(); |
|
|
} catch (error) { |
|
|
console.error('์น์์ค ๋ด์ฉ ๋ก๋ ์ค๋ฅ:', error); |
|
|
content.textContent = `์ค๋ฅ: ${error.message}`; |
|
|
} |
|
|
} |
|
|
|
|
|
function performWebnovelSearch() { |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
|
|
|
if (!searchTerm) { |
|
|
clearWebnovelSearch(); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!webnovelOriginalContent) { |
|
|
|
|
|
const tempDiv = document.createElement('div'); |
|
|
tempDiv.innerHTML = content.innerHTML; |
|
|
webnovelOriginalContent = tempDiv.textContent || tempDiv.innerText || ''; |
|
|
} |
|
|
|
|
|
|
|
|
let contentHtml = renderContentWithSections(webnovelOriginalContent, webnovelTOC); |
|
|
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi'); |
|
|
contentHtml = contentHtml.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>'); |
|
|
content.innerHTML = contentHtml; |
|
|
|
|
|
|
|
|
webnovelSearchMatches = []; |
|
|
const matches = [...webnovelOriginalContent.matchAll(new RegExp(escapeRegex(searchTerm), 'gi'))]; |
|
|
matches.forEach(match => { |
|
|
webnovelSearchMatches.push(match.index); |
|
|
}); |
|
|
|
|
|
webnovelCurrentMatchIndex = webnovelSearchMatches.length > 0 ? 0 : -1; |
|
|
updateWebnovelSearchInfo(); |
|
|
updateWebnovelNavigationButtons(); |
|
|
|
|
|
if (webnovelCurrentMatchIndex >= 0) { |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
} |
|
|
} |
|
|
|
|
|
function clearWebnovelSearch() { |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const content = document.getElementById('webnovelContent'); |
|
|
|
|
|
searchInput.value = ''; |
|
|
if (webnovelOriginalContent) { |
|
|
|
|
|
content.innerHTML = renderContentWithSections(webnovelOriginalContent, webnovelTOC); |
|
|
} |
|
|
webnovelSearchMatches = []; |
|
|
webnovelCurrentMatchIndex = -1; |
|
|
updateWebnovelSearchInfo(); |
|
|
updateWebnovelNavigationButtons(); |
|
|
} |
|
|
|
|
|
function scrollToMatch(index) { |
|
|
if (index < 0 || index >= webnovelSearchMatches.length) return; |
|
|
|
|
|
const content = document.getElementById('webnovelContent'); |
|
|
const container = document.getElementById('webnovelContentContainer'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
|
|
|
if (!searchTerm) return; |
|
|
|
|
|
|
|
|
const marks = content.querySelectorAll('mark'); |
|
|
if (marks.length > 0 && marks[index]) { |
|
|
|
|
|
marks.forEach((mark, i) => { |
|
|
if (i === index) { |
|
|
mark.style.background = '#ff9800'; |
|
|
mark.style.fontWeight = 'bold'; |
|
|
mark.style.boxShadow = '0 0 4px rgba(255, 152, 0, 0.5)'; |
|
|
} else { |
|
|
mark.style.background = '#ffeb3b'; |
|
|
mark.style.fontWeight = 'normal'; |
|
|
mark.style.boxShadow = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
marks[index].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
|
|
} else { |
|
|
|
|
|
const textNode = content.firstChild; |
|
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) { |
|
|
const range = document.createRange(); |
|
|
const matchPos = webnovelSearchMatches[index]; |
|
|
const matchLength = searchTerm.length; |
|
|
|
|
|
try { |
|
|
range.setStart(textNode, matchPos); |
|
|
range.setEnd(textNode, matchPos + matchLength); |
|
|
|
|
|
|
|
|
const rect = range.getBoundingClientRect(); |
|
|
const containerRect = container.getBoundingClientRect(); |
|
|
|
|
|
|
|
|
const scrollTop = container.scrollTop + (rect.top - containerRect.top) - (containerRect.height / 2); |
|
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' }); |
|
|
} catch (e) { |
|
|
console.error('์คํฌ๋กค ์ค๋ฅ:', e); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function scrollToNextMatch() { |
|
|
if (webnovelSearchMatches.length === 0) return; |
|
|
webnovelCurrentMatchIndex = (webnovelCurrentMatchIndex + 1) % webnovelSearchMatches.length; |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
updateWebnovelSearchInfo(); |
|
|
} |
|
|
|
|
|
function scrollToPreviousMatch() { |
|
|
if (webnovelSearchMatches.length === 0) return; |
|
|
webnovelCurrentMatchIndex = webnovelCurrentMatchIndex <= 0 |
|
|
? webnovelSearchMatches.length - 1 |
|
|
: webnovelCurrentMatchIndex - 1; |
|
|
scrollToMatch(webnovelCurrentMatchIndex); |
|
|
updateWebnovelSearchInfo(); |
|
|
} |
|
|
|
|
|
function updateWebnovelSearchInfo() { |
|
|
const info = document.getElementById('webnovelSearchInfo'); |
|
|
const searchInput = document.getElementById('webnovelSearchInput'); |
|
|
const searchTerm = searchInput.value.trim(); |
|
|
|
|
|
if (!searchTerm || webnovelSearchMatches.length === 0) { |
|
|
info.textContent = ''; |
|
|
return; |
|
|
} |
|
|
|
|
|
if (webnovelCurrentMatchIndex >= 0) { |
|
|
info.textContent = `${webnovelCurrentMatchIndex + 1} / ${webnovelSearchMatches.length}`; |
|
|
} else { |
|
|
info.textContent = `์ด ${webnovelSearchMatches.length}๊ฐ`; |
|
|
} |
|
|
} |
|
|
|
|
|
function updateWebnovelNavigationButtons() { |
|
|
const prevBtn = document.getElementById('prevMatchBtn'); |
|
|
const nextBtn = document.getElementById('nextMatchBtn'); |
|
|
const hasMatches = webnovelSearchMatches.length > 0; |
|
|
|
|
|
prevBtn.disabled = !hasMatches; |
|
|
nextBtn.disabled = !hasMatches; |
|
|
} |
|
|
|
|
|
function escapeRegex(str) { |
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
|
} |
|
|
|
|
|
function parseSummaryTableOfContents(data) { |
|
|
const toc = []; |
|
|
let sectionIndex = 0; |
|
|
|
|
|
|
|
|
if (data.parent_chunk) { |
|
|
toc.push({ |
|
|
id: 'summary-section-parent', |
|
|
title: 'Parent Chunk', |
|
|
level: 1 |
|
|
}); |
|
|
|
|
|
if (data.parent_chunk.world_view) { |
|
|
toc.push({ |
|
|
id: 'summary-section-world-view', |
|
|
title: '์ธ๊ณ๊ด', |
|
|
level: 2 |
|
|
}); |
|
|
} |
|
|
if (data.parent_chunk.characters) { |
|
|
toc.push({ |
|
|
id: 'summary-section-characters', |
|
|
title: '์ฃผ์ ์บ๋ฆญํฐ', |
|
|
level: 2 |
|
|
}); |
|
|
} |
|
|
if (data.parent_chunk.story) { |
|
|
toc.push({ |
|
|
id: 'summary-section-story', |
|
|
title: '์ฃผ์ ์คํ ๋ฆฌ', |
|
|
level: 2 |
|
|
}); |
|
|
} |
|
|
if (data.parent_chunk.episodes) { |
|
|
toc.push({ |
|
|
id: 'summary-section-episodes', |
|
|
title: '์ฃผ์ ์ํผ์๋', |
|
|
level: 2 |
|
|
}); |
|
|
} |
|
|
if (data.parent_chunk.others) { |
|
|
toc.push({ |
|
|
id: 'summary-section-others', |
|
|
title: '๊ธฐํ', |
|
|
level: 2 |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (data.episode_analysis && data.episode_analysis.analysis_content) { |
|
|
toc.push({ |
|
|
id: 'summary-section-episode-analysis', |
|
|
title: 'ํ์ฐจ๋ณ ๋ถ์', |
|
|
level: 1 |
|
|
}); |
|
|
|
|
|
|
|
|
const lines = data.episode_analysis.analysis_content.split('\n'); |
|
|
let episodeIndex = 0; |
|
|
lines.forEach((line) => { |
|
|
const headerMatch = line.match(/^##\s+(.+)$/); |
|
|
if (headerMatch) { |
|
|
const title = headerMatch[1].trim(); |
|
|
const sectionId = `summary-episode-${episodeIndex}`; |
|
|
toc.push({ |
|
|
id: sectionId, |
|
|
title: title, |
|
|
level: 2 |
|
|
}); |
|
|
episodeIndex++; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
return toc; |
|
|
} |
|
|
|
|
|
function renderSummaryWithSections(data) { |
|
|
let contentHtml = ''; |
|
|
let sectionIndex = 0; |
|
|
|
|
|
|
|
|
if (data.parent_chunk) { |
|
|
contentHtml += '<div id="summary-section-parent" class="content-section" style="margin-bottom: 32px;">'; |
|
|
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">Parent Chunk (์ํ ์ ์ฒด ์์ฝ)</h3>'; |
|
|
|
|
|
if (data.parent_chunk.world_view) { |
|
|
contentHtml += '<div id="summary-section-world-view" class="content-section" style="margin-bottom: 16px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ธ๊ณ๊ด</h4>'; |
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.world_view)}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
if (data.parent_chunk.characters) { |
|
|
contentHtml += '<div id="summary-section-characters" class="content-section" style="margin-bottom: 16px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์ ์บ๋ฆญํฐ</h4>'; |
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.characters)}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
if (data.parent_chunk.story) { |
|
|
contentHtml += '<div id="summary-section-story" class="content-section" style="margin-bottom: 16px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์ ์คํ ๋ฆฌ</h4>'; |
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.story)}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
if (data.parent_chunk.episodes) { |
|
|
contentHtml += '<div id="summary-section-episodes" class="content-section" style="margin-bottom: 16px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">์ฃผ์ ์ํผ์๋</h4>'; |
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.episodes)}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
if (data.parent_chunk.others) { |
|
|
contentHtml += '<div id="summary-section-others" class="content-section" style="margin-bottom: 16px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #1967d2;">๊ธฐํ</h4>'; |
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${renderMarkdown(data.parent_chunk.others)}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
contentHtml += '</div>'; |
|
|
} else { |
|
|
contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">Parent Chunk๊ฐ ์์ฑ๋์ง ์์์ต๋๋ค.</div>'; |
|
|
} |
|
|
|
|
|
|
|
|
if (data.episode_analysis) { |
|
|
contentHtml += '<div id="summary-section-episode-analysis" class="content-section" style="margin-top: 32px; border-top: 2px solid var(--border); padding-top: 24px;">'; |
|
|
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent); border-bottom: 2px solid var(--accent); padding-bottom: 8px;">ํ์ฐจ๋ณ ๋ถ์</h3>'; |
|
|
|
|
|
|
|
|
let episodeContent = data.episode_analysis.analysis_content || ''; |
|
|
|
|
|
|
|
|
let episodeHtml = renderMarkdown(episodeContent); |
|
|
|
|
|
|
|
|
const tempDiv = document.createElement('div'); |
|
|
tempDiv.innerHTML = episodeHtml; |
|
|
const headers = tempDiv.querySelectorAll('h2'); |
|
|
let headerIndex = 0; |
|
|
headers.forEach((header) => { |
|
|
const sectionId = `summary-episode-${headerIndex}`; |
|
|
header.id = sectionId; |
|
|
header.className = 'content-section'; |
|
|
header.style.cssText = 'font-size: 16px; font-weight: 600; margin-top: 24px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);'; |
|
|
headerIndex++; |
|
|
}); |
|
|
episodeHtml = tempDiv.innerHTML; |
|
|
|
|
|
contentHtml += `<div class="markdown-content" style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">${episodeHtml}</div>`; |
|
|
contentHtml += '</div>'; |
|
|
} else { |
|
|
contentHtml += '<div style="margin-top: 32px; padding: 16px; background: #fff3cd; border-radius: 6px; color: #856404;">ํ์ฐจ๋ณ ๋ถ์์ด ์์ฑ๋์ง ์์์ต๋๋ค.</div>'; |
|
|
} |
|
|
|
|
|
if (!data.parent_chunk && !data.episode_analysis) { |
|
|
contentHtml = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">์์ฝ ๋ด์ฉ์ด ์์ต๋๋ค.</div>'; |
|
|
} |
|
|
|
|
|
return contentHtml; |
|
|
} |
|
|
|
|
|
function renderSummaryTableOfContents(toc) { |
|
|
const tocContainer = document.getElementById('webnovelSummaryTOC'); |
|
|
if (toc.length === 0) { |
|
|
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๊ฐ ์์ต๋๋ค.</div></li>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
tocContainer.innerHTML = ''; |
|
|
toc.forEach((item) => { |
|
|
const li = document.createElement('li'); |
|
|
li.className = 'content-toc-item'; |
|
|
const link = document.createElement('a'); |
|
|
link.className = 'content-toc-link'; |
|
|
link.href = `#${item.id}`; |
|
|
link.textContent = item.title; |
|
|
if (item.level === 2) { |
|
|
link.style.paddingLeft = '16px'; |
|
|
link.style.fontSize = '10px'; |
|
|
} |
|
|
link.onclick = (e) => { |
|
|
e.preventDefault(); |
|
|
scrollToSummarySection(item.id); |
|
|
|
|
|
document.querySelectorAll('#webnovelSummaryTOC .content-toc-link').forEach(l => l.classList.remove('active')); |
|
|
link.classList.add('active'); |
|
|
}; |
|
|
li.appendChild(link); |
|
|
tocContainer.appendChild(li); |
|
|
}); |
|
|
} |
|
|
|
|
|
function scrollToSummarySection(sectionId) { |
|
|
const section = document.getElementById(sectionId); |
|
|
if (section) { |
|
|
const container = document.getElementById('webnovelSummaryContentContainer'); |
|
|
const containerRect = container.getBoundingClientRect(); |
|
|
const sectionRect = section.getBoundingClientRect(); |
|
|
const scrollTop = container.scrollTop + (sectionRect.top - containerRect.top) - 20; |
|
|
container.scrollTo({ top: scrollTop, behavior: 'smooth' }); |
|
|
} |
|
|
} |
|
|
|
|
|
async function viewWebnovelSummary(fileId, fileName) { |
|
|
const modal = document.getElementById('webnovelSummaryModal'); |
|
|
const title = document.getElementById('webnovelSummaryTitle'); |
|
|
const content = document.getElementById('webnovelSummaryContent'); |
|
|
|
|
|
console.log('[์์ฝ ๋ณด๊ธฐ] ํ์ผ ID:', fileId, 'ํ์ผ๋ช
:', fileName); |
|
|
|
|
|
title.textContent = `์์ฝ ๋ด์ฉ - ${fileName}`; |
|
|
content.textContent = '์์ฝ ๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค...'; |
|
|
modal.classList.add('active'); |
|
|
|
|
|
|
|
|
const tocContainer = document.getElementById('webnovelSummaryTOC'); |
|
|
tocContainer.innerHTML = '<li class="content-toc-item"><div style="padding: 8px 12px; color: var(--text-secondary); font-size: 12px;">๋ชฉ์ฐจ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...</div></li>'; |
|
|
|
|
|
try { |
|
|
const url = `/api/files/${fileId}/summary`; |
|
|
console.log('[์์ฝ ๋ณด๊ธฐ] API ์์ฒญ URL:', url); |
|
|
const response = await fetch(url, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
console.log('[์์ฝ ๋ณด๊ธฐ] ์๋ต ์ํ:', response.status, response.statusText); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorData = await response.json().catch(() => ({ error: '์์ฝ ๋ด์ฉ์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.' })); |
|
|
throw new Error(errorData.error || `์์ฝ ๋ด์ฉ์ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค. (${response.status})`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
const summaryTOC = parseSummaryTableOfContents(data); |
|
|
|
|
|
|
|
|
renderSummaryTableOfContents(summaryTOC); |
|
|
|
|
|
|
|
|
content.innerHTML = renderSummaryWithSections(data); |
|
|
} catch (error) { |
|
|
console.error('์์ฝ ๋ด์ฉ ๋ก๋ ์ค๋ฅ:', error); |
|
|
content.innerHTML = `<div style="text-align: center; padding: 24px; color: #ea4335;"> |
|
|
<p style="font-weight: 600; margin-bottom: 8px;">์์ฝ ๋ด์ฉ์ ๋ถ๋ฌ์ค๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.</p> |
|
|
<p style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(error.message)}</p> |
|
|
</div>`; |
|
|
} |
|
|
} |
|
|
|
|
|
function closeWebnovelSummaryModal() { |
|
|
document.getElementById('webnovelSummaryModal').classList.remove('active'); |
|
|
} |
|
|
|
|
|
function closeWebnovelContentModal() { |
|
|
document.getElementById('webnovelContentModal').classList.remove('active'); |
|
|
clearWebnovelSearch(); |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('webnovelSummaryModal').addEventListener('click', function(e) { |
|
|
if (e.target === this) { |
|
|
closeWebnovelSummaryModal(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('webnovelContentModal').addEventListener('click', function(e) { |
|
|
if (e.target === this) { |
|
|
closeWebnovelContentModal(); |
|
|
} |
|
|
}); |
|
|
|
|
|
document.getElementById('graphRAGModal').addEventListener('click', function(e) { |
|
|
if (e.target === this) { |
|
|
closeGraphRAGModal(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
function sortEpisodesByNumber(episodes) { |
|
|
return episodes.slice().sort((a, b) => { |
|
|
|
|
|
const numA = parseInt(a.match(/\d+/)?.[0] || '0'); |
|
|
const numB = parseInt(b.match(/\d+/)?.[0] || '0'); |
|
|
return numA - numB; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
function scrollToEpisode(episodeId) { |
|
|
const element = document.getElementById(episodeId); |
|
|
if (element) { |
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
|
|
|
|
|
|
|
|
const sidebarItems = document.querySelectorAll('.episode-sidebar-item'); |
|
|
sidebarItems.forEach(item => item.classList.remove('active')); |
|
|
|
|
|
|
|
|
const clickedItem = Array.from(sidebarItems).find(item => { |
|
|
const itemEpisodeId = `episode-${item.textContent.replace(/[^a-zA-Z0-9]/g, '-')}`; |
|
|
return itemEpisodeId === episodeId; |
|
|
}); |
|
|
if (clickedItem) { |
|
|
clickedItem.classList.add('active'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function updateActiveEpisode(episodes) { |
|
|
const contentElement = document.getElementById('graphRAGContent'); |
|
|
const sidebarItems = document.querySelectorAll('.episode-sidebar-item'); |
|
|
|
|
|
if (!contentElement || sidebarItems.length === 0) return; |
|
|
|
|
|
const scrollTop = contentElement.scrollTop; |
|
|
const viewportHeight = contentElement.clientHeight; |
|
|
const scrollBottom = scrollTop + viewportHeight; |
|
|
|
|
|
|
|
|
const statsOffset = 150; |
|
|
|
|
|
let activeEpisode = null; |
|
|
let activeElement = null; |
|
|
|
|
|
episodes.forEach(episode => { |
|
|
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`; |
|
|
const element = document.getElementById(episodeId); |
|
|
|
|
|
if (element) { |
|
|
const elementTop = element.offsetTop - statsOffset; |
|
|
const elementBottom = elementTop + element.offsetHeight; |
|
|
|
|
|
|
|
|
if (elementTop <= scrollTop + 100 && elementBottom > scrollTop) { |
|
|
activeEpisode = episode; |
|
|
activeElement = element; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (!activeEpisode && episodes.length > 0) { |
|
|
const firstEpisode = episodes[0]; |
|
|
const firstElement = document.getElementById(`episode-${firstEpisode.replace(/[^a-zA-Z0-9]/g, '-')}`); |
|
|
if (firstElement && firstElement.offsetTop - 150 > scrollTop + viewportHeight) { |
|
|
activeEpisode = firstEpisode; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
sidebarItems.forEach(item => { |
|
|
item.classList.remove('active'); |
|
|
if (activeEpisode && item.textContent === activeEpisode) { |
|
|
item.classList.add('active'); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
async function viewGraphRAG(fileId, fileName) { |
|
|
const modal = document.getElementById('graphRAGModal'); |
|
|
const title = document.getElementById('graphRAGModalTitle'); |
|
|
const content = document.getElementById('graphRAGContent'); |
|
|
const sidebar = document.getElementById('graphRAGEpisodeList'); |
|
|
|
|
|
title.textContent = `ํ์ฐจ๋ณ ์บ๋ฆญํฐ ๊ด๊ณ ๋ถ์ - ${fileName}`; |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค...</div>'; |
|
|
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">ํ์ฐจ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ์ค...</div>'; |
|
|
modal.classList.add('active'); |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/files/${fileId}/graph`, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
|
|
|
|
|
|
const episodes = sortEpisodesByNumber(data.episodes || []); |
|
|
|
|
|
|
|
|
sidebar.innerHTML = ''; |
|
|
if (episodes.length === 0) { |
|
|
sidebar.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); font-size: 13px;">ํ์ฐจ๊ฐ ์์ต๋๋ค.</div>'; |
|
|
} else { |
|
|
episodes.forEach((episode, index) => { |
|
|
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`; |
|
|
const item = document.createElement('div'); |
|
|
item.className = 'episode-sidebar-item'; |
|
|
item.textContent = episode; |
|
|
item.onclick = () => scrollToEpisode(episodeId); |
|
|
if (index === 0) { |
|
|
item.classList.add('active'); |
|
|
} |
|
|
sidebar.appendChild(item); |
|
|
}); |
|
|
} |
|
|
|
|
|
let contentHtml = ''; |
|
|
|
|
|
|
|
|
if (data.statistics) { |
|
|
contentHtml += '<div style="margin-bottom: 32px; padding: 16px; background: var(--bg-secondary); border-radius: 6px;">'; |
|
|
contentHtml += '<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--accent);">ํต๊ณ ์ ๋ณด</h3>'; |
|
|
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">'; |
|
|
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>์ํฐํฐ:</strong> ${data.statistics.total_entities}๊ฐ</div>`; |
|
|
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>๊ด๊ณ:</strong> ${data.statistics.total_relationships}๊ฐ</div>`; |
|
|
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>์ฌ๊ฑด:</strong> ${data.statistics.total_events}๊ฐ</div>`; |
|
|
contentHtml += `<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px;"><strong>ํ์ฐจ ์:</strong> ${data.statistics.episodes_count}๊ฐ</div>`; |
|
|
contentHtml += '</div>'; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
if (episodes.length === 0) { |
|
|
contentHtml += '<div style="text-align: center; padding: 24px; color: var(--text-secondary);">GraphRAG ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.</div>'; |
|
|
} else { |
|
|
episodes.forEach(episode => { |
|
|
const episodeId = `episode-${episode.replace(/[^a-zA-Z0-9]/g, '-')}`; |
|
|
contentHtml += `<div id="${episodeId}" style="margin-bottom: 32px; padding: 20px; background: var(--bg-secondary); border-radius: 8px; border-left: 4px solid var(--accent); scroll-margin-top: 20px;">`; |
|
|
contentHtml += `<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 20px; color: var(--accent);">${escapeHtml(episode)}</h3>`; |
|
|
|
|
|
|
|
|
const characters = data.entities_by_episode[episode]?.characters || []; |
|
|
if (characters.length > 0) { |
|
|
contentHtml += '<div style="margin-bottom: 20px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์ธ๋ฌผ</h4>'; |
|
|
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">'; |
|
|
characters.forEach(char => { |
|
|
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">'; |
|
|
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(char.entity_name)}</div>`; |
|
|
if (char.role) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์ญํ :</strong> ${escapeHtml(char.role)}</div>`; |
|
|
} |
|
|
if (char.description) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(char.description)}</div>`; |
|
|
} |
|
|
contentHtml += '</div>'; |
|
|
}); |
|
|
contentHtml += '</div>'; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
|
|
|
const locations = data.entities_by_episode[episode]?.locations || []; |
|
|
if (locations.length > 0) { |
|
|
contentHtml += '<div style="margin-bottom: 20px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์ฅ์</h4>'; |
|
|
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px;">'; |
|
|
locations.forEach(loc => { |
|
|
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">'; |
|
|
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(loc.entity_name)}</div>`; |
|
|
if (loc.category) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์ ํ:</strong> ${escapeHtml(loc.category)}</div>`; |
|
|
} |
|
|
if (loc.description) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary);">${escapeHtml(loc.description)}</div>`; |
|
|
} |
|
|
contentHtml += '</div>'; |
|
|
}); |
|
|
contentHtml += '</div>'; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
|
|
|
const relationships = data.relationships_by_episode[episode] || []; |
|
|
if (relationships.length > 0) { |
|
|
contentHtml += '<div style="margin-bottom: 20px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">๊ด๊ณ</h4>'; |
|
|
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">'; |
|
|
relationships.forEach(rel => { |
|
|
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">'; |
|
|
contentHtml += `<div style="margin-bottom: 8px;">`; |
|
|
contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.source)}</span>`; |
|
|
contentHtml += `<span style="margin: 0 8px; color: var(--text-secondary);">โ</span>`; |
|
|
contentHtml += `<span style="font-weight: 600; color: var(--accent);">${escapeHtml(rel.target)}</span>`; |
|
|
contentHtml += '</div>'; |
|
|
if (rel.relationship_type) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>๊ด๊ณ ์ ํ:</strong> ${escapeHtml(rel.relationship_type)}</div>`; |
|
|
} |
|
|
if (rel.description) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;">${escapeHtml(rel.description)}</div>`; |
|
|
} |
|
|
if (rel.event) { |
|
|
contentHtml += `<div style="font-size: 12px; color: #856404; padding: 8px; background: #fff3cd; border-radius: 4px; margin-top: 8px;"><strong>๊ด๋ จ ์ฌ๊ฑด:</strong> ${escapeHtml(rel.event)}</div>`; |
|
|
} |
|
|
contentHtml += '</div>'; |
|
|
}); |
|
|
contentHtml += '</div>'; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
|
|
|
const events = data.events_by_episode[episode] || []; |
|
|
if (events.length > 0) { |
|
|
contentHtml += '<div style="margin-bottom: 20px;">'; |
|
|
contentHtml += '<h4 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #1967d2;">์ฌ๊ฑด</h4>'; |
|
|
contentHtml += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 12px;">'; |
|
|
events.forEach(event => { |
|
|
contentHtml += '<div style="padding: 12px; background: var(--bg-primary); border-radius: 6px; border: 1px solid var(--border);">'; |
|
|
if (event.event_name) { |
|
|
contentHtml += `<div style="font-weight: 600; margin-bottom: 8px; color: var(--accent);">${escapeHtml(event.event_name)}</div>`; |
|
|
} |
|
|
if (event.description) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; line-height: 1.6;">${escapeHtml(event.description)}</div>`; |
|
|
} |
|
|
if (event.participants && event.participants.length > 0) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>๊ด๋ จ ์ธ๋ฌผ:</strong> ${escapeHtml(event.participants.join(', '))}</div>`; |
|
|
} |
|
|
if (event.location) { |
|
|
contentHtml += `<div style="font-size: 13px; color: var(--text-secondary); margin-bottom: 4px;"><strong>์ฅ์:</strong> ${escapeHtml(event.location)}</div>`; |
|
|
} |
|
|
if (event.significance) { |
|
|
contentHtml += `<div style="font-size: 12px; color: #137333; padding: 6px; background: #e8f5e9; border-radius: 4px; margin-top: 8px; display: inline-block;"><strong>์ค์๋:</strong> ${escapeHtml(event.significance)}</div>`; |
|
|
} |
|
|
contentHtml += '</div>'; |
|
|
}); |
|
|
contentHtml += '</div>'; |
|
|
contentHtml += '</div>'; |
|
|
} |
|
|
|
|
|
contentHtml += '</div>'; |
|
|
}); |
|
|
} |
|
|
|
|
|
content.innerHTML = contentHtml; |
|
|
|
|
|
|
|
|
const contentElement = document.getElementById('graphRAGContent'); |
|
|
contentElement.addEventListener('scroll', () => updateActiveEpisode(episodes)); |
|
|
|
|
|
|
|
|
updateActiveEpisode(episodes); |
|
|
} catch (error) { |
|
|
console.error('GraphRAG ๋ฐ์ดํฐ ๋ก๋ ์ค๋ฅ:', error); |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335;">GraphRAG ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.</div>'; |
|
|
} |
|
|
} |
|
|
|
|
|
function closeGraphRAGModal() { |
|
|
document.getElementById('graphRAGModal').classList.remove('active'); |
|
|
} |
|
|
|
|
|
|
|
|
let webnovelGraphData = null; |
|
|
let webnovelGraphNetwork = null; |
|
|
let webnovelAllGraphData = null; |
|
|
|
|
|
async function viewGraphRAGVisualization(fileId, fileName) { |
|
|
const modal = document.getElementById('graphRAGVisualizationModal'); |
|
|
const title = document.getElementById('graphRAGVisualizationModalTitle'); |
|
|
const content = document.getElementById('graphRAGVisualizationContent'); |
|
|
|
|
|
title.textContent = `์บ๋ฆญํฐ ๊ด๊ณ๋ ์๊ฐํ - ${fileName}`; |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">ํ์ฐจ์ ๋
ธ๋ ํ์
์ ์ ํํ์ฌ ๊ทธ๋ํ๋ฅผ ํ์ธํ์ธ์.</div>'; |
|
|
modal.classList.add('active'); |
|
|
|
|
|
|
|
|
if (webnovelGraphNetwork) { |
|
|
webnovelGraphNetwork.destroy(); |
|
|
webnovelGraphNetwork = null; |
|
|
} |
|
|
|
|
|
|
|
|
document.getElementById('showCharacters').checked = false; |
|
|
document.getElementById('showLocations').checked = false; |
|
|
document.getElementById('showEvents').checked = false; |
|
|
|
|
|
try { |
|
|
const response = await fetch(`/api/files/${fileId}/graph`, { |
|
|
credentials: 'include' |
|
|
}); |
|
|
if (!response.ok) throw new Error('GraphRAG ๋ฐ์ดํฐ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค.'); |
|
|
|
|
|
const data = await response.json(); |
|
|
webnovelAllGraphData = data; |
|
|
|
|
|
|
|
|
const episodeFilterList = document.getElementById('episodeFilterList'); |
|
|
episodeFilterList.innerHTML = ''; |
|
|
const episodeFilterAll = document.getElementById('episodeFilterAll'); |
|
|
episodeFilterAll.checked = false; |
|
|
|
|
|
if (data.episodes && data.episodes.length > 0) { |
|
|
const sortedEpisodes = sortEpisodesByNumber(data.episodes); |
|
|
sortedEpisodes.forEach(episode => { |
|
|
const label = document.createElement('label'); |
|
|
label.style.cssText = 'font-size: 13px; cursor: pointer; padding: 6px 12px; border-radius: 4px; display: flex; align-items: center; transition: background 0.2s;'; |
|
|
label.onmouseover = function() { this.style.background = 'var(--bg-secondary)'; }; |
|
|
label.onmouseout = function() { this.style.background = 'transparent'; }; |
|
|
|
|
|
const checkbox = document.createElement('input'); |
|
|
checkbox.type = 'checkbox'; |
|
|
checkbox.value = episode; |
|
|
checkbox.id = `episodeFilter_${episode.replace(/[^a-zA-Z0-9]/g, '_')}`; |
|
|
checkbox.onchange = handleWebnovelIndividualEpisodeChange; |
|
|
checkbox.style.marginRight = '8px'; |
|
|
checkbox.style.cursor = 'pointer'; |
|
|
|
|
|
const span = document.createElement('span'); |
|
|
span.textContent = episode; |
|
|
span.style.flex = '1'; |
|
|
|
|
|
label.appendChild(checkbox); |
|
|
label.appendChild(span); |
|
|
episodeFilterList.appendChild(label); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
updateWebnovelEpisodeFilterButtonText(); |
|
|
|
|
|
|
|
|
|
|
|
} catch (error) { |
|
|
console.error('GraphRAG ๊ทธ๋ํ ๋ก๋ ์ค๋ฅ:', error); |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: #ea4335; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๊ทธ๋ํ๋ฅผ ๋ถ๋ฌ์ค๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.</div>'; |
|
|
} |
|
|
} |
|
|
|
|
|
function createWebnovelGraphVisualization(data, episodeFilter = 'all') { |
|
|
const content = document.getElementById('graphRAGVisualizationContent'); |
|
|
|
|
|
|
|
|
if (!episodeFilter || (Array.isArray(episodeFilter) && episodeFilter.length === 0)) { |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">ํ์ฐจ๋ฅผ ์ ํํ์ฌ ๊ทธ๋ํ๋ฅผ ํ์ธํ์ธ์.</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
const showCharacters = document.getElementById('showCharacters').checked; |
|
|
const showLocations = document.getElementById('showLocations').checked; |
|
|
const showEvents = document.getElementById('showEvents').checked; |
|
|
|
|
|
|
|
|
if (!showCharacters && !showLocations && !showEvents) { |
|
|
content.innerHTML = '<div style="text-align: center; padding: 24px; color: var(--text-secondary); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">๋
ธ๋ ํ์
(์ธ๋ฌผ, ์ฅ์, ์ฌ๊ฑด)์ ํ๋ ์ด์ ์ ํํ์ฌ ๊ทธ๋ํ๋ฅผ ํ์ธํ์ธ์.</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
content.innerHTML = ''; |
|
|
|
|
|
|
|
|
const nodes = new vis.DataSet([]); |
|
|
const edges = new vis.DataSet([]); |
|
|
|
|
|
const nodeMap = new Map(); |
|
|
let nodeIdCounter = 1; |
|
|
|
|
|
|
|
|
const episodes = episodeFilter === 'all' |
|
|
? (data.episodes || []) |
|
|
: (Array.isArray(episodeFilter) ? episodeFilter : [episodeFilter]); |
|
|
|
|
|
|
|
|
episodes.forEach(episode => { |
|
|
const entities = data.entities_by_episode?.[episode] || {}; |
|
|
|
|
|
|
|
|
if (showCharacters && entities.characters) { |
|
|
entities.characters.forEach(char => { |
|
|
const nodeId = `char_${char.entity_name}`; |
|
|
if (!nodeMap.has(nodeId)) { |
|
|
const id = nodeIdCounter++; |
|
|
nodeMap.set(nodeId, id); |
|
|
nodes.add({ |
|
|
id: id, |
|
|
label: char.entity_name, |
|
|
title: `์ธ๋ฌผ: ${char.entity_name}\n์ญํ : ${char.role || '์์'}\n์ค๋ช
: ${char.description || '์์'}`, |
|
|
color: { |
|
|
background: '#4285f4', |
|
|
border: '#1967d2', |
|
|
highlight: { background: '#1a73e8', border: '#1557b0' } |
|
|
}, |
|
|
shape: 'ellipse', |
|
|
font: { size: 14, face: 'Inter' }, |
|
|
size: 20 |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
if (showLocations && entities.locations) { |
|
|
entities.locations.forEach(loc => { |
|
|
const nodeId = `loc_${loc.entity_name}`; |
|
|
if (!nodeMap.has(nodeId)) { |
|
|
const id = nodeIdCounter++; |
|
|
nodeMap.set(nodeId, id); |
|
|
nodes.add({ |
|
|
id: id, |
|
|
label: loc.entity_name, |
|
|
title: `์ฅ์: ${loc.entity_name}\n์ ํ: ${loc.category || '์์'}\n์ค๋ช
: ${loc.description || '์์'}`, |
|
|
color: { |
|
|
background: '#34a853', |
|
|
border: '#137333', |
|
|
highlight: { background: '#2e7d32', border: '#1b5e20' } |
|
|
}, |
|
|
shape: 'box', |
|
|
font: { size: 14, face: 'Inter' }, |
|
|
size: 20 |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
if (showEvents) { |
|
|
episodes.forEach(episode => { |
|
|
const events = data.events_by_episode?.[episode] || []; |
|
|
events.forEach(event => { |
|
|
const eventName = event.event_name || `์ฌ๊ฑด_${episode}_${events.indexOf(event)}`; |
|
|
const nodeId = `event_${eventName}`; |
|
|
if (!nodeMap.has(nodeId)) { |
|
|
const id = nodeIdCounter++; |
|
|
nodeMap.set(nodeId, id); |
|
|
nodes.add({ |
|
|
id: id, |
|
|
label: eventName, |
|
|
title: `์ฌ๊ฑด: ${eventName}\n์ค๋ช
: ${event.description || '์์'}\n๊ด๋ จ ์ธ๋ฌผ: ${event.participants ? event.participants.join(', ') : '์์'}\n์ฅ์: ${event.location || '์์'}\n์ค์๋: ${event.significance || '์์'}`, |
|
|
color: { |
|
|
background: '#ff9800', |
|
|
border: '#f57c00', |
|
|
highlight: { background: '#fb8c00', border: '#e65100' } |
|
|
}, |
|
|
shape: 'diamond', |
|
|
font: { size: 13, face: 'Inter' }, |
|
|
size: 25 |
|
|
}); |
|
|
|
|
|
|
|
|
if (event.participants && Array.isArray(event.participants)) { |
|
|
event.participants.forEach(participant => { |
|
|
const participantNodeId = `char_${participant}`; |
|
|
const participantId = nodeMap.get(participantNodeId); |
|
|
if (participantId) { |
|
|
edges.add({ |
|
|
from: participantId, |
|
|
to: id, |
|
|
label: '์ฐธ์ฌ', |
|
|
title: `${participant}์ด(๊ฐ) ${eventName}์ ์ฐธ์ฌ`, |
|
|
color: { |
|
|
color: '#ff9800', |
|
|
highlight: '#f57c00' |
|
|
}, |
|
|
arrows: 'to', |
|
|
font: { size: 11, align: 'middle' }, |
|
|
dashes: true, |
|
|
smooth: { |
|
|
type: 'curvedCW', |
|
|
roundness: 0.3 |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
if (event.location) { |
|
|
const locationNodeId = `loc_${event.location}`; |
|
|
const locationId = nodeMap.get(locationNodeId); |
|
|
if (locationId) { |
|
|
edges.add({ |
|
|
from: locationId, |
|
|
to: id, |
|
|
label: '๋ฐ์', |
|
|
title: `${eventName}์ด(๊ฐ) ${event.location}์์ ๋ฐ์`, |
|
|
color: { |
|
|
color: '#ff9800', |
|
|
highlight: '#f57c00' |
|
|
}, |
|
|
arrows: 'to', |
|
|
font: { size: 11, align: 'middle' }, |
|
|
dashes: [5, 5], |
|
|
smooth: { |
|
|
type: 'curvedCW', |
|
|
roundness: 0.3 |
|
|
} |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
episodes.forEach(episode => { |
|
|
const relationships = data.relationships_by_episode?.[episode] || []; |
|
|
relationships.forEach(rel => { |
|
|
|
|
|
let sourceNodeId = null; |
|
|
let targetNodeId = null; |
|
|
|
|
|
|
|
|
if (nodeMap.has(`char_${rel.source}`)) { |
|
|
sourceNodeId = nodeMap.get(`char_${rel.source}`); |
|
|
} else if (nodeMap.has(`loc_${rel.source}`)) { |
|
|
sourceNodeId = nodeMap.get(`loc_${rel.source}`); |
|
|
} |
|
|
|
|
|
|
|
|
if (nodeMap.has(`char_${rel.target}`)) { |
|
|
targetNodeId = nodeMap.get(`char_${rel.target}`); |
|
|
} else if (nodeMap.has(`loc_${rel.target}`)) { |
|
|
targetNodeId = nodeMap.get(`loc_${rel.target}`); |
|
|
} |
|
|
|
|
|
if (sourceNodeId && targetNodeId) { |
|
|
edges.add({ |
|
|
from: sourceNodeId, |
|
|
to: targetNodeId, |
|
|
label: rel.relationship_type || '', |
|
|
title: `๊ด๊ณ: ${rel.relationship_type || '์์'}\n์ค๋ช
: ${rel.description || '์์'}${rel.event ? `\n๊ด๋ จ ์ฌ๊ฑด: ${rel.event}` : ''}`, |
|
|
color: { |
|
|
color: '#ea4335', |
|
|
highlight: '#c5221f' |
|
|
}, |
|
|
arrows: 'to', |
|
|
font: { size: 12, align: 'middle' }, |
|
|
smooth: { |
|
|
type: 'curvedCW', |
|
|
roundness: 0.2 |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
const container = document.createElement('div'); |
|
|
container.id = 'webnovelGraphNetworkContainer'; |
|
|
container.style.width = '100%'; |
|
|
container.style.height = '100%'; |
|
|
content.appendChild(container); |
|
|
|
|
|
const graphData = { |
|
|
nodes: nodes, |
|
|
edges: edges |
|
|
}; |
|
|
|
|
|
const options = { |
|
|
nodes: { |
|
|
borderWidth: 2, |
|
|
shadow: true, |
|
|
font: { |
|
|
size: 14, |
|
|
face: 'Inter' |
|
|
} |
|
|
}, |
|
|
edges: { |
|
|
width: 2, |
|
|
shadow: true, |
|
|
font: { |
|
|
size: 12, |
|
|
align: 'middle' |
|
|
}, |
|
|
arrows: { |
|
|
to: { |
|
|
enabled: true, |
|
|
scaleFactor: 0.8 |
|
|
} |
|
|
} |
|
|
}, |
|
|
physics: { |
|
|
enabled: true, |
|
|
stabilization: { |
|
|
enabled: true, |
|
|
iterations: 200 |
|
|
}, |
|
|
barnesHut: { |
|
|
gravitationalConstant: -2000, |
|
|
centralGravity: 0.1, |
|
|
springLength: 200, |
|
|
springConstant: 0.04, |
|
|
damping: 0.09 |
|
|
} |
|
|
}, |
|
|
interaction: { |
|
|
hover: true, |
|
|
tooltipDelay: 200, |
|
|
zoomView: true, |
|
|
dragView: true |
|
|
}, |
|
|
layout: { |
|
|
improvedLayout: true |
|
|
} |
|
|
}; |
|
|
|
|
|
webnovelGraphNetwork = new vis.Network(container, graphData, options); |
|
|
|
|
|
|
|
|
webnovelGraphNetwork.on('click', function(params) { |
|
|
if (params.nodes.length > 0) { |
|
|
const nodeId = params.nodes[0]; |
|
|
const node = nodes.get(nodeId); |
|
|
if (node) { |
|
|
console.log('์ ํ๋ ๋
ธ๋:', node); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
webnovelGraphNetwork.on('stabilizationEnd', function() { |
|
|
try { |
|
|
webnovelGraphNetwork.fit({ |
|
|
animation: { |
|
|
duration: 500, |
|
|
easingFunction: 'easeInOutQuad' |
|
|
} |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('์๋ fit() ์ค๋ฅ:', error); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (webnovelGraphNetwork && nodes.length() > 0) { |
|
|
try { |
|
|
webnovelGraphNetwork.fit({ |
|
|
animation: { |
|
|
duration: 500, |
|
|
easingFunction: 'easeInOutQuad' |
|
|
} |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error('์ง์ฐ fit() ์ค๋ฅ:', error); |
|
|
} |
|
|
} |
|
|
}, 500); |
|
|
} |
|
|
|
|
|
|
|
|
function toggleWebnovelEpisodeFilter() { |
|
|
const dropdown = document.getElementById('episodeFilterDropdown'); |
|
|
const icon = document.getElementById('episodeFilterToggleIcon'); |
|
|
const isVisible = dropdown.style.display !== 'none'; |
|
|
|
|
|
if (isVisible) { |
|
|
dropdown.style.display = 'none'; |
|
|
icon.style.transform = 'rotate(0deg)'; |
|
|
} else { |
|
|
dropdown.style.display = 'block'; |
|
|
icon.style.transform = 'rotate(180deg)'; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('click', function(event) { |
|
|
const dropdown = document.getElementById('episodeFilterDropdown'); |
|
|
const toggle = document.getElementById('episodeFilterToggle'); |
|
|
|
|
|
if (dropdown && toggle && !dropdown.contains(event.target) && !toggle.contains(event.target)) { |
|
|
dropdown.style.display = 'none'; |
|
|
const icon = document.getElementById('episodeFilterToggleIcon'); |
|
|
if (icon) { |
|
|
icon.style.transform = 'rotate(0deg)'; |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
function updateWebnovelEpisodeFilterButtonText() { |
|
|
const toggle = document.getElementById('episodeFilterToggle'); |
|
|
const episodeFilterAll = document.getElementById('episodeFilterAll'); |
|
|
const episodeFilterList = document.getElementById('episodeFilterList'); |
|
|
|
|
|
if (!toggle || !episodeFilterList) return; |
|
|
|
|
|
let selectedCount = 0; |
|
|
let buttonText = 'ํ์ฐจ ํํฐ'; |
|
|
|
|
|
if (episodeFilterAll && episodeFilterAll.checked) { |
|
|
buttonText = 'ํ์ฐจ ํํฐ (์ ์ฒด)'; |
|
|
} else { |
|
|
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked'); |
|
|
selectedCount = checkboxes.length; |
|
|
if (selectedCount > 0) { |
|
|
buttonText = `ํ์ฐจ ํํฐ (${selectedCount}๊ฐ ์ ํ)`; |
|
|
} |
|
|
} |
|
|
|
|
|
toggle.querySelector('span:first-child').textContent = buttonText; |
|
|
} |
|
|
|
|
|
|
|
|
function handleWebnovelEpisodeFilterAllChange() { |
|
|
const episodeFilterAll = document.getElementById('episodeFilterAll'); |
|
|
const episodeFilterList = document.getElementById('episodeFilterList'); |
|
|
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]'); |
|
|
|
|
|
|
|
|
if (episodeFilterAll.checked) { |
|
|
checkboxes.forEach(checkbox => { |
|
|
checkbox.checked = false; |
|
|
}); |
|
|
} |
|
|
|
|
|
updateWebnovelEpisodeFilterButtonText(); |
|
|
updateGraphVisualization(); |
|
|
} |
|
|
|
|
|
|
|
|
function handleWebnovelIndividualEpisodeChange() { |
|
|
const episodeFilterAll = document.getElementById('episodeFilterAll'); |
|
|
const episodeFilterList = document.getElementById('episodeFilterList'); |
|
|
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]'); |
|
|
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length; |
|
|
|
|
|
|
|
|
if (checkedCount > 0) { |
|
|
episodeFilterAll.checked = false; |
|
|
} |
|
|
|
|
|
updateWebnovelEpisodeFilterButtonText(); |
|
|
updateGraphVisualization(); |
|
|
} |
|
|
|
|
|
function updateGraphVisualization() { |
|
|
if (!webnovelAllGraphData) return; |
|
|
|
|
|
|
|
|
const episodeFilterAll = document.getElementById('episodeFilterAll'); |
|
|
let selectedEpisodes = []; |
|
|
|
|
|
if (episodeFilterAll && episodeFilterAll.checked) { |
|
|
|
|
|
selectedEpisodes = 'all'; |
|
|
} else { |
|
|
|
|
|
const episodeFilterList = document.getElementById('episodeFilterList'); |
|
|
const checkboxes = episodeFilterList.querySelectorAll('input[type="checkbox"]:checked'); |
|
|
selectedEpisodes = Array.from(checkboxes).map(cb => cb.value); |
|
|
} |
|
|
|
|
|
|
|
|
if (webnovelGraphNetwork) { |
|
|
webnovelGraphNetwork.destroy(); |
|
|
webnovelGraphNetwork = null; |
|
|
} |
|
|
|
|
|
|
|
|
createWebnovelGraphVisualization(webnovelAllGraphData, selectedEpisodes); |
|
|
} |
|
|
|
|
|
function resetGraphView() { |
|
|
if (!webnovelGraphNetwork) { |
|
|
console.warn('๊ทธ๋ํ ๋คํธ์ํฌ๊ฐ ์์ง ์์ฑ๋์ง ์์์ต๋๋ค.'); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
|
|
|
if (typeof webnovelGraphNetwork.fit === 'function') { |
|
|
|
|
|
webnovelGraphNetwork.fit({ |
|
|
animation: { |
|
|
duration: 1000, |
|
|
easingFunction: 'easeInOutQuad' |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
|
|
|
console.warn('fit() ๋ฉ์๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('๋ทฐ ๋ฆฌ์
์ค๋ฅ:', error); |
|
|
|
|
|
try { |
|
|
if (typeof webnovelGraphNetwork.fit === 'function') { |
|
|
webnovelGraphNetwork.fit(); |
|
|
} |
|
|
} catch (e) { |
|
|
console.error('๋ทฐ ๋ฆฌ์
์คํจ:', e); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
function closeGraphRAGVisualizationModal() { |
|
|
document.getElementById('graphRAGVisualizationModal').classList.remove('active'); |
|
|
if (webnovelGraphNetwork) { |
|
|
webnovelGraphNetwork.destroy(); |
|
|
webnovelGraphNetwork = null; |
|
|
} |
|
|
webnovelGraphData = null; |
|
|
webnovelAllGraphData = null; |
|
|
} |
|
|
|
|
|
document.getElementById('graphRAGVisualizationModal').addEventListener('click', function(e) { |
|
|
if (e.target === this) { |
|
|
closeGraphRAGVisualizationModal(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
window.addEventListener('load', async () => { |
|
|
await loadWebnovelModelFilter(); |
|
|
await loadWebnovels(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|
|
|
|