implement post metadata import and views. implemented series paging and reader view.
This commit is contained in:
@@ -22,6 +22,8 @@
|
||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||
{% elif t.kind == 'series' %}
|
||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||
{% elif t.kind == 'fandom' %}
|
||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||
{% elif t.kind == 'rating' %}
|
||||
{{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
|
||||
{% else %}
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
</div>
|
||||
|
||||
<div id="modalTagEditor" class="tag-editor" data-image-id="">
|
||||
<div id="modalSeriesInfo" class="modal-series-info" style="display: none;">
|
||||
<div class="series-info-row">
|
||||
<span class="series-info-label">📚 Page</span>
|
||||
<input type="number" id="modalPageNumber" min="1" class="series-page-input">
|
||||
<span class="series-info-label">of</span>
|
||||
<a id="modalSeriesName" href="#" class="series-info-link"></a>
|
||||
<button id="updatePageBtn" class="btn-small" title="Update page number">Save</button>
|
||||
<span id="pageUpdateStatus" class="page-update-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modalTagList" class="tags"></div>
|
||||
<form id="modalTagForm" class="tag-form" autocomplete="off">
|
||||
<div class="tag-form-wrapper">
|
||||
|
||||
+158
-1
@@ -29,6 +29,60 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if post_metadata %}
|
||||
<!-- Post Info Header -->
|
||||
<div class="post-info-header">
|
||||
<div class="post-info-content">
|
||||
{% if post_metadata.title %}
|
||||
<h2 class="post-title">{{ post_metadata.title }}</h2>
|
||||
{% endif %}
|
||||
<div class="post-meta">
|
||||
{% if post_metadata.artist %}
|
||||
<span class="post-artist">{{ post_metadata.artist }}</span>
|
||||
{% endif %}
|
||||
{% if post_metadata.platform %}
|
||||
<span class="post-platform post-platform-{{ post_metadata.platform }}">{{ post_metadata.platform }}</span>
|
||||
{% endif %}
|
||||
{% if post_metadata.published_at %}
|
||||
<span class="post-date">{{ post_metadata.published_at.strftime('%B %d, %Y') }}</span>
|
||||
{% endif %}
|
||||
{% if post_metadata.attachment_count > 1 %}
|
||||
<span class="post-count">{{ post_metadata.attachment_count }} images</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if post_metadata.description %}
|
||||
<div class="post-description">
|
||||
{{ post_metadata.description | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if post_metadata.source_url %}
|
||||
<a href="{{ post_metadata.source_url }}" target="_blank" rel="noopener noreferrer" class="post-source-link">
|
||||
View original post
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if series_info %}
|
||||
<!-- Series Header -->
|
||||
<div class="series-header">
|
||||
<div class="series-header-content">
|
||||
<div class="series-header-left">
|
||||
<h2 class="series-title">{{ series_info.name }}</h2>
|
||||
<span id="seriesPageCount" class="series-page-count">{{ series_info.page_count }} pages</span>
|
||||
</div>
|
||||
<div class="series-header-actions">
|
||||
{% if series_info.page_count > 0 %}
|
||||
<a href="{{ url_for('main.read_series', tag_id=series_info.tag_id) }}" class="btn primary-btn">
|
||||
Read Series
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Gallery Grid with Date Sections -->
|
||||
<div id="galleryInfinite" class="gallery-infinite">
|
||||
{% for year_month_key, year_month_label, images in grouped_images %}
|
||||
@@ -90,6 +144,22 @@
|
||||
<p class="form-help">Common tags across selected images:</p>
|
||||
<div id="commonTagsList" class="tags"></div>
|
||||
</div>
|
||||
{% if series_info %}
|
||||
<div class="bulk-editor-section bulk-editor-series">
|
||||
<h4>📚 Add to Series</h4>
|
||||
<p class="form-help">Add selected images as pages to "{{ series_info.name }}"</p>
|
||||
<div class="series-editor-controls">
|
||||
<div class="series-editor-input">
|
||||
<label for="startPageInput">Starting page:</label>
|
||||
<input type="number" id="startPageInput" value="{{ series_info.page_count + 1 }}" min="1" class="form-input">
|
||||
</div>
|
||||
<button id="addToSeriesBtn" class="btn primary-btn" disabled>
|
||||
Add to Series
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesEditorFeedback" class="series-editor-feedback"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="bulk-editor-actions">
|
||||
<button id="clearSelectionBtn" class="btn secondary-btn">Clear Selection</button>
|
||||
</div>
|
||||
@@ -100,11 +170,98 @@
|
||||
window.galleryState = {
|
||||
cursor: {{ initial_cursor | tojson | safe if initial_cursor else 'null' }},
|
||||
hasMore: {{ 'true' if has_more else 'false' }},
|
||||
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }}
|
||||
activeTag: {{ active_tag | tojson | safe if active_tag else 'null' }},
|
||||
seriesInfo: {{ series_info | tojson | safe if series_info else 'null' }}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
|
||||
|
||||
{% if series_info %}
|
||||
<script>
|
||||
// Series Editor functionality (integrated into bulk editor panel)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const startPageInput = document.getElementById('startPageInput');
|
||||
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
|
||||
const feedbackEl = document.getElementById('seriesEditorFeedback');
|
||||
const seriesTagId = {{ series_info.tag_id }};
|
||||
|
||||
// Update button state based on selection
|
||||
function updateAddButtonState() {
|
||||
const selectedCount = window.selectedImages ? window.selectedImages.size : 0;
|
||||
addToSeriesBtn.disabled = selectedCount === 0;
|
||||
addToSeriesBtn.textContent = selectedCount > 0
|
||||
? `Add ${selectedCount} to Series`
|
||||
: 'Add to Series';
|
||||
}
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionChanged', updateAddButtonState);
|
||||
|
||||
// Add selected images to series
|
||||
addToSeriesBtn.addEventListener('click', async () => {
|
||||
if (!window.selectedImages || window.selectedImages.size === 0) return;
|
||||
|
||||
const imageIds = Array.from(window.selectedImages);
|
||||
const startPage = parseInt(startPageInput.value) || 1;
|
||||
|
||||
addToSeriesBtn.disabled = true;
|
||||
addToSeriesBtn.textContent = 'Adding...';
|
||||
feedbackEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/series/${seriesTagId}/pages/bulk-add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_ids: imageIds, start_page: startPage })
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.ok) {
|
||||
let msg = `Added ${data.added.length} image(s) to series.`;
|
||||
if (data.skipped.length > 0) {
|
||||
msg += ` Skipped ${data.skipped.length}.`;
|
||||
}
|
||||
feedbackEl.innerHTML = `<div class="feedback-success">${msg}</div>`;
|
||||
|
||||
// Update the starting page input to the next available page
|
||||
if (data.added.length > 0) {
|
||||
const lastPage = data.added[data.added.length - 1].page_number;
|
||||
startPageInput.value = lastPage + 1;
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
if (window.clearSelection) {
|
||||
window.clearSelection();
|
||||
}
|
||||
|
||||
// Update page count in header
|
||||
const pageCountEl = document.getElementById('seriesPageCount');
|
||||
if (pageCountEl) {
|
||||
const currentCount = parseInt(pageCountEl.textContent) || 0;
|
||||
pageCountEl.textContent = `${currentCount + data.added.length} pages`;
|
||||
}
|
||||
|
||||
// Show "Read Series" button if it was hidden (first pages added)
|
||||
const readBtn = document.querySelector('.series-header-actions .primary-btn');
|
||||
if (!readBtn && data.added.length > 0) {
|
||||
location.reload(); // Simplest way to update the UI
|
||||
}
|
||||
} else {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
feedbackEl.innerHTML = `<div class="feedback-error">Error: ${e.message}</div>`;
|
||||
} finally {
|
||||
updateAddButtonState();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
updateAddButtonState();
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block title %}{{ series_name }} - Reader{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="reader-container">
|
||||
<!-- Reader Header -->
|
||||
<div class="reader-header">
|
||||
<div class="reader-header-left">
|
||||
<a href="{{ url_for('main.gallery', tag=tag.name) }}" class="reader-back-btn" title="Back to gallery">
|
||||
<span class="back-arrow">←</span>
|
||||
</a>
|
||||
<h1 class="reader-title">{{ series_name }}</h1>
|
||||
<span class="reader-page-count">{{ total_pages }} pages</span>
|
||||
</div>
|
||||
<div class="reader-header-right">
|
||||
<div class="reader-jump">
|
||||
<label for="pageJumpSelect">Jump to:</label>
|
||||
<select id="pageJumpSelect" class="reader-jump-select">
|
||||
{% for page in pages %}
|
||||
<option value="{{ page.page_number }}" {% if page.page_number == start_page %}selected{% endif %}>
|
||||
Page {{ page.page_number }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button id="toggleNavBtn" class="reader-toggle-nav" title="Toggle navigation panel">
|
||||
<span class="nav-icon">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-body">
|
||||
<!-- Navigation Sidebar (toggleable) -->
|
||||
<aside id="readerNav" class="reader-nav">
|
||||
<div class="reader-nav-header">
|
||||
<h3>Pages</h3>
|
||||
<button id="closeNavBtn" class="reader-nav-close">×</button>
|
||||
</div>
|
||||
<div class="reader-nav-thumbs">
|
||||
{% for page in pages %}
|
||||
<div class="reader-nav-thumb" data-page="{{ page.page_number }}">
|
||||
<img src="{{ url_for('main.serve_image', filename=(page.image.thumb_path or page.image.filepath) | replace('/images/', '')) }}" alt="Page {{ page.page_number }}" loading="lazy">
|
||||
<span class="thumb-page-num">{{ page.page_number }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Reading Area -->
|
||||
<main id="readerMain" class="reader-main">
|
||||
<div id="readerContent" class="reader-content">
|
||||
{% if pages %}
|
||||
{% for page in pages %}
|
||||
<div class="reader-page" id="page-{{ page.page_number }}" data-page="{{ page.page_number }}">
|
||||
<img src="{{ url_for('main.serve_image', filename=page.image.filepath | replace('/images/', '')) }}"
|
||||
alt="Page {{ page.page_number }}"
|
||||
loading="lazy"
|
||||
data-image-id="{{ page.image.id }}">
|
||||
<div class="page-indicator">{{ page.page_number }} / {{ total_pages }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="reader-empty">
|
||||
<p>No pages in this series yet.</p>
|
||||
<a href="{{ url_for('main.gallery', tag=tag.name) }}" class="btn primary-btn">
|
||||
Go to gallery to add pages
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Floating page indicator -->
|
||||
<div id="floatingPageIndicator" class="floating-page-indicator">
|
||||
<span id="currentPageNum">1</span> / {{ total_pages }}
|
||||
</div>
|
||||
|
||||
<!-- Quick jump buttons -->
|
||||
<div class="reader-quick-nav">
|
||||
<button id="scrollToTopBtn" class="reader-quick-btn" title="Go to first page">↑ First</button>
|
||||
<button id="scrollToBottomBtn" class="reader-quick-btn" title="Go to last page">Last ↓</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const readerContent = document.getElementById('readerContent');
|
||||
const pageJumpSelect = document.getElementById('pageJumpSelect');
|
||||
const floatingIndicator = document.getElementById('floatingPageIndicator');
|
||||
const currentPageNum = document.getElementById('currentPageNum');
|
||||
const readerNav = document.getElementById('readerNav');
|
||||
const toggleNavBtn = document.getElementById('toggleNavBtn');
|
||||
const closeNavBtn = document.getElementById('closeNavBtn');
|
||||
const navThumbs = document.querySelectorAll('.reader-nav-thumb');
|
||||
const scrollToTopBtn = document.getElementById('scrollToTopBtn');
|
||||
const scrollToBottomBtn = document.getElementById('scrollToBottomBtn');
|
||||
const pages = document.querySelectorAll('.reader-page');
|
||||
|
||||
const startPage = {{ start_page }};
|
||||
let currentPage = startPage;
|
||||
let isNavOpen = false;
|
||||
|
||||
// Scroll to starting page on load
|
||||
if (startPage > 1) {
|
||||
const targetPage = document.getElementById('page-' + startPage);
|
||||
if (targetPage) {
|
||||
setTimeout(() => {
|
||||
targetPage.scrollIntoView({ behavior: 'auto' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Track current page while scrolling
|
||||
function updateCurrentPage() {
|
||||
const scrollTop = readerContent.scrollTop;
|
||||
const viewportHeight = readerContent.clientHeight;
|
||||
const viewportCenter = scrollTop + (viewportHeight / 3);
|
||||
|
||||
let newCurrentPage = 1;
|
||||
pages.forEach(page => {
|
||||
const pageTop = page.offsetTop;
|
||||
const pageBottom = pageTop + page.offsetHeight;
|
||||
if (viewportCenter >= pageTop && viewportCenter < pageBottom) {
|
||||
newCurrentPage = parseInt(page.dataset.page);
|
||||
}
|
||||
});
|
||||
|
||||
if (newCurrentPage !== currentPage) {
|
||||
currentPage = newCurrentPage;
|
||||
currentPageNum.textContent = currentPage;
|
||||
pageJumpSelect.value = currentPage;
|
||||
|
||||
// Update nav thumb highlight
|
||||
navThumbs.forEach(thumb => {
|
||||
thumb.classList.toggle('active', parseInt(thumb.dataset.page) === currentPage);
|
||||
});
|
||||
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('page', currentPage);
|
||||
history.replaceState({}, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
// Throttled scroll handler
|
||||
let scrollTimeout;
|
||||
readerContent.addEventListener('scroll', () => {
|
||||
if (scrollTimeout) return;
|
||||
scrollTimeout = setTimeout(() => {
|
||||
updateCurrentPage();
|
||||
scrollTimeout = null;
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Jump to page from select
|
||||
pageJumpSelect.addEventListener('change', (e) => {
|
||||
const pageNum = parseInt(e.target.value);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) {
|
||||
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle navigation sidebar
|
||||
toggleNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
});
|
||||
|
||||
closeNavBtn.addEventListener('click', () => {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
});
|
||||
|
||||
// Click thumbnail to jump
|
||||
navThumbs.forEach(thumb => {
|
||||
thumb.addEventListener('click', () => {
|
||||
const pageNum = parseInt(thumb.dataset.page);
|
||||
const targetPage = document.getElementById('page-' + pageNum);
|
||||
if (targetPage) {
|
||||
targetPage.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
// Close nav on mobile
|
||||
if (window.innerWidth < 768) {
|
||||
isNavOpen = false;
|
||||
readerNav.classList.remove('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Quick nav buttons
|
||||
scrollToTopBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
scrollToBottomBtn.addEventListener('click', () => {
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'PageDown':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' });
|
||||
break;
|
||||
case 'n':
|
||||
isNavOpen = !isNavOpen;
|
||||
readerNav.classList.toggle('open', isNavOpen);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
updateCurrentPage();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,6 +3,7 @@
|
||||
{%- if active_kind == 'artist' -%}Artists
|
||||
{%- elif active_kind == 'character' -%}Characters
|
||||
{%- elif active_kind == 'series' -%}Series
|
||||
{%- elif active_kind == 'fandom' -%}Fandoms
|
||||
{%- elif active_kind == 'rating' -%}Ratings
|
||||
{%- elif active_kind == 'archive' -%}Archives
|
||||
{%- elif active_kind == 'source' -%}Sources
|
||||
@@ -16,6 +17,7 @@
|
||||
{%- if active_kind == 'artist' -%}Artists
|
||||
{%- elif active_kind == 'character' -%}Characters
|
||||
{%- elif active_kind == 'series' -%}Series
|
||||
{%- elif active_kind == 'fandom' -%}Fandoms
|
||||
{%- elif active_kind == 'rating' -%}Ratings
|
||||
{%- elif active_kind == 'archive' -%}Archives
|
||||
{%- elif active_kind == 'source' -%}Sources
|
||||
@@ -42,6 +44,10 @@
|
||||
href="{{ url_for('main.tag_list', kind='series') }}"
|
||||
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
|
||||
|
||||
<a class="filter-btn {{ 'is-active' if active_kind=='fandom' }}"
|
||||
href="{{ url_for('main.tag_list', kind='fandom') }}"
|
||||
aria-current="{{ 'page' if active_kind=='fandom' else 'false' }}">🎭 Fandoms</a>
|
||||
|
||||
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
|
||||
href="{{ url_for('main.tag_list', kind='rating') }}"
|
||||
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
|
||||
@@ -84,6 +90,7 @@
|
||||
{%- if tag.kind == 'artist' -%}🎨
|
||||
{%- elif tag.kind == 'character' -%}👤
|
||||
{%- elif tag.kind == 'series' -%}📺
|
||||
{%- elif tag.kind == 'fandom' -%}🎭
|
||||
{%- elif tag.kind == 'rating' -%}⚠️
|
||||
{%- elif tag.kind == 'archive' -%}🗜️
|
||||
{%- elif tag.kind == 'source' -%}🌐
|
||||
@@ -122,6 +129,7 @@
|
||||
<option value="artist">🎨 Artist</option>
|
||||
<option value="character">👤 Character</option>
|
||||
<option value="series">📺 Series</option>
|
||||
<option value="fandom">🎭 Fandom</option>
|
||||
<option value="rating">⚠️ Rating</option>
|
||||
<option value="archive">🗜️ Archive</option>
|
||||
<option value="source">🌐 Source</option>
|
||||
|
||||
Reference in New Issue
Block a user