refactor(tags-page): improve code structure and reuse
This commit is contained in:
+180
-44
@@ -40,6 +40,10 @@ class TagsPageManager {
|
||||
// Context menu state
|
||||
this.contextMenuTag = null;
|
||||
|
||||
// Document listener refs for cleanup
|
||||
this._onDocClick = null;
|
||||
this._onDocKeydown = null;
|
||||
|
||||
this.bindEvents();
|
||||
this.initInfiniteScroll();
|
||||
this.buildContextMenu();
|
||||
@@ -105,8 +109,7 @@ class TagsPageManager {
|
||||
});
|
||||
if (this.tagSearch) params.set('search', this.tagSearch);
|
||||
|
||||
const resp = await fetch(`/prompt_manager/tags/stats?${params}`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`);
|
||||
|
||||
if (data.success) {
|
||||
this.tags = data.tags;
|
||||
@@ -127,8 +130,17 @@ class TagsPageManager {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err);
|
||||
const container = document.getElementById('tagsList');
|
||||
if (container) {
|
||||
container.replaceChildren();
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-center py-8 text-red-400 text-sm';
|
||||
errDiv.textContent = 'Failed to load tags. Try refreshing.';
|
||||
container.appendChild(errDiv);
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingTags = false;
|
||||
requestAnimationFrame(() => this.refreshTagObserver());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +148,8 @@ class TagsPageManager {
|
||||
if (this.isLoadingTags || !this.hasMoreTags) return;
|
||||
this.isLoadingTags = true;
|
||||
|
||||
const loader = document.getElementById('tagsLoader');
|
||||
if (loader) loader.classList.remove('hidden');
|
||||
const spinner = document.querySelector('#tagsLoader .tags-loader-spinner');
|
||||
if (spinner) spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -147,24 +159,37 @@ class TagsPageManager {
|
||||
});
|
||||
if (this.tagSearch) params.set('search', this.tagSearch);
|
||||
|
||||
const resp = await fetch(`/prompt_manager/tags/stats?${params}`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`);
|
||||
|
||||
if (data.success) {
|
||||
this.tags = this.tags.concat(data.tags);
|
||||
const newTags = data.tags;
|
||||
this.tags = this.tags.concat(newTags);
|
||||
this.hasMoreTags = data.pagination.has_more;
|
||||
this.tagOffset += this.tagLimit;
|
||||
this.maxTagCount = Math.max(1, ...this.tags.map(t => t.count));
|
||||
this.renderTagsList();
|
||||
this.appendTagElements(newTags);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load more tags:', err);
|
||||
this.hasMoreTags = false;
|
||||
} finally {
|
||||
this.isLoadingTags = false;
|
||||
if (loader) loader.classList.add('hidden');
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
requestAnimationFrame(() => this.refreshTagObserver());
|
||||
}
|
||||
}
|
||||
|
||||
appendTagElements(newTags) {
|
||||
const container = document.getElementById('tagsList');
|
||||
if (!container || newTags.length === 0) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
newTags.forEach(tag => {
|
||||
fragment.appendChild(this.createTagElement(tag));
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
renderTagsList() {
|
||||
const container = document.getElementById('tagsList');
|
||||
if (!container) return;
|
||||
@@ -287,13 +312,15 @@ class TagsPageManager {
|
||||
});
|
||||
menu.replaceChildren(fragment);
|
||||
|
||||
// Close on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
// Close on click outside (store refs for cleanup)
|
||||
this._onDocClick = (e) => {
|
||||
if (!menu.contains(e.target)) this.hideContextMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
};
|
||||
this._onDocKeydown = (e) => {
|
||||
if (e.key === 'Escape') this.hideContextMenu();
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', this._onDocClick);
|
||||
document.addEventListener('keydown', this._onDocKeydown);
|
||||
}
|
||||
|
||||
showContextMenu(e, tagName) {
|
||||
@@ -329,14 +356,15 @@ class TagsPageManager {
|
||||
if (!newName || newName.trim() === '' || newName.trim() === tagName) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(`Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`, 'success');
|
||||
let msg = `Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
// Update selectedTags if the renamed tag was selected
|
||||
const idx = this.selectedTags.indexOf(tagName);
|
||||
if (idx >= 0) this.selectedTags[idx] = newName.trim();
|
||||
@@ -357,12 +385,13 @@ class TagsPageManager {
|
||||
if (!confirm(`Delete tag "${tagName}" from all prompts? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(`Deleted tag "${tagName}" from ${data.affected_count} prompts`, 'success');
|
||||
let msg = `Deleted tag "${tagName}" from ${data.affected_count} prompts`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
this.selectedTags = this.selectedTags.filter(t => t !== tagName);
|
||||
this.updateUrlHash();
|
||||
this.loadTagsList();
|
||||
@@ -387,17 +416,15 @@ class TagsPageManager {
|
||||
if (!targetTag || targetTag.trim() === '' || targetTag.trim() === tagName) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/prompt_manager/tags/merge', {
|
||||
const data = await this.fetchJson('/prompt_manager/tags/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_tags: [tagName], target_tag: targetTag.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(
|
||||
`Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`,
|
||||
'success'
|
||||
);
|
||||
let msg = `Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
this.selectedTags = this.selectedTags.filter(t => t !== tagName);
|
||||
this.updateUrlHash();
|
||||
this.loadTagsList();
|
||||
@@ -426,6 +453,15 @@ class TagsPageManager {
|
||||
this.loadTagPrompts();
|
||||
}
|
||||
|
||||
scrollToTag(name) {
|
||||
const container = document.getElementById('tagsListContainer');
|
||||
if (!container) return;
|
||||
const tagEl = container.querySelector(`[data-tag-name="${CSS.escape(name)}"]`);
|
||||
if (tagEl) {
|
||||
tagEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
toggleTagFilter(name) {
|
||||
const idx = this.selectedTags.indexOf(name);
|
||||
if (idx >= 0) {
|
||||
@@ -585,8 +621,7 @@ class TagsPageManager {
|
||||
url = `/prompt_manager/tags/filter?${params}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(url);
|
||||
|
||||
if (data.success) {
|
||||
this.prompts = data.prompts;
|
||||
@@ -595,6 +630,8 @@ class TagsPageManager {
|
||||
this.promptOffset = this.promptLimit;
|
||||
this.renderTagPrompts();
|
||||
this.updatePromptCountBadge();
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tag prompts:', err);
|
||||
@@ -614,8 +651,8 @@ class TagsPageManager {
|
||||
if (this.isLoadingPrompts || !this.hasMorePrompts) return;
|
||||
this.isLoadingPrompts = true;
|
||||
|
||||
const loader = document.getElementById('tagPromptsLoader');
|
||||
if (loader) loader.classList.remove('hidden');
|
||||
const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner');
|
||||
if (spinner) spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -637,33 +674,77 @@ class TagsPageManager {
|
||||
url = `/prompt_manager/tags/filter?${params}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(url);
|
||||
|
||||
if (data.success) {
|
||||
const startIndex = this.prompts.length;
|
||||
this.prompts = this.prompts.concat(data.prompts);
|
||||
this.hasMorePrompts = data.pagination.has_more;
|
||||
this.promptOffset += this.promptLimit;
|
||||
this.renderTagPrompts();
|
||||
this.appendTagPrompts(data.prompts, startIndex);
|
||||
this.updatePromptCountBadge();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load more prompts:', err);
|
||||
this.hasMorePrompts = false;
|
||||
} finally {
|
||||
this.isLoadingPrompts = false;
|
||||
if (loader) loader.classList.add('hidden');
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
}
|
||||
|
||||
updatePromptCountBadge() {
|
||||
const badge = document.getElementById('tagPromptsCountBadge');
|
||||
if (badge) {
|
||||
badge.textContent = this.promptTotal > 0
|
||||
? `${this.prompts.length} of ${this.promptTotal} prompts`
|
||||
: '';
|
||||
if (this.promptTotal > 0) {
|
||||
const remaining = this.promptTotal - this.prompts.length;
|
||||
badge.textContent = remaining > 0
|
||||
? `${this.prompts.length} of ${this.promptTotal} prompts — scroll for more`
|
||||
: `${this.promptTotal} prompts`;
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadMoreButton() {
|
||||
const btn = document.getElementById('loadMorePromptsBtn');
|
||||
if (!btn) return;
|
||||
if (this.hasMorePrompts) {
|
||||
const remaining = this.promptTotal - this.prompts.length;
|
||||
btn.textContent = `Load more prompts (${remaining} remaining)`;
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
appendTagPrompts(newPrompts, startIndex) {
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (!grid || newPrompts.length === 0) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
newPrompts.forEach((p, i) => {
|
||||
const card = this.createPromptCard(p);
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(8px)';
|
||||
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
||||
card.style.transitionDelay = (i % 9) * 30 + 'ms';
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
grid.appendChild(fragment);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cards = grid.querySelectorAll('.tag-prompt-card');
|
||||
for (let i = startIndex; i < cards.length; i++) {
|
||||
cards[i].style.opacity = '1';
|
||||
cards[i].style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderTagPrompts() {
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (!grid) return;
|
||||
@@ -810,15 +891,23 @@ class TagsPageManager {
|
||||
|
||||
card.appendChild(metaRow);
|
||||
|
||||
// Tags
|
||||
// Tags (clickable — selects tag on left panel)
|
||||
if (prompt.tags && prompt.tags.length > 0) {
|
||||
const tagsDiv = document.createElement('div');
|
||||
tagsDiv.className = 'flex flex-wrap gap-1 mt-2';
|
||||
prompt.tags.forEach(tag => {
|
||||
prompt.tags.forEach(tagName => {
|
||||
const tagSpan = document.createElement('span');
|
||||
const isActive = this.selectedTags.includes(tag);
|
||||
tagSpan.className = `px-2 py-0.5 rounded text-xs ${isActive ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300'}`;
|
||||
tagSpan.textContent = tag;
|
||||
const isActive = this.selectedTags.includes(tagName);
|
||||
tagSpan.className = `px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-green-600 text-white hover:bg-green-500'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-green-600/40 hover:text-green-200'
|
||||
}`;
|
||||
tagSpan.textContent = tagName;
|
||||
tagSpan.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleTagFilter(tagName);
|
||||
});
|
||||
tagsDiv.appendChild(tagSpan);
|
||||
});
|
||||
card.appendChild(tagsDiv);
|
||||
@@ -836,13 +925,16 @@ class TagsPageManager {
|
||||
// If there might be more images, load them all
|
||||
if (prompt.image_count > images.length) {
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/prompts/${prompt.id}/images`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/prompts/${prompt.id}/images`);
|
||||
if (data.success && data.images) {
|
||||
images = data.images;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load all images:', err);
|
||||
this.admin.showNotification(
|
||||
`Showing ${images.length} of ${prompt.image_count} images — full load failed`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,8 +989,10 @@ class TagsPageManager {
|
||||
clearPrompts() {
|
||||
this.prompts = [];
|
||||
this.promptTotal = 0;
|
||||
this.hasMorePrompts = false;
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (grid) grid.replaceChildren();
|
||||
this.updateLoadMoreButton();
|
||||
const emptyState = document.getElementById('tagPromptsEmpty');
|
||||
if (emptyState) emptyState.classList.remove('hidden');
|
||||
this.updatePromptCountBadge();
|
||||
@@ -930,6 +1024,24 @@ class TagsPageManager {
|
||||
}
|
||||
}
|
||||
|
||||
refreshPromptObserver() {
|
||||
if (!this.promptObserver || !this.hasMorePrompts) return;
|
||||
const loader = document.getElementById('tagPromptsLoader');
|
||||
if (loader) {
|
||||
this.promptObserver.unobserve(loader);
|
||||
this.promptObserver.observe(loader);
|
||||
}
|
||||
}
|
||||
|
||||
refreshTagObserver() {
|
||||
if (!this.tagObserver || !this.hasMoreTags) return;
|
||||
const loader = document.getElementById('tagsLoader');
|
||||
if (loader) {
|
||||
this.tagObserver.unobserve(loader);
|
||||
this.tagObserver.observe(loader);
|
||||
}
|
||||
}
|
||||
|
||||
destroyInfiniteScroll() {
|
||||
if (this.tagObserver) {
|
||||
this.tagObserver.disconnect();
|
||||
@@ -970,16 +1082,40 @@ class TagsPageManager {
|
||||
const orBtn = document.getElementById('filterModeOr');
|
||||
if (andBtn) andBtn.addEventListener('click', () => this.setFilterMode('and'));
|
||||
if (orBtn) orBtn.addEventListener('click', () => this.setFilterMode('or'));
|
||||
|
||||
// Load More button (fallback for infinite scroll)
|
||||
const loadMoreBtn = document.getElementById('loadMorePromptsBtn');
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.addEventListener('click', () => this.loadMoreTagPrompts());
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyInfiniteScroll();
|
||||
this.hideContextMenu();
|
||||
clearTimeout(this.searchTimer);
|
||||
if (this._onDocClick) {
|
||||
document.removeEventListener('click', this._onDocClick);
|
||||
this._onDocClick = null;
|
||||
}
|
||||
if (this._onDocKeydown) {
|
||||
document.removeEventListener('keydown', this._onDocKeydown);
|
||||
this._onDocKeydown = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
async fetchJson(url, options = {}) {
|
||||
const resp = await fetch(url, options);
|
||||
if (!resp.ok) {
|
||||
let errorMsg;
|
||||
try { errorMsg = (await resp.json()).error; } catch { errorMsg = resp.statusText; }
|
||||
throw new Error(`Server error ${resp.status}: ${errorMsg}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
getThumbnailUrl(image) {
|
||||
if (image.thumbnail_url) return image.thumbnail_url;
|
||||
if (image.url) return image.url;
|
||||
|
||||
Reference in New Issue
Block a user