DRYing the project

This commit is contained in:
2026-02-05 21:23:59 -05:00
parent 079f1c1d49
commit ac9a8d145f
11 changed files with 168 additions and 200 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(python:*)", "Bash(python:*)",
"Bash(git mv:*)" "Bash(git mv:*)",
"Bash(grep:*)",
"Bash(wc:*)"
] ]
} }
} }
+7 -42
View File
@@ -5,6 +5,9 @@
(function() { (function() {
'use strict'; 'use strict';
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
const state = { const state = {
isSelectMode: false, isSelectMode: false,
selectedIds: new Set(), selectedIds: new Set(),
@@ -263,7 +266,7 @@
dom.commonTags.innerHTML = tags.map(t => { dom.commonTags.innerHTML = tags.map(t => {
const icon = getTagIcon(t.kind); const icon = getTagIcon(t.kind);
const display = t.name.includes(':') ? t.name.split(':')[1] : t.name; const display = getTagDisplayName(t.name, t.kind);
const escapedName = escapeHtml(t.name); const escapedName = escapeHtml(t.name);
const escapedDisplay = escapeHtml(display); const escapedDisplay = escapeHtml(display);
return `<span class="tag-chip removable" data-name="${escapedName}"> return `<span class="tag-chip removable" data-name="${escapedName}">
@@ -471,7 +474,7 @@
dom.autocomplete.innerHTML = tags.map((t, i) => { dom.autocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind); const icon = getTagIcon(t.kind);
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name; const displayName = getTagDisplayName(t.name, t.kind);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${escapeHtml(t.name)}"> return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${escapeHtml(t.name)}">
<span>${icon} ${escapeHtml(displayName)}</span> <span>${icon} ${escapeHtml(displayName)}</span>
<span class="tag-kind">${t.kind || 'user'}</span> <span class="tag-kind">${t.kind || 'user'}</span>
@@ -491,41 +494,9 @@
autocompleteSelectedIndex = index; autocompleteSelectedIndex = index;
} }
// ---------------------------
// Helpers
// ---------------------------
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --------------------------- // ---------------------------
// Tag Refresh for Gallery Items // Tag Refresh for Gallery Items
// --------------------------- // ---------------------------
function getTagDisplayName(name, kind) {
// Strip prefix for known prefixed kinds
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
// Regular tags get a # prefix
return '#' + name;
}
async function refreshItemTags(imageId) { async function refreshItemTags(imageId) {
// Find the gallery item // Find the gallery item
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`); const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
@@ -555,14 +526,8 @@
item.appendChild(overlay); item.appendChild(overlay);
} }
// Render tags with icons // Render tags with icons using shared utility
overlay.innerHTML = visibleTags.map(t => { overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
const displayName = getTagDisplayName(t.name, t.kind);
const icon = getTagIcon(t.kind);
const encodedName = encodeURIComponent(t.name);
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${prefix}${escapeHtml(displayName)}</a>`;
}).join('');
} catch (e) { } catch (e) {
console.error('Failed to refresh tags for image', imageId, e); console.error('Failed to refresh tags for image', imageId, e);
+5 -22
View File
@@ -4,6 +4,9 @@
(function() { (function() {
'use strict'; 'use strict';
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, escapeHtml, createTagChipHtml } = window.TagUtils || {};
// Configuration // Configuration
const CONFIG = { const CONFIG = {
SCROLL_THRESHOLD: 1200, // px from bottom to trigger load SCROLL_THRESHOLD: 1200, // px from bottom to trigger load
@@ -187,21 +190,6 @@
return section; return section;
} }
// Tag icon mapping (matches showcase.js and bulk-select.js)
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
// Create a single image element // Create a single image element
function createImageElement(img) { function createImageElement(img) {
const item = document.createElement('div'); const item = document.createElement('div');
@@ -211,15 +199,10 @@
item.dataset.type = img.is_video ? 'video' : 'image'; item.dataset.type = img.is_video ? 'video' : 'image';
item.dataset.filename = img.filename; item.dataset.filename = img.filename;
// Build tag overlay HTML with icons // Build tag overlay HTML with icons using shared utility
let tagsHtml = ''; let tagsHtml = '';
if (img.tags && img.tags.length > 0) { if (img.tags && img.tags.length > 0) {
const tagChips = img.tags.map(t => { const tagChips = img.tags.map(t => createTagChipHtml(t)).join('');
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
const icon = getTagIcon(t.kind);
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodeURIComponent(t.name)}" title="Filter by ${t.name}" onclick="event.stopPropagation()">${prefix}${displayName}</a>`;
}).join('');
tagsHtml = `<div class="tag-overlay">${tagChips}</div>`; tagsHtml = `<div class="tag-overlay">${tagChips}</div>`;
} }
+7 -36
View File
@@ -2,6 +2,11 @@
// JS-managed masonry with column distribution for infinite scroll // JS-managed masonry with column distribution for infinite scroll
(function() { (function() {
'use strict';
// Use shared TagUtils module
const { formatTagHtml, createTagChipHtml } = window.TagUtils || {};
const container = document.getElementById('showcaseGrid'); const container = document.getElementById('showcaseGrid');
if (!container) return; if (!container) return;
@@ -156,15 +161,8 @@
if (visibleTags.length > 0) { if (visibleTags.length > 0) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'tag-overlay'; overlay.className = 'tag-overlay';
visibleTags.forEach(t => { // Use shared utility to create tag chips
const chip = document.createElement('a'); overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
chip.className = 'tag-chip';
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
chip.title = `Filter by ${t.name}`;
chip.onclick = (e) => e.stopPropagation();
chip.innerHTML = formatTag(t);
overlay.appendChild(chip);
});
item.appendChild(overlay); item.appendChild(overlay);
} }
@@ -274,33 +272,6 @@
} }
} }
function formatTag(tag) {
const escape = (s) => {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
};
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
const icon = icons[tag.kind];
if (icon) {
const label = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
return `${icon} ${escape(label)}`;
} else {
return `#${escape(tag.name)}`;
}
}
// Start // Start
init(); init();
})(); })();
+112
View File
@@ -0,0 +1,112 @@
// /app/static/js/tag-utils.js
// Shared tag utility functions used across gallery, modal, and bulk editor
(function() {
'use strict';
/**
* Get emoji icon for a tag kind
* @param {string} kind - Tag kind (artist, archive, character, etc.)
* @returns {string} Emoji icon or '#' for user tags
*/
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
/**
* Get display name for a tag, stripping the prefix if present
* @param {string} name - Full tag name (e.g., "artist:Name")
* @param {string} [kind] - Optional tag kind for context
* @returns {string} Display name without prefix
*/
function getTagDisplayName(name, kind) {
// For prefixed kinds, strip the prefix
if (kind && ['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
// If no kind specified but has colon, assume it's prefixed
if (!kind && name.includes(':')) {
return name.split(':', 2)[1];
}
return name;
}
/**
* Get display name for tag overlay (used in gallery items)
* Returns name with # prefix for unprefixed user tags
* @param {string} name - Full tag name
* @param {string} kind - Tag kind
* @returns {string} Display name for overlay
*/
function getTagDisplayNameForOverlay(name, kind) {
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
return '#' + name;
}
/**
* Escape HTML special characters
* @param {string} str - String to escape
* @returns {string} HTML-escaped string
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* Format a tag for display with icon and name
* @param {Object} tag - Tag object with name and kind
* @returns {string} Formatted HTML string
*/
function formatTagHtml(tag) {
const icon = getTagIcon(tag.kind);
const displayName = getTagDisplayName(tag.name, tag.kind);
const escaped = escapeHtml(displayName);
if (icon !== '#') {
return `${icon} ${escaped}`;
}
return `#${escaped}`;
}
/**
* Create tag chip HTML for gallery overlays
* @param {Object} tag - Tag object with name and kind
* @param {boolean} [stopPropagation=true] - Add onclick to stop propagation
* @returns {string} HTML string for tag chip link
*/
function createTagChipHtml(tag, stopPropagation = true) {
const icon = getTagIcon(tag.kind);
const displayName = getTagDisplayNameForOverlay(tag.name, tag.kind);
const encodedName = encodeURIComponent(tag.name);
const escapedTitle = escapeHtml(tag.name);
const escapedDisplay = escapeHtml(displayName);
const prefix = icon !== '#' ? icon + ' ' : '#';
const onclick = stopPropagation ? ' onclick="event.stopPropagation()"' : '';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedTitle}"${onclick}>${prefix}${escapedDisplay}</a>`;
}
// Export to window.TagUtils
window.TagUtils = {
getTagIcon,
getTagDisplayName,
getTagDisplayNameForOverlay,
escapeHtml,
formatTagHtml,
createTagChipHtml
};
})();
+4 -34
View File
@@ -57,30 +57,15 @@ document.addEventListener('DOMContentLoaded', () => {
// --------------------------- // ---------------------------
// Tag editor helpers // Tag editor helpers
// --------------------------- // ---------------------------
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
function setEditorImageId(id) { function setEditorImageId(id) {
if (tagEditor) tagEditor.dataset.imageId = id || ''; if (tagEditor) tagEditor.dataset.imageId = id || '';
} }
function getEditorImageId() { function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : ''; return tagEditor ? tagEditor.dataset.imageId : '';
} }
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
function getTagDisplayName(name) {
// Remove prefix if present (e.g., "artist:Name" -> "Name")
return name.includes(':') ? name.split(':', 2)[1] : name;
}
function renderTags(tags) { function renderTags(tags) {
if (!tagList) return; if (!tagList) return;
@@ -108,13 +93,6 @@ document.addEventListener('DOMContentLoaded', () => {
// --------------------------- // ---------------------------
// Gallery item tag refresh (fallback if bulk-select.js not loaded) // Gallery item tag refresh (fallback if bulk-select.js not loaded)
// --------------------------- // ---------------------------
function getTagDisplayNameForOverlay(name, kind) {
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
return '#' + name;
}
async function refreshItemTagsFallback(imageId) { async function refreshItemTagsFallback(imageId) {
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`); const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
if (!item) return; if (!item) return;
@@ -138,15 +116,7 @@ document.addEventListener('DOMContentLoaded', () => {
item.appendChild(overlay); item.appendChild(overlay);
} }
overlay.innerHTML = visibleTags.map(t => { overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
const displayName = getTagDisplayNameForOverlay(t.name, t.kind);
const icon = getTagIcon(t.kind);
const encodedName = encodeURIComponent(t.name);
const escapedName = t.name.replace(/"/g, '&quot;');
const escapedDisplay = displayName.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapedName}" onclick="event.stopPropagation()">${prefix}${escapedDisplay}</a>`;
}).join('');
} catch (e) { } catch (e) {
console.error('Failed to refresh tags for image', imageId, e); console.error('Failed to refresh tags for image', imageId, e);
} }
-1
View File
@@ -174,7 +174,6 @@ header {
box-shadow: 0 4px 12px rgba(0,0,0,0.5); box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 1001; z-index: 1001;
padding: 0.5rem 0; padding: 0.5rem 0;
margin-top: 0.25rem;
} }
.nav-dropdown-menu a { .nav-dropdown-menu a {
+1
View File
@@ -175,6 +175,7 @@
}; };
</script> </script>
<script src="{{ url_for('static', filename='js/tag-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script> <script src="{{ url_for('static', filename='js/gallery-infinite.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script> <script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script> <script src="{{ url_for('static', filename='js/bulk-select.js') }}"></script>
+3 -26
View File
@@ -47,35 +47,12 @@
{% endfor %}] {% endfor %}]
</script> </script>
<!-- Modal viewer --> <!-- Modal viewer (shared with gallery) -->
<div id="imageModal" class="modal"> {% include '_gallery_modal.html' %}
<div class="modal-content">
<button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<span class="modal-close-hint">ESC to close</span>
<div class="modal-body">
<div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View">
</div>
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="tagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/tag-utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/view-modal.js') }}"></script> <script src="{{ url_for('static', filename='js/view-modal.js') }}"></script>
<script src="{{ url_for('static', filename='js/showcase.js') }}"></script> <script src="{{ url_for('static', filename='js/showcase.js') }}"></script>
{% endblock %} {% endblock %}
-27
View File
@@ -1,27 +0,0 @@
# Dockerfile
FROM python:slim
WORKDIR /app
# System deps for archives and video thumbs (all FOSS)
RUN apt-get update && apt-get install -y --no-install-recommends \
p7zip-full \
unar \
file \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Install deps first for better layer caching
COPY requirements.txt .
RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
# Copy the rest of the app
COPY . .
# Entrypoint controls migrations, background worker, and Gunicorn
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 5000
ENTRYPOINT ["/entrypoint.sh"]
+26 -11
View File
@@ -4,7 +4,7 @@ A Flask-based image repository with Celery task processing for importing, organi
> **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications. > **IMPORTANT**: This summary must be kept up to date with any code changes. Update the timestamp below when making modifications.
> >
> **Last Updated**: 2026-02-04 (Pixiv sidecar support, Tags navbar dropdown, tag search) > **Last Updated**: 2026-02-05 (Consolidated JS utilities, shared modal template, Tags dropdown gap fix)
--- ---
@@ -346,25 +346,40 @@ Import tasks support two modes based on `context.deep_scan`:
| Template | Purpose | | Template | Purpose |
|----------|---------| |----------|---------|
| `layout.html` | Base template with navbar | | `layout.html` | Base template with navbar (includes Tags dropdown) |
| `showcase.html` | Random image masonry view | | `showcase.html` | Random image masonry view (extends layout, includes `_gallery_modal.html`) |
| `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor, series editor | | `gallery.html` | Main gallery with infinite scroll, timeline sidebar, bulk editor (includes `_gallery_modal.html`) |
| `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation | | `reader.html` | Series reader with vertical scroll, page jump, thumbnail navigation |
| `tags_list.html` | Tag browser with preview images | | `tags_list.html` | Tag browser with preview images and infinite scroll |
| `settings.html` | Import settings, deletion tools, duplicate detection, maintenance tools | | `settings.html` | Import settings, deletion tools, duplicate detection, maintenance tools |
| `_gallery_item.html` | Single gallery item partial (with tag overlay icons) | | `_gallery_item.html` | Single gallery item partial (with tag overlay icons) |
| `_gallery_modal.html` | Image modal with tag editor and series management | | `_gallery_modal.html` | **Shared** image modal with tag editor and series management (used by gallery & showcase) |
| `_pagination.html` | Pagination controls | | `_tag_cards.html` | Tag card grid partial for tag list |
| `_pagination.html` | Legacy pagination controls |
| `_pagination_floating.html` | Floating pagination indicator |
**Template Inheritance**:
```
layout.html (base)
├── gallery.html (includes: _gallery_item.html, _gallery_modal.html)
├── showcase.html (includes: _gallery_modal.html)
├── reader.html
├── tags_list.html (includes: _tag_cards.html)
└── settings.html
```
### JavaScript Files (`app/static/js/`) ### JavaScript Files (`app/static/js/`)
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `gallery-infinite.js` | Infinite scroll, timeline navigation | | `tag-utils.js` | **Shared utility module** - `getTagIcon()`, `getTagDisplayName()`, `escapeHtml()`, `createTagChipHtml()` used by all tag-related JS |
| `view-modal.js` | Modal image navigation, tag editing, series management (add/update/move) | | `gallery-infinite.js` | Infinite scroll, timeline navigation (uses TagUtils) |
| `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh, series bulk-add | | `view-modal.js` | Modal image navigation, tag editing, series management (uses TagUtils) |
| `bulk-select.js` | Multi-select with ordered selection, bulk tag editing, tag refresh (uses TagUtils) |
| `tag-editor.js` | Tag autocomplete and editing | | `tag-editor.js` | Tag autocomplete and editing |
| `showcase.js` | Showcase shuffle functionality | | `showcase.js` | Showcase masonry layout and shuffle functionality (uses TagUtils) |
**Script Loading Order**: `tag-utils.js` must be loaded before other JS files that depend on `window.TagUtils`.
--- ---