aboutsummaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-19 17:06:56 +0530
committerBobby <[email protected]>2025-07-19 17:06:56 +0530
commit3f73b3c66de04a55bc101ffb96070ae19e7bf27a (patch)
tree85ca777a49e3ec533b2fbc3c709ceb6e093c89c0 /static
parentd31111cf0133b223a8e665e6798b8ae09aa5c8a9 (diff)
downloadimageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.tar.xz
imageboard-3f73b3c66de04a55bc101ffb96070ae19e7bf27a.zip
tag adding feature for images
Diffstat (limited to 'static')
-rw-r--r--static/css/main.css252
-rw-r--r--static/scripts/tagEditor.js446
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();