gopherbook/app/gopherbook/templates/index.html
riomoo 7495927c7d
feat: added bookmarks
feat: seperate html template

patch: fixed admin panel

feat: added image panning

- Panning/Dragging to view images better when zoomed in

patch: library fix

- library now is below admin panel
2026-01-06 01:13:29 -05:00

1963 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gopherbook</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #1b1e2c;
color: #bfbcb7;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background: #395E62;
border-bottom: 1px solid #314C52;
padding: 20px 0;
margin-bottom: 30px;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
color: #1b1e2c;
font-size: 24px;
}
.auth-section {
background: #1b1e2c;
border: 1px solid #446B6E;
border-radius: 6px;
padding: 30px;
max-width: 400px;
margin: 100px auto;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #bfbcb7;
font-weight: 500;
}
input[type="text"],
input[type="password"],
input[type="file"],
select {
width: 100%;
padding: 10px 12px;
background: #1b1e2c;
border: 1px solid #446B6E;
border-radius: 6px;
color: #bfbcb7;
font-size: 14px;
}
input[type="text"]:focus,
input[type="password"]:focus,
select:focus {
outline: none;
border-color: #446B6E;
}
button {
width: 100%;
padding: 10px 16px;
background: #395E62;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #446B6E;
}
.secondary-btn {
background: #314C52;
margin-top: 10px;
}
.secondary-btn:hover {
background: #446B6E;
}
.upload-section {
background: #1b1e2c;
border: 1px solid #446B6E;
border-radius: 6px;
padding: 24px;
margin-bottom: 30px;
}
.filter-section {
background: #1b1e2c;
border: 1px solid #446B6E;
border-radius: 6px;
padding: 20px;
margin-bottom: 20px;
}
.filter-controls {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.tag-filter {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #1b1e2c;
border: 1px solid #bfbcb7;
border-radius: 16px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.tag-filter:hover {
border-color: #395E62;
}
.tag-filter.active {
background: #446B6E;
border-color: #446B6E;
}
.comics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.comic-card {
background: #1b1e2c;
border: 1px solid #395E62;
border-radius: 6px;
overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
cursor: pointer;
}
.comic-card:hover {
transform: translateY(-2px);
border-color: #395E62;
}
.comic-cover-container {
width: 100%;
height: 280px;
background: #1b1e2c;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.comic-cover {
width: 100%;
height: 100%;
object-fit: cover;
background: #1b1e2c;
}
.comic-cover-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #858380;
font-size: 14px;
background: #1b1e2c;
border-bottom: 1px solid #A34346;
}
.comic-info {
padding: 16px;
}
.comic-title {
font-weight: 600;
color: #446B6E;
margin-bottom: 8px;
font-size: 14px;
}
.comic-meta {
font-size: 12px;
color: #8b949e;
line-height: 1.6;
margin-bottom: 8px;
}
.comic-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.comic-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
color: white;
}
.comic-artist {
display: inline-block;
background: #1f6feb;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
}
.unorganized {
background: #8b949e;
}
.hidden {
display: none;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #30363d;
}
.tab {
padding: 10px 20px;
background: none;
border: none;
color: #8b949e;
cursor: pointer;
border-bottom: 2px solid transparent;
width: auto;
}
.tab.active {
color: #395E62;
border-bottom-color: #395E62;
}
.message {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
}
.success {
background: #446B6E;
color: white;
}
.error {
background: #A55354;
color: white;
}
#readerModal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
}
#readerModal.active {
display: flex;
flex-direction: column;
}
.reader-header {
background: #1b1e2c;
border-bottom: 1px solid #395E62;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.reader-title {
color: #395E62;
font-weight: 600;
}
.reader-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.bookmark-indicator {
color: #f0883e;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.bookmark-badge {
position: absolute;
top: 8px;
right: 8px;
background: #f0883e;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10;
}
.bookmark-list {
position: absolute;
bottom: 70px;
left: 20px;
background: rgba(22, 27, 34, 0.95);
border: 1px solid #30363d;
border-radius: 6px;
padding: 12px;
max-height: 300px;
overflow-y: auto;
min-width: 200px;
z-index: 100;
}
.bookmark-list-title {
color: #f0883e;
font-weight: 600;
margin-bottom: 8px;
font-size: 14px;
}
.bookmark-list-item {
padding: 6px 8px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 4px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.bookmark-list-item:hover {
background: #30363d;
}
.bookmark-list-item.current {
border-color: #f0883e;
background: rgba(240, 136, 62, 0.1);
}
.bookmark-delete {
color: #da3633;
cursor: pointer;
padding: 0 4px;
font-size: 16px;
}
.bookmark-delete:hover {
color: #ff5555;
}
.reader-btn {
background: #21262d;
color: #c9d1d9;
border: 1px solid #30363d;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
width: auto;
}
.reader-btn:hover {
background: #30363d;
}
.reader-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-input {
width: 80px;
text-align: center;
padding: 6px;
background: #0d1117;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
}
.reader-content {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
}
.image-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
cursor: grab;
}
.image-container.panning {
cursor: grabbing;
}
#comicImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center center;
transition: transform 0.1s;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
user-select: none;
-webkit-user-drag: none;
}
.zoom-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
background: rgba(22, 27, 34, 0.95);
padding: 8px;
border-radius: 6px;
border: 1px solid #30363d;
}
.zoom-btn {
width: 40px;
height: 40px;
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-btn:hover {
background: #30363d;
}
#tagModal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
align-items: center;
justify-content: center;
}
#tagModal.active {
display: flex;
}
.modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 24px;
max-width: 500px;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
color: #58a6ff;
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #8b949e;
font-size: 24px;
cursor: pointer;
width: auto;
padding: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 16px;
font-size: 13px;
color: white;
cursor: pointer;
}
.tag-item .remove {
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.add-tag-form {
display: flex;
gap: 8px;
}
.add-tag-input {
flex: 1;
}
.color-picker {
width: 60px;
height: 38px;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
}
.available-tags {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #30363d;
}
.available-tags-title {
font-size: 14px;
color: #8b949e;
margin-bottom: 12px;
}
.context-menu {
position: absolute;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 0;
min-width: 150px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
z-index: 3000;
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
color: #c9d1d9;
font-size: 14px;
transition: background 0.2s;
}
.context-menu-item:hover {
background: #21262d;
}
#passwordModal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 2500;
align-items: center;
justify-content: center;
}
#passwordModal.active {
display: flex;
}
.password-modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 30px;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.password-modal-header {
margin-bottom: 20px;
}
.password-modal-title {
color: #58a6ff;
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.password-modal-subtitle {
color: #8b949e;
font-size: 14px;
}
.password-input-group {
margin-bottom: 20px;
}
.password-input-group label {
display: block;
margin-bottom: 8px;
color: #c9d1d9;
font-weight: 500;
}
.password-input-group input {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #c9d1d9;
font-size: 14px;
}
.password-input-group input:focus {
outline: none;
border-color: #58a6ff;
}
.password-modal-buttons {
display: flex;
gap: 10px;
}
.password-modal-buttons button {
flex: 1;
}
.password-error {
background: #da3633;
color: white;
padding: 10px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 13px;
}
</style>
</head>
<body>
<header>
<div class="header-content">
<h1>Gopherbook</h1>
<div id="userInfo" class="hidden">
<button onclick="logout()" class="secondary-btn" style="width: auto; padding: 8px 16px;">Logout</button>
</div>
</div>
</header>
<div class="container">
<div id="authSection" class="auth-section">
<div id="authMessage" class="message hidden"></div>
<div class="tabs">
<button class="tab active" onclick="showTab('login')">Login</button>
<button class="tab" onclick="showTab('register')">Register</button>
</div>
<div id="loginForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="loginUsername" placeholder="Enter username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPassword" placeholder="Enter password">
</div>
<button onclick="login()">Login</button>
</div>
<div id="registerForm" class="hidden">
<div class="form-group">
<label>Username</label>
<input type="text" id="regUsername" placeholder="Choose username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="regPassword" placeholder="Choose password">
</div>
<button onclick="register()">Register</button>
</div>
</div>
<div id="mainSection" class="hidden">
<div id="message"></div>
<div class="upload-section">
<h2 style="margin-bottom: 16px; color: #c9d1d9;">Upload Comic</h2>
<div class="form-group">
<label>Select File (CBZ)</label>
<input type="file" id="fileUpload" accept=".cbz">
</div>
<button onclick="uploadComic()">Upload</button>
</div>
<div class="filter-section">
<h3 style="margin-bottom: 12px; color: #c9d1d9; font-size: 16px;">Filter by Tags</h3>
<div class="filter-controls">
<div style="position: relative; width: 300px;">
<input type="text" id="tagSearch" placeholder="Search tags..." style="width: 100%; padding-right: 30px;">
<span style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #8b949e;">🔍</span>
</div>
<div id="tagSuggestions" class="context-menu hidden" style="width: 300px; max-height: 200px; overflow-y: auto;"></div>
<div id="selectedTagFilters" class="filter-controls" style="flex-wrap: wrap; gap: 8px; margin-top: 10px;"></div>
</div>
</div>
<div id="adminPanel" class="upload-section hidden">
<h2>Admin Panel</h2>
<div class="form-group">
<label>Registration: <span id="regStatus">Enabled</span></label>
<button onclick="toggleRegistration()" class="secondary-btn">Toggle</button>
</div>
<div class="form-group">
<label>Delete Comic ID:</label>
<input type="text" id="deleteId" placeholder="Enter comic ID">
<button onclick="deleteComic()" class="secondary-btn">Delete</button>
</div>
</div>
<h2 style="margin-bottom: 20px; color: #c9d1d9;">Library</h2>
<div id="comicsGrid" class="comics-grid"></div>
</div>
</div>
<div id="readerModal">
<div class="reader-header">
<div class="reader-title" id="readerTitle">Loading...</div>
<div class="reader-controls">
<button class="reader-btn" onclick="prevPage()" id="prevBtn">← Previous</button>
<input type="number" class="page-input" id="pageInput" min="1" onchange="goToPage()">
<span style="color: #8b949e;"> / <span id="totalPages">0</span></span>
<button class="reader-btn" onclick="nextPage()" id="nextBtn">Next →</button>
<button class="reader-btn" onclick="toggleFitMode()">Fit: <span id="fitMode">Width</span></button>
<div class="bookmark-indicator" id="bookmarkIndicator" style="display: none;">
<span>📖</span>
<span id="bookmarkText">Bookmarked</span>
</div>
<button class="reader-btn" onclick="toggleBookmark()" id="bookmarkBtn">📖 Bookmark Page</button>
<button class="reader-btn" onclick="toggleBookmarkList()" id="bookmarkListBtn">📚 Bookmarks (<span id="bookmarkCount">0</span>)</button>
<button class="reader-btn" onclick="closeReader()">Close</button>
</div>
</div>
<div class="reader-content" id="readerContent">
<div id="bookmarkList" class="bookmark-list" style="display: none;">
<div class="bookmark-list-title">Bookmarked Pages</div>
<div id="bookmarkListItems"></div>
</div>
<div class="image-container" id="imageContainer">
<img id="comicImage" alt="Comic page">
</div>
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomOut()"></button>
<button class="zoom-btn" onclick="resetZoom()"></button>
<button class="zoom-btn" onclick="zoomIn()">+</button>
</div>
</div>
</div>
<div id="tagModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Manage Tags</h3>
<button class="close-btn" onclick="closeTagModal()">×</button>
</div>
<div class="tag-list" id="currentTags"></div>
<div class="available-tags">
<div class="available-tags-title">Available Tags (click to add):</div>
<div class="tag-list" id="availableTags"></div>
</div>
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #30363d;">
<h4 style="font-size: 14px; color: #8b949e; margin-bottom: 12px;">Create New Tag</h4>
<div class="add-tag-form">
<input type="text" id="newTagName" class="add-tag-input" placeholder="Tag name">
<input type="color" id="newTagColor" class="color-picker" value="#1f6feb">
<button onclick="createTag()" style="width: auto; padding: 8px 16px;">Add</button>
</div>
</div>
</div>
</div>
<div id="passwordModal">
<div class="password-modal-content">
<div class="password-modal-header">
<div class="password-modal-title">🔒 Password Required</div>
<div class="password-modal-subtitle" id="passwordComicTitle">This comic is encrypted</div>
</div>
<div id="passwordError" class="password-error hidden"></div>
<div class="password-input-group">
<label>Enter Password</label>
<input type="password" id="passwordInput" placeholder="Enter password">
</div>
<div class="password-modal-buttons">
<button onclick="cancelPassword()" class="secondary-btn">Cancel</button>
<button onclick="submitPassword()">Unlock</button>
</div>
</div>
</div>
<div id="contextMenu" class="context-menu hidden"></div>
<script>
let comics = [];
let allTags = [];
let currentComic = null;
let currentPage = 0;
let totalPages = 0;
let zoomLevel = 1;
let fitMode = 'width';
let selectedTags = new Set();
let managingComic = null;
// Pan variables
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let panOffsetX = 0;
let panOffsetY = 0;
let currentPanX = 0;
let currentPanY = 0;
function showTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
if (tab === 'register' && window.registrationEnabled === false) {
showMessage('Registration disabled by admin', 'error', 'auth');
return;
}
if (tab === 'login') {
document.getElementById('loginForm').classList.remove('hidden');
document.getElementById('registerForm').classList.add('hidden');
} else {
document.getElementById('loginForm').classList.add('hidden');
document.getElementById('registerForm').classList.remove('hidden');
}
}
async function register() {
const username = document.getElementById('regUsername').value;
const password = document.getElementById('regPassword').value;
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (res.ok) {
showMessage('Registration successful! Please login.', 'success', 'auth');
document.getElementById('loginForm').classList.remove('hidden');
document.getElementById('registerForm').classList.add('hidden');
document.querySelectorAll('.tab')[0].classList.add('active');
document.querySelectorAll('.tab')[1].classList.remove('active');
} else {
const data = await res.text();
showMessage(data || 'Registration failed', 'error', 'auth');
}
} catch (err) {
showMessage('Network error: ' + err.message, 'error', 'auth');
}
}
async function login() {
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (res.ok) {
const data = await res.json();
window.isAdmin = data.is_admin || false;
window.registrationEnabled = true;
document.getElementById('authSection').classList.add('hidden');
document.getElementById('mainSection').classList.remove('hidden');
document.getElementById('userInfo').classList.remove('hidden');
if (window.isAdmin) {
try {
const adminRes = await fetch('/api/admin/toggle-registration');
if (adminRes.ok) {
const adminData = await adminRes.json();
window.registrationEnabled = adminData.enabled;
document.getElementById('regStatus').textContent = adminData.enabled ? 'Enabled' : 'Disabled';
}
} catch (err) {
console.error('Error fetching admin settings:', err);
}
}
await loadTags();
await loadComics();
} else {
const error = await res.text();
showMessage(error || 'Invalid credentials', 'error', 'auth');
}
} catch (err) {
showMessage('Network error: ' + err.message, 'error', 'auth');
}
}
async function logout() {
await fetch('/api/logout', {method: 'POST'});
location.reload();
}
async function uploadComic() {
const fileInput = document.getElementById('fileUpload');
const file = fileInput.files[0];
if (!file) {
showMessage('Please select a file', 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (res.ok) {
showMessage('Comic uploaded successfully!', 'success');
fileInput.value = '';
loadComics();
} else {
showMessage('Upload failed', 'error');
}
}
async function loadComics() {
const res = await fetch('/api/comics');
if (res.ok) {
comics = await res.json();
renderComics();
} else {
showMessage('Failed to load comics', 'error');
}
}
async function loadTags() {
const res = await fetch('/api/tags');
if (res.ok) {
allTags = await res.json();
renderTagFilters();
} else {
showMessage('Failed to load tags', 'error');
}
}
function renderTagFilters() {
var container = document.getElementById('selectedTagFilters');
container.innerHTML = '';
selectedTags.forEach(function(tagName) {
var tag = allTags.find(function(t) { return t.name === tagName; });
var color = tag ? tag.color : '#1f6feb';
var filter = document.createElement('div');
filter.className = 'tag-filter';
filter.style.background = color;
filter.innerHTML = tagName + ' <span class="remove" style="cursor: pointer; margin-left: 8px;">x</span>';
filter.querySelector('.remove').onclick = function(e) {
e.stopPropagation();
selectedTags.delete(tagName);
renderTagFilters();
renderComics();
};
container.appendChild(filter);
});
if (selectedTags.size > 0) {
var clearBtn = document.createElement('button');
clearBtn.textContent = 'Clear Filters';
clearBtn.className = 'reader-btn';
clearBtn.style.width = 'auto';
clearBtn.onclick = function() {
selectedTags.clear();
renderTagFilters();
renderComics();
};
container.appendChild(clearBtn);
}
var searchInput = document.getElementById('tagSearch');
searchInput.oninput = function() {
renderTagSuggestions(searchInput.value.toLowerCase());
};
searchInput.onclick = function() {
renderTagSuggestions('');
};
}
function renderTagSuggestions(searchTerm) {
var container = document.getElementById('tagSuggestions');
container.innerHTML = '';
container.className = 'context-menu';
var availableTags = allTags.filter(function(tag) {
return !selectedTags.has(tag.name) &&
(searchTerm === '' || tag.name.toLowerCase().indexOf(searchTerm) !== -1);
});
if (availableTags.length === 0) {
container.className = 'context-menu hidden';
return;
}
availableTags.forEach(function(tag) {
var item = document.createElement('div');
item.className = 'context-menu-item';
item.style.background = tag.color;
item.textContent = tag.name + ' (' + tag.count + ')';
item.onclick = function() {
selectedTags.add(tag.name);
renderTagFilters();
renderComics();
document.getElementById('tagSearch').value = '';
container.className = 'context-menu hidden';
};
container.appendChild(item);
});
var searchInput = document.getElementById('tagSearch');
var rect = searchInput.getBoundingClientRect();
container.style.left = rect.left + 'px';
container.style.top = (rect.bottom + window.scrollY) + 'px';
}
document.addEventListener('click', function(e) {
var suggestions = document.getElementById('tagSuggestions');
if (!e.target.closest('#tagSearch') && !e.target.closest('#tagSuggestions')) {
suggestions.className = 'context-menu hidden';
}
});
function renderComics() {
if (window.isAdmin) {
document.getElementById('adminPanel').classList.remove('hidden');
document.getElementById('regStatus').textContent = window.registrationEnabled ? 'Enabled' : 'Disabled';
} else {
document.getElementById('adminPanel').classList.add('hidden');
}
const grid = document.getElementById('comicsGrid');
grid.innerHTML = '';
let filtered = comics;
if (selectedTags.size > 0) {
filtered = comics.filter(comic => {
return Array.from(selectedTags).every(tag =>
comic.tags && comic.tags.includes(tag)
);
});
}
filtered.forEach(comic => {
const card = document.createElement('div');
card.className = 'comic-card';
const coverContainer = document.createElement('div');
coverContainer.className = 'comic-cover-container';
const cover = document.createElement('img');
cover.className = 'comic-cover';
cover.src = '/api/cover/' + encodeURIComponent(comic.id);
cover.alt = comic.title || comic.filename;
const fallback = document.createElement('div');
fallback.className = 'comic-cover-fallback hidden';
fallback.textContent = 'COVER NOT AVAILABLE';
fallback.style.background = '#21262d';
if (comic.bookmarks && comic.bookmarks.length > 0) {
const badge = document.createElement('div');
badge.className = 'bookmark-badge';
badge.textContent = '📖 ' + comic.bookmarks.length + ' bookmark' + (comic.bookmarks.length > 1 ? 's' : '');
coverContainer.appendChild(badge);
}
cover.onerror = function() {
cover.classList.add('hidden');
fallback.classList.remove('hidden');
};
coverContainer.appendChild(cover);
coverContainer.appendChild(fallback);
card.appendChild(coverContainer);
const info = document.createElement('div');
info.className = 'comic-info';
const artistClass = comic.artist === 'Unknown' ? 'unorganized' : '';
let metaHTML = '';
if (comic.series) metaHTML += '<div>Series: ' + comic.series + '</div>';
if (comic.number) metaHTML += '<div>Issue: ' + comic.number + '</div>';
if (comic.year) metaHTML += '<div>Year: ' + comic.year + '</div>';
if (comic.page_count) metaHTML += '<div>Pages: ' + comic.page_count + '</div>';
let tagsHTML = '';
if (comic.tags && comic.tags.length > 0) {
tagsHTML = '<div class="comic-tags">';
comic.tags.forEach(tagName => {
const tag = allTags.find(t => t.name === tagName);
const color = tag ? tag.color : '#1f6feb';
tagsHTML += '<span class="comic-tag" style="background: ' + color + '">' + tagName + '</span>';
});
tagsHTML += '</div>';
}
info.innerHTML =
'<div class="comic-title">' + (comic.title || comic.filename) + '</div>' +
'<div class="comic-meta">' + metaHTML + '</div>' +
'<span class="comic-artist ' + artistClass + '">' + comic.artist + '</span>' +
tagsHTML;
card.appendChild(info);
card.onclick = function(e) {
if (e.target.classList.contains('comic-tag')) {
return;
}
if (e.target.classList.contains('bookmark-badge') && comic.bookmarks && comic.bookmarks.length > 0) {
openReaderAtBookmark(comic, comic.bookmarks[0]);
return;
}
openReader(comic);
};
card.oncontextmenu = function(e) {
e.preventDefault();
showContextMenu(e, comic);
};
grid.appendChild(card);
});
}
function showContextMenu(e, comic) {
const menu = document.getElementById('contextMenu');
menu.className = 'context-menu';
menu.innerHTML = '<div class="context-menu-item" onclick="openTagModal(\'' + comic.id + '\')">Manage Tags</div>';
if (comic.bookmarks && comic.bookmarks.length > 0) {
menu.innerHTML += '<div class="context-menu-item" onclick="openReaderAtBookmark(comics.find(c => c.id === \'' + comic.id + '\'), ' + comic.bookmarks[0] + ')">Go to First Bookmark (Page ' + (comic.bookmarks[0] + 1) + ')</div>';
}
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
document.addEventListener('click', function hideMenu() {
menu.className = 'context-menu hidden';
document.removeEventListener('click', hideMenu);
});
}
function openTagModal(comicId) {
managingComic = comics.find(c => c.id === comicId);
if (!managingComic) return;
document.getElementById('tagModal').classList.add('active');
renderCurrentTags();
renderAvailableTags();
}
function closeTagModal() {
document.getElementById('tagModal').classList.remove('active');
managingComic = null;
loadComics();
loadTags();
}
function renderCurrentTags() {
const container = document.getElementById('currentTags');
container.innerHTML = '';
if (!managingComic.tags || managingComic.tags.length === 0) {
container.innerHTML = '<div style="color: #8b949e; font-size: 13px;">No tags assigned</div>';
return;
}
managingComic.tags.forEach(tagName => {
const tag = allTags.find(t => t.name === tagName);
const color = tag ? tag.color : '#1f6feb';
const item = document.createElement('div');
item.className = 'tag-item';
item.style.background = color;
item.innerHTML = tagName + ' <span class="remove">×</span>';
item.querySelector('.remove').onclick = function(e) {
e.stopPropagation();
removeTag(tagName);
};
container.appendChild(item);
});
}
function renderAvailableTags() {
const container = document.getElementById('availableTags');
container.innerHTML = '';
const available = allTags.filter(tag =>
!managingComic.tags || !managingComic.tags.includes(tag.name)
);
if (available.length === 0) {
container.innerHTML = '<div style="color: #8b949e; font-size: 13px;">All tags assigned</div>';
return;
}
available.forEach(tag => {
const item = document.createElement('div');
item.className = 'tag-item';
item.style.background = tag.color;
item.textContent = tag.name;
item.onclick = function() {
addTagToComic(tag.name);
};
container.appendChild(item);
});
}
async function addTagToComic(tagName) {
const res = await fetch('/api/comic-tags/' + encodeURIComponent(managingComic.id), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({tag: tagName})
});
if (res.ok) {
const updated = await res.json();
managingComic = updated;
const idx = comics.findIndex(c => c.id === updated.id);
if (idx >= 0) comics[idx] = updated;
renderCurrentTags();
renderAvailableTags();
}
}
async function removeTag(tagName) {
const res = await fetch('/api/comic-tags/' + encodeURIComponent(managingComic.id) + '/' + encodeURIComponent(tagName), {
method: 'DELETE'
});
if (res.ok) {
const updated = await res.json();
managingComic = updated;
const idx = comics.findIndex(c => c.id === updated.id);
if (idx >= 0) comics[idx] = updated;
renderCurrentTags();
renderAvailableTags();
}
}
async function createTag() {
const name = document.getElementById('newTagName').value.trim();
const color = document.getElementById('newTagColor').value;
if (!name) {
showMessage('Please enter a tag name', 'error');
return;
}
const res = await fetch('/api/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, color})
});
if (res.ok) {
document.getElementById('newTagName').value = '';
document.getElementById('newTagColor').value = '#1f6feb';
loadTags();
renderAvailableTags();
showMessage('Tag created!', 'success');
} else {
showMessage('Failed to create tag', 'error');
}
}
// Initialize pan functionality
function initializePan() {
const container = document.getElementById('imageContainer');
const img = document.getElementById('comicImage');
container.addEventListener('mousedown', startPan);
container.addEventListener('mousemove', doPan);
container.addEventListener('mouseup', endPan);
container.addEventListener('mouseleave', endPan);
// Touch support for mobile
container.addEventListener('touchstart', handleTouchStart, {passive: false});
container.addEventListener('touchmove', handleTouchMove, {passive: false});
container.addEventListener('touchend', endPan);
}
function startPan(e) {
if (zoomLevel <= 1) return; // Only pan when zoomed in
isPanning = true;
const container = document.getElementById('imageContainer');
container.classList.add('panning');
panStartX = e.clientX - currentPanX;
panStartY = e.clientY - currentPanY;
e.preventDefault();
}
function doPan(e) {
if (!isPanning) return;
e.preventDefault();
currentPanX = e.clientX - panStartX;
currentPanY = e.clientY - panStartY;
updateImageTransform();
}
function endPan(e) {
if (isPanning) {
isPanning = false;
const container = document.getElementById('imageContainer');
container.classList.remove('panning');
}
}
function handleTouchStart(e) {
if (zoomLevel <= 1 || e.touches.length !== 1) return;
isPanning = true;
const container = document.getElementById('imageContainer');
container.classList.add('panning');
const touch = e.touches[0];
panStartX = touch.clientX - currentPanX;
panStartY = touch.clientY - currentPanY;
e.preventDefault();
}
function handleTouchMove(e) {
if (!isPanning || e.touches.length !== 1) return;
e.preventDefault();
const touch = e.touches[0];
currentPanX = touch.clientX - panStartX;
currentPanY = touch.clientY - panStartY;
updateImageTransform();
}
function updateImageTransform() {
const img = document.getElementById('comicImage');
img.style.transform = `translate(calc(-50% + ${currentPanX}px), calc(-50% + ${currentPanY}px)) scale(${zoomLevel})`;
}
async function openReader(comic) {
currentComic = comic;
currentPage = 0;
document.getElementById('readerTitle').textContent = comic.title || comic.filename;
document.getElementById('readerModal').classList.add('active');
if (comic.encrypted && !comic.has_password) {
await showPasswordModal(comic);
return;
}
const encodedId = encodeURIComponent(currentComic.id);
const url = '/api/pages/' + encodedId;
try {
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
if (data.needs_password) {
alert('Password required but not set. Please re-open the comic.');
closeReader();
return;
}
totalPages = data.page_count;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('pageInput').max = totalPages;
if (totalPages > 0) {
loadPage(0);
updateBookmarkUI();
} else {
showMessage('No pages found in comic', 'error');
}
} else {
const error = await res.text();
showMessage('Error loading comic: ' + error, 'error');
}
} catch (err) {
showMessage('Error: ' + err.message, 'error');
}
}
async function openReaderAtBookmark(comic, bookmarkPage) {
currentComic = comic;
currentPage = bookmarkPage || 0;
document.getElementById('readerTitle').textContent = comic.title || comic.filename;
document.getElementById('readerModal').classList.add('active');
if (comic.encrypted && !comic.has_password) {
await showPasswordModal(comic);
return;
}
const encodedId = encodeURIComponent(currentComic.id);
const url = '/api/pages/' + encodedId;
try {
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
if (data.needs_password) {
alert('Password required but not set. Please re-open the comic.');
closeReader();
return;
}
totalPages = data.page_count;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('pageInput').max = totalPages;
if (totalPages > 0) {
loadPage(currentPage);
updateBookmarkUI();
} else {
showMessage('No pages found in comic', 'error');
}
} else {
const error = await res.text();
showMessage('Error loading comic: ' + error, 'error');
}
} catch (err) {
showMessage('Error: ' + err.message, 'error');
}
}
function closeReader() {
document.getElementById('readerModal').classList.remove('active');
document.getElementById('bookmarkList').style.display = 'none';
currentComic = null;
resetZoom();
}
async function loadPage(pageNum) {
if (pageNum < 0 || pageNum >= totalPages) return;
currentPage = pageNum;
document.getElementById('pageInput').value = currentPage + 1;
const img = document.getElementById('comicImage');
const encodedId = encodeURIComponent(currentComic.id);
const imgUrl = '/api/comic/' + encodedId + '/page/' + currentPage;
img.src = imgUrl;
img.onerror = function() {
showMessage('Failed to load page ' + (currentPage + 1), 'error');
};
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = currentPage === totalPages - 1;
updateBookmarkUI();
resetZoom();
}
function updateBookmarkUI() {
if (!currentComic.bookmarks) {
currentComic.bookmarks = [];
}
const isBookmarked = currentComic.bookmarks.includes(currentPage);
const btn = document.getElementById('bookmarkBtn');
const indicator = document.getElementById('bookmarkIndicator');
const count = document.getElementById('bookmarkCount');
btn.textContent = isBookmarked ? '📖 Remove Bookmark' : '📖 Bookmark Page';
count.textContent = currentComic.bookmarks.length;
if (isBookmarked) {
indicator.style.display = 'flex';
document.getElementById('bookmarkText').textContent = 'Current page bookmarked';
} else {
indicator.style.display = 'none';
}
renderBookmarkList();
}
function toggleBookmarkList() {
const list = document.getElementById('bookmarkList');
list.style.display = list.style.display === 'none' ? 'block' : 'none';
}
function renderBookmarkList() {
const container = document.getElementById('bookmarkListItems');
container.innerHTML = '';
if (!currentComic.bookmarks || currentComic.bookmarks.length === 0) {
container.innerHTML = '<div style="color: #8b949e; font-size: 12px; padding: 8px;">No bookmarks</div>';
return;
}
currentComic.bookmarks.forEach(page => {
const item = document.createElement('div');
item.className = 'bookmark-list-item' + (page === currentPage ? ' current' : '');
const pageText = document.createElement('span');
pageText.textContent = 'Page ' + (page + 1);
pageText.style.flex = '1';
pageText.onclick = function() {
loadPage(page);
document.getElementById('bookmarkList').style.display = 'none';
};
const deleteBtn = document.createElement('span');
deleteBtn.className = 'bookmark-delete';
deleteBtn.textContent = '×';
deleteBtn.onclick = function(e) {
e.stopPropagation();
removeBookmark(page);
};
item.appendChild(pageText);
item.appendChild(deleteBtn);
container.appendChild(item);
});
}
async function toggleBookmark() {
const isBookmarked = currentComic.bookmarks && currentComic.bookmarks.includes(currentPage);
if (isBookmarked) {
await removeBookmark(currentPage);
} else {
await addBookmark(currentPage);
}
}
async function addBookmark(page) {
const res = await fetch('/api/bookmark/' + encodeURIComponent(currentComic.id), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({page: page})
});
if (res.ok) {
const data = await res.json();
currentComic.bookmarks = data.bookmarks;
const idx = comics.findIndex(c => c.id === currentComic.id);
if (idx >= 0) {
comics[idx].bookmarks = data.bookmarks;
}
updateBookmarkUI();
showMessage('Bookmark added!', 'success');
} else {
showMessage('Failed to add bookmark', 'error');
}
}
async function removeBookmark(page) {
const res = await fetch('/api/bookmark/' + encodeURIComponent(currentComic.id) + '/' + page, {
method: 'DELETE'
});
if (res.ok) {
const data = await res.json();
currentComic.bookmarks = data.bookmarks;
const idx = comics.findIndex(c => c.id === currentComic.id);
if (idx >= 0) {
comics[idx].bookmarks = data.bookmarks;
}
updateBookmarkUI();
showMessage('Bookmark removed!', 'success');
loadComics();
} else {
showMessage('Failed to remove bookmark', 'error');
}
}
function nextPage() {
if (currentPage < totalPages - 1) {
loadPage(currentPage + 1);
}
}
function prevPage() {
if (currentPage > 0) {
loadPage(currentPage - 1);
}
}
function goToPage() {
const pageNum = parseInt(document.getElementById('pageInput').value) - 1;
if (pageNum >= 0 && pageNum < totalPages) {
loadPage(pageNum);
}
}
function zoomIn() {
zoomLevel = Math.min(zoomLevel + 0.25, 5);
applyZoom();
}
function zoomOut() {
zoomLevel = Math.max(zoomLevel - 0.25, 0.25);
applyZoom();
}
function resetZoom() {
zoomLevel = 1;
currentPanX = 0;
currentPanY = 0;
applyZoom();
}
function applyZoom() {
updateImageTransform();
// Show/hide grab cursor based on zoom level
const container = document.getElementById('imageContainer');
if (zoomLevel > 1) {
container.style.cursor = 'grab';
} else {
container.style.cursor = 'default';
}
}
function toggleFitMode() {
const modes = ['width', 'height', 'page'];
const currentIndex = modes.indexOf(fitMode);
fitMode = modes[(currentIndex + 1) % modes.length];
const img = document.getElementById('comicImage');
img.style.maxWidth = '';
img.style.maxHeight = '';
img.style.width = '';
img.style.height = '';
if (fitMode === 'width') {
img.style.maxWidth = '100%';
img.style.height = 'auto';
} else if (fitMode === 'height') {
img.style.maxHeight = '100%';
img.style.width = 'auto';
} else {
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
}
document.getElementById('fitMode').textContent = fitMode.charAt(0).toUpperCase() + fitMode.slice(1);
}
document.addEventListener('keydown', function(e) {
if (document.getElementById('passwordModal').classList.contains('active')) {
if (e.key === 'Enter') {
submitPassword();
return;
}
if (e.key === 'Escape') {
cancelPassword();
return;
}
}
if (!currentComic) return;
if (e.key === 'ArrowRight' || e.key === 'd') nextPage();
if (e.key === 'ArrowLeft' || e.key === 'a') prevPage();
if (e.key === 'Escape') closeReader();
if (e.key === '+' || e.key === '=') zoomIn();
if (e.key === '-' || e.key === '_') zoomOut();
if (e.key === '0') resetZoom();
});
function showMessage(text, type, context = 'main') {
let msg;
if (context === 'auth') {
msg = document.getElementById('authMessage');
} else {
msg = document.getElementById('message');
}
msg.textContent = text;
msg.className = 'message ' + type;
setTimeout(function() { msg.className = 'message hidden'; }, 5000);
}
async function toggleRegistration() {
try {
const res = await fetch('/api/admin/toggle-registration', {method: 'POST'});
if (res.ok) {
const data = await res.json();
window.registrationEnabled = data.enabled;
document.getElementById('regStatus').textContent = data.enabled ? 'Enabled' : 'Disabled';
showMessage('Registration toggled!', 'success');
} else {
showMessage('Toggle failed: ' + (await res.text()), 'error');
}
} catch (err) {
showMessage('Network error: ' + err.message, 'error');
}
}
async function deleteComic() {
const id = document.getElementById('deleteId').value.trim();
if (!id) {
showMessage('Please enter a comic ID', 'error');
return;
}
if (!confirm('Delete comic ID: ' + id + '?')) return;
try {
const res = await fetch('/api/admin/delete-comic/' + encodeURIComponent(id), {method: 'DELETE'});
if (res.ok) {
loadComics();
showMessage('Comic deleted!', 'success');
document.getElementById('deleteId').value = '';
} else {
showMessage('Delete failed: ' + (await res.text()), 'error');
}
} catch (err) {
showMessage('Network error: ' + err.message, 'error');
}
}
let pendingPasswordComic = null;
async function showPasswordModal(comic) {
pendingPasswordComic = comic;
document.getElementById('passwordComicTitle').textContent = comic.title || comic.filename;
document.getElementById('passwordInput').value = '';
document.getElementById('passwordError').className = 'password-error hidden';
document.getElementById('passwordModal').classList.add('active');
document.getElementById('passwordInput').focus();
}
function cancelPassword() {
document.getElementById('passwordModal').classList.remove('active');
document.getElementById('readerModal').classList.remove('active');
pendingPasswordComic = null;
}
async function submitPassword() {
const pwd = document.getElementById('passwordInput').value;
if (!pwd) {
showPasswordError('Please enter a password');
return;
}
const res = await fetch("/api/set-password/" + encodeURIComponent(pendingPasswordComic.id), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: pwd})
});
if (!res.ok) {
showPasswordError('Invalid password. Please try again.');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
return;
}
document.getElementById('passwordModal').classList.remove('active');
await loadComics();
await loadTags();
currentComic = comics.find(c => c.id === pendingPasswordComic.id);
pendingPasswordComic = null;
continueOpenReader();
}
function showPasswordError(message) {
const errorDiv = document.getElementById('passwordError');
errorDiv.textContent = message;
errorDiv.className = 'password-error';
}
async function continueOpenReader() {
const encodedId = encodeURIComponent(currentComic.id);
const url = '/api/pages/' + encodedId;
try {
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
if (data.needs_password) {
alert('Password required but not set. Please re-open the comic.');
closeReader();
return;
}
totalPages = data.page_count;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('pageInput').max = totalPages;
if (totalPages > 0) {
loadPage(0);
} else {
showMessage('No pages found in comic', 'error');
}
} else {
const error = await res.text();
showMessage('Error loading comic: ' + error, 'error');
}
} catch (err) {
showMessage('Error: ' + err.message, 'error');
}
}
// Initialize pan when page loads
window.addEventListener('load', function() {
initializePan();
});
fetch('/api/user')
.then(function(res) {
if (res.ok) {
return res.json();
}
})
.then(function(data) {
if (data) {
window.isAdmin = data.is_admin || false;
if (window.isAdmin) {
fetch('/api/admin/toggle-registration')
.then(function(adminRes) {
if (adminRes.ok) return adminRes.json();
})
.then(function(adminData) {
if (adminData) {
window.registrationEnabled = adminData.enabled;
}
});
}
}
})
.catch(function(err) {
console.error('User fetch failed:', err);
});
fetch('/api/comics')
.then(function(res) {
if (res.ok) {
document.getElementById('authSection').classList.add('hidden');
document.getElementById('mainSection').classList.remove('hidden');
document.getElementById('userInfo').classList.remove('hidden');
loadTags().then(loadComics);
}
})
.catch(function(err) {
console.error('Initial comics fetch failed:', err);
});
</script>
</body>
</html>