diff options
| author | Bobby <[email protected]> | 2025-07-19 17:06:56 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-07-19 17:06:56 +0530 |
| commit | 3f73b3c66de04a55bc101ffb96070ae19e7bf27a (patch) | |
| tree | 85ca777a49e3ec533b2fbc3c709ceb6e093c89c0 /static | |
| parent | d31111cf0133b223a8e665e6798b8ae09aa5c8a9 (diff) | |
| download | imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.tar.xz imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.zip | |
tag adding feature for images
Diffstat (limited to 'static')
| -rw-r--r-- | static/css/main.css | 252 | ||||
| -rw-r--r-- | static/scripts/tagEditor.js | 446 |
2 files changed, 698 insertions, 0 deletions
diff --git a/static/css/main.css b/static/css/main.css index 640303c..a7b9eb9 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1008,4 +1008,256 @@ footer::before { .edit-sidebar>.post-detail-item>.post-detail-value { word-break: break-all; +} + +/* Tag Editor Styles */ +.tag-editor { + margin-top: 24px; + background-color: #0d0020; + border: 1px solid #4d4d80; + padding: 16px; +} + +.tag-editor-title { + color: #ff99cc; + margin: 0 0 20px 0; + text-align: left; +} + +.tag-category { + margin-bottom: 20px; + background: rgba(13, 0, 26, 0.8); + border: 1px solid #4d4d80; + padding: 12px; +} + +.tag-category:hover { + border-color: #8080cc; +} + +.tag-category-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #6666cc; +} + +.tag-category-title { + margin: 0; + color: #ffccff; + display: flex; + align-items: center; + gap: 8px; +} + +.tag-type-icon { + font-weight: bold; +} + +.tag-count { + color: #cccccc; + font-weight: normal; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + min-height: 32px; + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border: 1px dashed #333366; +} + +.tag-item { + display: flex; + align-items: center; + background: #1a0033; + border: 1px solid #6666cc; + padding: 4px 8px; + gap: 4px; +} + +.tag-item:hover { + background: #330066; + border-color: #9999ff; +} + +.tag-link { + text-decoration: none; + font-weight: bold; +} + +.tag-link:hover { + text-decoration: underline; +} + +.tag-parent-indicator, +.tag-children-indicator { + opacity: 0.7; + cursor: help; +} + +.tag-parent-indicator { + color: #99ffcc; +} + +.tag-children-indicator { + color: #ffcc99; +} + +.tag-remove-btn { + background: none; + border: none; + color: #ff6666; + cursor: pointer; + font-weight: bold; + padding: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.tag-remove-btn:hover { + background: #ff6666; + color: white; +} + +.no-tags { + color: #666699; + font-style: italic; + font-size: 14px; + padding: 8px; + text-align: center; + width: 100%; +} + +.tag-input-container { + position: relative; +} + +.tag-input-wrapper { + position: relative; +} + +.tag-input { + width: 100%; + background: rgba(0, 0, 0, 0.5); + border: 1px solid #6666cc; + border-radius: 4px; + padding: 8px 12px; + color: #ccccff; + font-size: 14px; + transition: all 0.3s ease; +} + +.tag-input:focus { + border-color: #9999ff; + background: rgba(0, 0, 0, 0.7); + box-shadow: 0 0 10px rgba(153, 153, 255, 0.3); + outline: none; +} + +.tag-input::placeholder { + color: #666699; + font-style: italic; +} + +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: linear-gradient(135deg, #0a0015, #1a0033); + border: 1px solid #6666cc; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; +} + +.tag-suggestions.show { + display: block; +} + +.tag-suggestion { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #333366; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s ease; +} + +.tag-suggestion:hover, +.tag-suggestion.selected { + background: rgba(102, 102, 204, 0.3); +} + +.tag-suggestion:last-child { + border-bottom: none; +} + +.tag-suggestion-name { + font-weight: bold; +} + +.tag-suggestion-count { + color: #666699; + font-size: 12px; +} + +.tag-suggestion-create { + color: #99ffcc; + font-style: italic; +} + +.tag-suggestion-create .tag-suggestion-name { + color: #99ffcc; +} + +/* Tag type specific colors */ +.tag-category[data-type="general"] .tag-category-title .tag-type-icon { + color: #4ECDC4; +} + +.tag-category[data-type="artist"] .tag-category-title .tag-type-icon { + color: #FF6B9D; +} + +.tag-category[data-type="character"] .tag-category-title .tag-type-icon { + color: #FFB347; +} + +.tag-category[data-type="copyright"] .tag-category-title .tag-type-icon { + color: #A8E6CF; +} + +.tag-category[data-type="meta"] .tag-category-title .tag-type-icon { + color: #DDA0DD; +} + +/* Loading animation */ +.tag-input.loading { + background-image: linear-gradient(90deg, transparent, rgba(153, 153, 255, 0.2), transparent); + background-size: 200% 100%; + animation: loading-shimmer 1.5s infinite; +} + +@keyframes loading-shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } }
\ No newline at end of file diff --git a/static/scripts/tagEditor.js b/static/scripts/tagEditor.js new file mode 100644 index 0000000..95ba3ec --- /dev/null +++ b/static/scripts/tagEditor.js @@ -0,0 +1,446 @@ +class TagEditor { + constructor() { + this.debounceTimer = null; + this.selectedSuggestionIndex = -1; + this.currentInput = null; + this.init(); + } + + init() { + document.addEventListener('DOMContentLoaded', () => { + this.bindEvents(); + }); + } + + bindEvents() { + const tagInputs = document.querySelectorAll('.tag-input'); + const tagRemoveBtns = document.querySelectorAll('.tag-remove-btn'); + + tagInputs.forEach(input => { + input.addEventListener('input', (e) => this.handleInput(e)); + input.addEventListener('keydown', (e) => this.handleKeydown(e)); + input.addEventListener('blur', (e) => this.handleBlur(e)); + input.addEventListener('focus', (e) => this.handleFocus(e)); + }); + + tagRemoveBtns.forEach(btn => { + btn.addEventListener('click', (e) => this.removeTag(e)); + }); + + // Close suggestions when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.tag-input-wrapper')) { + this.hideSuggestions(); + } + }); + } + + handleInput(e) { + const input = e.target; + const query = input.value.trim(); + const tagType = input.dataset.type; + + clearTimeout(this.debounceTimer); + + if (query.length < 2) { + this.hideSuggestions(); + return; + } + + input.classList.add('loading'); + + this.debounceTimer = setTimeout(() => { + this.searchTags(query, tagType, input); + }, 300); + } + + handleKeydown(e) { + const suggestionsContainer = this.getSuggestionsContainer(e.target); + const suggestions = suggestionsContainer.querySelectorAll('.tag-suggestion'); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.selectedSuggestionIndex = Math.min(this.selectedSuggestionIndex + 1, suggestions.length - 1); + this.updateSuggestionSelection(suggestions); + break; + + case 'ArrowUp': + e.preventDefault(); + this.selectedSuggestionIndex = Math.max(this.selectedSuggestionIndex - 1, -1); + this.updateSuggestionSelection(suggestions); + break; + + case 'Enter': + e.preventDefault(); + if (this.selectedSuggestionIndex >= 0 && suggestions[this.selectedSuggestionIndex]) { + this.selectSuggestion(suggestions[this.selectedSuggestionIndex]); + } else { + this.addTagFromInput(e.target); + } + break; + + case 'Escape': + this.hideSuggestions(); + e.target.blur(); + break; + } + } + + handleBlur(e) { + // Delay hiding suggestions to allow for clicks + setTimeout(() => { + this.hideSuggestions(); + }, 200); + } + + handleFocus(e) { + this.currentInput = e.target; + const query = e.target.value.trim(); + if (query.length >= 2) { + this.searchTags(query, e.target.dataset.type, e.target); + } + } + + async searchTags(query, tagType, input) { + try { + const postId = this.getPostIdFromUrl(); + const response = await fetch(`/tags/search_for_image.json?q=${encodeURIComponent(query)}&type=${tagType}&image_id=${postId}`); + const data = await response.json(); + + input.classList.remove('loading'); + this.showSuggestions(data, input, query); + } catch (error) { + console.error('Search failed:', error); + input.classList.remove('loading'); + this.hideSuggestions(); + } + } + + showSuggestions(data, input, query) { + const suggestionsContainer = this.getSuggestionsContainer(input); + const { tags, can_create } = data; + const expectedTagType = input.dataset.type; + + let html = ''; + + // Existing tags - only show tags that match the expected type + tags.forEach(tag => { + if (tag.type === expectedTagType) { + html += ` + <div class="tag-suggestion" data-tag-id="${tag.ID}" data-tag-name="${tag.name}" data-tag-type="${tag.type}"> + <span class="tag-suggestion-name" style="color: ${this.getTagTypeColor(tag.type)};">${tag.name}</span> + <span class="tag-suggestion-count">(${tag.count})</span> + </div> + `; + } + }); + + // Create new tag option - only show if no exact match found and user can create tags + const exactMatch = tags.some(tag => tag.name.toLowerCase() === query.toLowerCase() && tag.type === expectedTagType); + const hasMatchingTypeTags = tags.some(tag => tag.type === expectedTagType); + + if (can_create && !exactMatch && !hasMatchingTypeTags) { + html += ` + <div class="tag-suggestion tag-suggestion-create" data-create="true" data-tag-name="${query}" data-tag-type="${expectedTagType}"> + <span class="tag-suggestion-name">Create "${query}" as ${expectedTagType}</span> + <span class="tag-suggestion-count">New tag</span> + </div> + `; + } + + suggestionsContainer.innerHTML = html; + + if (html) { + suggestionsContainer.classList.add('show'); + this.bindSuggestionEvents(suggestionsContainer); + this.selectedSuggestionIndex = -1; + } else { + suggestionsContainer.classList.remove('show'); + } + } + + bindSuggestionEvents(container) { + const suggestions = container.querySelectorAll('.tag-suggestion'); + suggestions.forEach((suggestion, index) => { + suggestion.addEventListener('click', () => this.selectSuggestion(suggestion)); + suggestion.addEventListener('mouseenter', () => { + this.selectedSuggestionIndex = index; + this.updateSuggestionSelection(suggestions); + }); + }); + } + + updateSuggestionSelection(suggestions) { + suggestions.forEach((suggestion, index) => { + if (index === this.selectedSuggestionIndex) { + suggestion.classList.add('selected'); + } else { + suggestion.classList.remove('selected'); + } + }); + } + + async selectSuggestion(suggestion) { + const tagName = suggestion.dataset.tagName; + const tagType = suggestion.dataset.tagType; + const isCreate = suggestion.dataset.create === 'true'; + + try { + await this.addTag(tagName, tagType); + this.hideSuggestions(); + + if (this.currentInput) { + this.currentInput.value = ''; + } + } catch (error) { + console.error('Failed to add tag:', error); + let errorMessage = error.message; + + // Handle specific cross-category errors + if (errorMessage.includes('already exists as') || errorMessage.includes('previously existed as')) { + errorMessage = `Tag "${tagName}" ${errorMessage}`; + } + + this.showError(errorMessage); + } + } + + async addTagFromInput(input) { + const tagName = input.value.trim(); + const tagType = input.dataset.type; + + if (!tagName) return; + + try { + await this.addTag(tagName, tagType); + input.value = ''; + this.hideSuggestions(); + } catch (error) { + console.error('Failed to add tag:', error); + let errorMessage = error.message; + + // Handle specific cross-category errors + if (errorMessage.includes('already exists as') || errorMessage.includes('previously existed as')) { + errorMessage = `Tag "${tagName}" ${errorMessage}`; + } + + this.showError(errorMessage); + } + } + + async addTag(tagName, tagType) { + // Validate that we're adding to the correct category + if (!tagType || !['general', 'artist', 'character', 'copyright', 'meta'].includes(tagType)) { + throw new Error('Invalid tag type'); + } + + const postId = this.getPostIdFromUrl(); + const response = await fetch(`/tags/add_to_image.json?image_id=${postId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag_name: tagName, + tag_type: tagType + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to add tag'); + } + + const result = await response.json(); + if (result.success) { + // Validate that the returned tag matches the requested type + if (result.tag.type !== tagType) { + throw new Error(`Tag type mismatch: expected ${tagType}, got ${result.tag.type}`); + } + this.addTagToUI(result.tag, tagType); + } + } async removeTag(e) { + e.preventDefault(); + e.stopPropagation(); + + const tagId = e.target.dataset.tagId; + const tagItem = e.target.closest('.tag-item'); + + try { + const postId = this.getPostIdFromUrl(); + + const response = await fetch(`/tags/remove_from_image.json?image_id=${postId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag_id: parseInt(tagId) + }) + }); + + if (!response.ok) { + throw new Error('Failed to remove tag'); + } + + const result = await response.json(); + if (result.success) { + const category = tagItem.closest('.tag-category'); + const tagList = category.querySelector('.tag-list'); + + tagItem.style.animation = 'fadeOut 0.3s ease'; + setTimeout(() => { + tagItem.remove(); + this.updateTagCount(category); + + // Check if no tags remain and add "No tags" message + const remainingTags = tagList.querySelectorAll('.tag-item'); + if (remainingTags.length === 0) { + const tagType = category.dataset.type; + const noTagsDiv = document.createElement('div'); + noTagsDiv.className = 'no-tags'; + noTagsDiv.textContent = `No ${tagType} tags`; + tagList.appendChild(noTagsDiv); + } + }, 300); + } + } catch (error) { + console.error('Failed to remove tag:', error); + this.showError('Failed to remove tag'); + } + } addTagToUI(tag, expectedTagType) { + // Validate that the tag type matches what we expect + if (tag.type !== expectedTagType) { + console.error(`Tag type mismatch: expected ${expectedTagType}, got ${tag.type}`); + this.showError(`Cannot add ${tag.type} tag "${tag.name}" to ${expectedTagType} section`); + return; + } + + const tagList = document.getElementById(`tag-list-${expectedTagType}`); + + // Check if tag already exists in this category + const existingTag = tagList.querySelector(`[data-tag-id="${tag.ID}"]`); + if (existingTag) { + return; // Tag already exists, don't add duplicate + } + + // Also check if tag exists in any other category (cross-category validation) + const allTagLists = document.querySelectorAll('.tag-list'); + for (let otherList of allTagLists) { + if (otherList.id !== `tag-list-${expectedTagType}`) { + const existingInOther = otherList.querySelector(`[data-tag-id="${tag.ID}"]`); + if (existingInOther) { + console.warn(`Tag "${tag.name}" already exists in another category`); + return; + } + } + } + + const noTagsElement = tagList.querySelector('.no-tags'); + + if (noTagsElement) { + noTagsElement.remove(); + } + + const tagItem = document.createElement('div'); + tagItem.className = 'tag-item'; + tagItem.dataset.tagId = tag.ID; + tagItem.style.animation = 'fadeIn 0.3s ease'; + + tagItem.innerHTML = ` + <a href="/tags/${tag.name}" class="tag-link" style="color: ${this.getTagTypeColor(tag.type)};"> + ${tag.name} + </a> + ${tag.parent ? `<span class="tag-parent-indicator" title="Child of ${tag.parent.name}">⬆</span>` : ''} + ${tag.children && tag.children.length > 0 ? `<span class="tag-children-indicator" title="Has ${tag.children.length} children">⬇</span>` : ''} + <button type="button" class="tag-remove-btn" data-tag-id="${tag.ID}" title="Remove tag">×</button> + `; + + tagList.appendChild(tagItem); + + // Bind remove event + tagItem.querySelector('.tag-remove-btn').addEventListener('click', (e) => this.removeTag(e)); + + // Update count + this.updateTagCount(tagList.closest('.tag-category')); + } + + updateTagCount(category) { + const tagList = category.querySelector('.tag-list'); + const countElement = category.querySelector('.tag-count'); + const tagItems = tagList.querySelectorAll('.tag-item'); + + countElement.textContent = `(${tagItems.length})`; + } + + getSuggestionsContainer(input) { + return input.parentElement.querySelector('.tag-suggestions'); + } + + hideSuggestions() { + const allSuggestions = document.querySelectorAll('.tag-suggestions'); + allSuggestions.forEach(container => { + container.classList.remove('show'); + }); + this.selectedSuggestionIndex = -1; + } + + getPostIdFromUrl() { + const match = window.location.pathname.match(/\/posts\/(\d+)/); + return match ? match[1] : null; + } + + getTagTypeColor(tagType) { + const colors = { + general: '#4ECDC4', + artist: '#FF6B9D', + character: '#FFB347', + copyright: '#A8E6CF', + meta: '#DDA0DD' + }; + return colors[tagType] || '#E6E6FA'; + } + + showError(message) { + // Find the tag editor container and add error message + const tagEditor = document.querySelector('.tag-editor'); + + // Remove any existing error + const existingError = tagEditor.querySelector('.error'); + if (existingError) { + existingError.remove(); + } + + // Create standard error div + const errorDiv = document.createElement('div'); + errorDiv.className = 'error'; + errorDiv.textContent = message; + + // Insert at the top of tag editor + tagEditor.insertBefore(errorDiv, tagEditor.firstChild); + + // Remove after 3 seconds + setTimeout(() => { + errorDiv.remove(); + }, 3000); + } +} + +// CSS animations for tag items +const style = document.createElement('style'); +style.textContent = ` + @keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes fadeOut { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-10px); } + } +`; +document.head.appendChild(style); + +// Initialize the tag editor +new TagEditor(); |
