Last active
August 22, 2025 07:38
-
-
Save Yandrik/9a5329575db39b78d5a61a63c858218a to your computer and use it in GitHub Desktop.
Vikunja Quick Move / Delete Userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Vikunja Quick Project Switch (try.vikunja.io) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.3 | |
| // @description Adds a button to quickly move tasks to other projects, add tags, and delete tasks in Vikunja. | |
| // @author Yandrik | |
| // @match https://try.vikunja.io/* | |
| // @grant GM_addStyle | |
| // @grant GM_xmlhttpRequest | |
| // ==/UserScript== | |
| // USAGE: | |
| // install in Tampermonkey (create new, copy -> paste) | |
| // There, in the editor: | |
| // ENTER YOUR VICUNJA INSTANCE URL IN @match! | |
| (function() { | |
| 'use strict'; | |
| const debug = false; | |
| let projects = []; | |
| let allLabels = []; | |
| // Function to log messages if debug is true | |
| function log(...messages) { | |
| if (debug) { | |
| console.log('[Vikunja Quick Switch]', ...messages); | |
| } | |
| } | |
| // Function to get the JWT token from local storage | |
| function getJwtToken() { | |
| return localStorage.getItem('token'); | |
| } | |
| // Function to fetch all projects | |
| function fetchProjects() { | |
| log('fetchProjects'); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return; | |
| } | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: '/api/v1/projects', | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| projects = JSON.parse(response.responseText); | |
| log('Projects loaded:', projects); | |
| } else { | |
| log('Failed to fetch projects:', response.statusText); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error fetching projects:', error); | |
| } | |
| }); | |
| } | |
| // Function to fetch all labels | |
| function fetchAllLabels() { | |
| log('fetchAllLabels'); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return Promise.resolve([]); | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: '/api/v1/labels', | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| allLabels = JSON.parse(response.responseText); | |
| log('All labels loaded:', allLabels); | |
| resolve(allLabels); | |
| } else { | |
| log('Failed to fetch labels:', response.statusText); | |
| resolve([]); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error fetching labels:', error); | |
| reject(error); | |
| } | |
| }); | |
| }); | |
| } | |
| // Function to fetch labels for a specific task | |
| function fetchTaskLabels(taskId) { | |
| log('fetchTaskLabels', taskId); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return Promise.resolve([]); | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: `/api/v1/tasks/${taskId}/labels`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| const taskLabels = JSON.parse(response.responseText); | |
| log('Task labels loaded:', taskLabels); | |
| resolve(taskLabels); | |
| } else { | |
| log('Failed to fetch task labels:', response.statusText); | |
| resolve([]); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error fetching task labels:', error); | |
| reject(error); | |
| } | |
| }); | |
| }); | |
| } | |
| const COLORS = [ | |
| '#ffbe0b', | |
| '#fd8a09', | |
| '#fb5607', | |
| '#ff006e', | |
| '#efbdeb', | |
| '#8338ec', | |
| '#5f5ff6', | |
| '#3a86ff', | |
| '#4c91ff', | |
| '#0ead69', | |
| '#25be8b', | |
| '#073b4c', | |
| '#373f47', | |
| ]; | |
| function getRandomColorHex() { | |
| return COLORS[Math.floor(Math.random() * COLORS.length)]; | |
| } | |
| // Function to create a new label | |
| function createLabel(title, hexColor = null) { | |
| if (!hexColor) { | |
| hexColor = getRandomColorHex().replace('#', ''); | |
| } | |
| log('createLabel', title, hexColor); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return Promise.resolve(null); | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'PUT', | |
| url: '/api/v1/labels', | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| data: JSON.stringify({ | |
| title: title, | |
| hex_color: hexColor | |
| }), | |
| onload: function(response) { | |
| if (response.status === 200 || response.status === 201) { | |
| const newLabel = JSON.parse(response.responseText); | |
| log('Label created:', newLabel); | |
| allLabels.push(newLabel); | |
| resolve(newLabel); | |
| } else { | |
| log('Failed to create label:', response.statusText); | |
| resolve(null); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error creating label:', error); | |
| reject(error); | |
| } | |
| }); | |
| }); | |
| } | |
| // Function to add a label to a task | |
| function addLabelToTask(taskId, labelId) { | |
| log('addLabelToTask', taskId, labelId); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return Promise.resolve(false); | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'PUT', | |
| url: `/api/v1/tasks/${taskId}/labels`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| data: JSON.stringify({ | |
| label_id: labelId | |
| }), | |
| onload: function(response) { | |
| if (response.status === 200 || response.status === 201) { | |
| log('Label added to task successfully'); | |
| resolve(true); | |
| } else { | |
| log('Failed to add label to task:', response.statusText); | |
| resolve(false); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error adding label to task:', error); | |
| reject(error); | |
| } | |
| }); | |
| }); | |
| } | |
| // Function to remove a label from a task | |
| function removeLabelFromTask(taskId, labelId) { | |
| log('removeLabelFromTask', taskId, labelId); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return Promise.resolve(false); | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'DELETE', | |
| url: `/api/v1/tasks/${taskId}/labels/${labelId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status === 200 || response.status === 204) { | |
| log('Label removed from task successfully'); | |
| resolve(true); | |
| } else { | |
| log('Failed to remove label from task:', response.statusText); | |
| resolve(false); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error removing label from task:', error); | |
| reject(error); | |
| } | |
| }); | |
| }); | |
| } | |
| // Function to perform fuzzy search on labels | |
| function fuzzySearch(needle, haystack) { | |
| if (!needle) return true; | |
| const needleLower = needle.toLowerCase(); | |
| const haystackLower = haystack.toLowerCase(); | |
| // Simple fuzzy search: check if all characters from needle exist in order in haystack | |
| let needleIndex = 0; | |
| for (let i = 0; i < haystackLower.length && needleIndex < needleLower.length; i++) { | |
| if (haystackLower[i] === needleLower[needleIndex]) { | |
| needleIndex++; | |
| } | |
| } | |
| return needleIndex === needleLower.length; | |
| } | |
| // Function to get text color based on background color | |
| function getTextColor(hexColor) { | |
| // Remove # if present | |
| const color = hexColor.replace('#', ''); | |
| const r = parseInt(color.substr(0, 2), 16); | |
| const g = parseInt(color.substr(2, 2), 16); | |
| const b = parseInt(color.substr(4, 2), 16); | |
| // Calculate luminance | |
| const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; | |
| // Return dark or light text color based on background luminance | |
| return luminance > 0.5 ? 'hsl(215, 27.9%, 16.9%)' : 'hsl(220, 14.3%, 95.9%)'; | |
| } | |
| // Function to create and show the label selector | |
| async function showLabelSelector(event, taskElement) { | |
| const existingSelector = document.getElementById('label-quick-selector'); | |
| if (existingSelector) { | |
| existingSelector.remove(); | |
| } | |
| const button = event.currentTarget; | |
| const rect = button.getBoundingClientRect(); | |
| const taskId = taskElement.querySelector('a.task-link').href.split('/').pop(); | |
| // Fetch data in parallel - always refresh labels | |
| const [taskLabels] = await Promise.all([ | |
| fetchTaskLabels(taskId), | |
| fetchAllLabels() // Always fetch fresh labels | |
| ]); | |
| const selectorContainer = document.createElement('div'); | |
| selectorContainer.id = 'label-quick-selector'; | |
| selectorContainer.className = 'multiselect has-search-results'; | |
| selectorContainer.style.position = 'absolute'; | |
| selectorContainer.style.top = `${window.scrollY + rect.bottom}px`; | |
| selectorContainer.style.left = `${window.scrollX + rect.right - 350}px`; // Position to the left of the button | |
| selectorContainer.style.width = '350px'; | |
| selectorContainer.style.zIndex = '10000'; | |
| selectorContainer.tabIndex = -1; | |
| const controlDiv = document.createElement('div'); | |
| controlDiv.className = 'control'; | |
| const inputWrapper = document.createElement('div'); | |
| inputWrapper.className = 'input-wrapper input has-multiple'; | |
| // Add current task labels as chips | |
| (taskLabels || []).forEach(label => { | |
| const chip = createLabelChip(label, taskId, () => { | |
| // Refresh the selector after removing a label, but don't close it | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); | |
| }); | |
| inputWrapper.appendChild(chip); | |
| }); | |
| const searchInput = document.createElement('input'); | |
| searchInput.type = 'text'; | |
| searchInput.className = 'input'; | |
| searchInput.placeholder = 'Type to add a label…'; | |
| searchInput.style.border = 'none'; | |
| searchInput.style.outline = 'none'; | |
| searchInput.style.backgroundColor = 'transparent'; | |
| inputWrapper.appendChild(searchInput); | |
| controlDiv.appendChild(inputWrapper); | |
| selectorContainer.appendChild(controlDiv); | |
| const searchResults = document.createElement('div'); | |
| searchResults.className = 'search-results'; | |
| selectorContainer.appendChild(searchResults); | |
| let selectedIndex = -1; | |
| function updateSearchResults(query = '', currentTaskLabels = taskLabels) { | |
| searchResults.innerHTML = ''; | |
| selectedIndex = -1; | |
| // Filter available labels (excluding already attached ones) - use actual task labels data | |
| const attachedLabelTitles = (currentTaskLabels || []).map(l => l.title); | |
| const availableLabels = allLabels.filter(label => | |
| !attachedLabelTitles.includes(label.title) && | |
| fuzzySearch(query, label.title) | |
| ); | |
| // Show matching existing labels | |
| availableLabels.forEach((label, index) => { | |
| const resultButton = document.createElement('button'); | |
| resultButton.type = 'button'; | |
| resultButton.className = 'base-button base-button--type-button search-result-button is-fullwidth'; | |
| const labelSpan = document.createElement('span'); | |
| const labelChip = document.createElement('span'); | |
| labelChip.className = 'tag search-result'; | |
| labelChip.style.background = `#${label.hex_color}`; | |
| labelChip.style.color = getTextColor(label.hex_color); | |
| const labelText = document.createElement('span'); | |
| labelText.textContent = label.title; | |
| labelChip.appendChild(labelText); | |
| labelSpan.appendChild(labelChip); | |
| const hintText = document.createElement('span'); | |
| hintText.className = 'hint-text'; | |
| hintText.textContent = 'Click or press enter to select'; | |
| resultButton.appendChild(labelSpan); | |
| resultButton.appendChild(hintText); | |
| resultButton.addEventListener('click', async () => { | |
| await addLabelToTask(taskId, label.id); | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing | |
| }); | |
| searchResults.appendChild(resultButton); | |
| }); | |
| // Show "Add as new label" option if query exists and no exact match among ALL labels (not just available ones) | |
| if (query.trim() && !allLabels.some(l => l.title.toLowerCase() === query.toLowerCase())) { | |
| const createButton = document.createElement('button'); | |
| createButton.type = 'button'; | |
| createButton.className = 'base-button base-button--type-button search-result-button is-fullwidth'; | |
| const labelSpan = document.createElement('span'); | |
| const labelChip = document.createElement('span'); | |
| labelChip.className = 'tag search-result'; | |
| const labelText = document.createElement('span'); | |
| labelText.textContent = query.trim(); | |
| labelChip.appendChild(labelText); | |
| labelSpan.appendChild(labelChip); | |
| const hintText = document.createElement('span'); | |
| hintText.className = 'hint-text'; | |
| hintText.textContent = 'Add this as new label'; | |
| createButton.appendChild(labelSpan); | |
| createButton.appendChild(hintText); | |
| createButton.addEventListener('click', async () => { | |
| const newLabel = await createLabel(query.trim()); | |
| if (newLabel) { | |
| await addLabelToTask(taskId, newLabel.id); | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); // Refresh without closing | |
| } | |
| }); | |
| searchResults.appendChild(createButton); | |
| } | |
| } | |
| // Keyboard navigation | |
| searchInput.addEventListener('keydown', (e) => { | |
| const results = searchResults.querySelectorAll('.search-result-button'); | |
| if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| selectedIndex = Math.min(selectedIndex + 1, results.length - 1); | |
| updateSelection(results); | |
| } else if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| selectedIndex = Math.max(selectedIndex - 1, -1); | |
| updateSelection(results); | |
| } else if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| if (selectedIndex >= 0 && results[selectedIndex]) { | |
| results[selectedIndex].click(); | |
| } else if (searchInput.value.trim()) { | |
| const query = searchInput.value.trim(); | |
| // Check if there's an exact match with an existing label first | |
| const attachedLabelTitles = (taskLabels || []).map(l => l.title); | |
| const exactMatch = allLabels.find(label => | |
| !attachedLabelTitles.includes(label.title) && | |
| label.title.toLowerCase() === query.toLowerCase() | |
| ); | |
| if (exactMatch) { | |
| // Use existing label | |
| addLabelToTask(taskId, exactMatch.id).then(() => { | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); | |
| }); | |
| } else { | |
| // Create new label only if no exact match exists | |
| createLabel(query).then(newLabel => { | |
| if (newLabel) { | |
| return addLabelToTask(taskId, newLabel.id); | |
| } | |
| }).then(() => { | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); | |
| }); | |
| } | |
| } | |
| } else if (e.key === 'Escape') { | |
| selectorContainer.remove(); | |
| } | |
| }); | |
| function updateSelection(results) { | |
| results.forEach((result, index) => { | |
| if (index === selectedIndex) { | |
| result.style.backgroundColor = 'var(--grey-200, #f1f3f9)'; | |
| } else { | |
| result.style.backgroundColor = ''; | |
| } | |
| }); | |
| } | |
| searchInput.addEventListener('input', (e) => { | |
| const currentTaskLabels = e.taskLabels || taskLabels; | |
| updateSearchResults(e.target.value, currentTaskLabels); | |
| }); | |
| updateSearchResults(); | |
| document.body.appendChild(selectorContainer); | |
| // Focus the input | |
| searchInput.focus(); | |
| // Close selector when clicking outside | |
| document.addEventListener('click', function closeSelector(e) { | |
| if (!selectorContainer.contains(e.target) && e.target !== button) { | |
| selectorContainer.remove(); | |
| document.removeEventListener('click', closeSelector); | |
| } | |
| }); | |
| } | |
| // Function to update the task's label display in the task list | |
| function updateTaskLabelsDisplay(taskElement, taskLabels) { | |
| let labelWrapper = taskElement.querySelector('.label-wrapper.labels'); | |
| // If no label wrapper exists and we have labels to display, create it | |
| if (!labelWrapper && taskLabels && taskLabels.length > 0) { | |
| labelWrapper = document.createElement('div'); | |
| labelWrapper.className = 'label-wrapper labels ml-2 mr-1'; | |
| // Add Vue data attributes by copying from existing elements if available | |
| const existingLabelWrapper = document.querySelector('.label-wrapper.labels'); | |
| if (existingLabelWrapper) { | |
| Array.from(existingLabelWrapper.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| labelWrapper.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| // Insert the label wrapper after the task link span | |
| const taskTextDiv = taskElement.querySelector('.tasktext'); | |
| const taskLinkSpan = taskTextDiv.querySelector('span'); | |
| if (taskLinkSpan && taskTextDiv) { | |
| taskTextDiv.insertBefore(labelWrapper, taskLinkSpan.nextSibling); | |
| } else { | |
| log('Could not find proper insertion point for label wrapper'); | |
| return; | |
| } | |
| } | |
| // If still no label wrapper and no labels to display, nothing to do | |
| if (!labelWrapper) { | |
| return; | |
| } | |
| // Get the Vue data attributes from an existing label if any exist | |
| const existingLabel = labelWrapper.querySelector('.tag'); | |
| let outerDataAttrs = ''; | |
| let innerDataAttrs = ''; | |
| if (existingLabel) { | |
| // Extract data attributes from existing labels | |
| const outerAttrs = Array.from(existingLabel.attributes) | |
| .filter(attr => attr.name.startsWith('data-v-')) | |
| .map(attr => `${attr.name}="${attr.value}"`) | |
| .join(' '); | |
| const innerSpan = existingLabel.querySelector('span'); | |
| const innerAttrs = innerSpan ? Array.from(innerSpan.attributes) | |
| .filter(attr => attr.name.startsWith('data-v-')) | |
| .map(attr => `${attr.name}="${attr.value}"`) | |
| .join(' ') : ''; | |
| outerDataAttrs = outerAttrs; | |
| innerDataAttrs = innerAttrs; | |
| } | |
| // Clear existing labels | |
| labelWrapper.innerHTML = ''; | |
| // Add updated labels with proper Vue data attributes | |
| (taskLabels || []).forEach(label => { | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.className = 'tag'; | |
| labelSpan.style.background = `#${label.hex_color}`; | |
| labelSpan.style.color = getTextColor(label.hex_color); | |
| labelSpan.style.marginRight = '0.25rem'; // Add spacing between labels | |
| // Add Vue data attributes for proper styling | |
| if (outerDataAttrs) { | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = `<span class="tag" ${outerDataAttrs}></span>`; | |
| const tempSpan = tempDiv.firstChild; | |
| Array.from(tempSpan.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| labelSpan.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| const labelText = document.createElement('span'); | |
| labelText.textContent = label.title; | |
| // Add Vue data attributes to inner span | |
| if (innerDataAttrs) { | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = `<span ${innerDataAttrs}></span>`; | |
| const tempSpan = tempDiv.firstChild; | |
| Array.from(tempSpan.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| labelText.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| labelSpan.appendChild(labelText); | |
| labelWrapper.appendChild(labelSpan); | |
| }); | |
| } | |
| // Function to refresh the label selector without closing it | |
| async function refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement = null) { | |
| // Fetch fresh data | |
| const [taskLabels] = await Promise.all([ | |
| fetchTaskLabels(taskId), | |
| fetchAllLabels() // Always refresh all labels too | |
| ]); | |
| const inputWrapper = selectorContainer.querySelector('.input-wrapper'); | |
| // Clear existing chips (but keep the input) | |
| const existingChips = inputWrapper.querySelectorAll('.tag'); | |
| existingChips.forEach(chip => chip.remove()); | |
| // Re-add current task labels as chips | |
| (taskLabels || []).forEach(label => { | |
| const chip = createLabelChip(label, taskId, () => { | |
| refreshLabelSelector(taskId, selectorContainer, searchInput, taskElement); | |
| }); | |
| inputWrapper.insertBefore(chip, searchInput); | |
| }); | |
| // Update the task's label display in the task list | |
| if (taskElement) { | |
| updateTaskLabelsDisplay(taskElement, taskLabels); | |
| } | |
| // Clear search input and trigger update to refresh the search results with fresh task labels | |
| searchInput.value = ''; | |
| // Manually call updateSearchResults with the fresh task labels | |
| const updateEvent = new Event('input'); | |
| updateEvent.taskLabels = taskLabels; | |
| searchInput.dispatchEvent(updateEvent); | |
| searchInput.focus(); | |
| } | |
| // Function to create a label chip | |
| function createLabelChip(label, taskId, onRemove) { | |
| const chip = document.createElement('span'); | |
| chip.className = 'tag'; | |
| chip.style.background = `#${label.hex_color}`; | |
| chip.style.color = getTextColor(label.hex_color); | |
| chip.style.margin = '0 0 .25rem .5rem'; | |
| const labelText = document.createElement('span'); | |
| labelText.textContent = label.title; | |
| chip.appendChild(labelText); | |
| const deleteButton = document.createElement('button'); | |
| deleteButton.type = 'button'; | |
| deleteButton.className = 'base-button base-button--type-button delete is-small'; | |
| deleteButton.addEventListener('click', async (e) => { | |
| e.stopPropagation(); | |
| await removeLabelFromTask(taskId, label.id); | |
| if (onRemove) onRemove(); | |
| }); | |
| chip.appendChild(deleteButton); | |
| return chip; | |
| } | |
| // Function to update the task's priority display in the task list | |
| function updateTaskPriorityDisplay(taskElement, priority) { | |
| const taskTextDiv = taskElement.querySelector('.tasktext'); | |
| if (!taskTextDiv) { | |
| log('Could not find tasktext div for priority update'); | |
| return; | |
| } | |
| const taskSpan = taskTextDiv.querySelector('span'); | |
| if (!taskSpan) { | |
| log('Could not find task span for priority update'); | |
| return; | |
| } | |
| // Remove existing priority indicator if it exists | |
| const existingPriorityLabel = taskSpan.querySelector('.priority-label'); | |
| if (existingPriorityLabel) { | |
| existingPriorityLabel.remove(); | |
| } | |
| // Only show priority indicators for priorities 3, 4, and 5 | |
| if (priority >= 3 && priority <= 5) { | |
| // Get Vue data attributes from existing priority elements if available | |
| const existingPriority = document.querySelector('.priority-label'); | |
| let outerDataAttrs = ''; | |
| let iconDataAttrs = ''; | |
| let textDataAttrs = ''; | |
| if (existingPriority) { | |
| // Extract data attributes from existing priority elements | |
| const outerAttrs = Array.from(existingPriority.attributes) | |
| .filter(attr => attr.name.startsWith('data-v-')) | |
| .map(attr => `${attr.name}="${attr.value}"`) | |
| .join(' '); | |
| const iconSpan = existingPriority.querySelector('.icon'); | |
| const iconAttrs = iconSpan ? Array.from(iconSpan.attributes) | |
| .filter(attr => attr.name.startsWith('data-v-')) | |
| .map(attr => `${attr.name}="${attr.value}"`) | |
| .join(' ') : ''; | |
| const textSpan = existingPriority.querySelector('span:not(.icon)'); | |
| const textAttrs = textSpan ? Array.from(textSpan.attributes) | |
| .filter(attr => attr.name.startsWith('data-v-')) | |
| .map(attr => `${attr.name}="${attr.value}"`) | |
| .join(' ') : ''; | |
| outerDataAttrs = outerAttrs; | |
| iconDataAttrs = iconAttrs; | |
| textDataAttrs = textAttrs; | |
| } | |
| // Create priority indicator | |
| const prioritySpan = document.createElement('span'); | |
| // Set classes based on priority level | |
| if (priority === 3) { | |
| prioritySpan.className = 'not-so-high high-priority priority-label pr-2'; | |
| } else { | |
| prioritySpan.className = 'high-priority priority-label pr-2'; | |
| } | |
| // Add Vue data attributes if available | |
| if (outerDataAttrs) { | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = `<span ${outerDataAttrs}></span>`; | |
| const tempSpan = tempDiv.firstChild; | |
| Array.from(tempSpan.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| prioritySpan.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| // Create icon span | |
| const iconSpan = document.createElement('span'); | |
| iconSpan.className = 'icon'; | |
| // Add Vue data attributes to icon span | |
| if (iconDataAttrs) { | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = `<span ${iconDataAttrs}></span>`; | |
| const tempSpan = tempDiv.firstChild; | |
| Array.from(tempSpan.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| iconSpan.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| // Create SVG icon (fa-circle-exclamation) | |
| const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| svg.setAttribute('class', 'svg-inline--fa fa-circle-exclamation'); | |
| svg.setAttribute('aria-hidden', 'true'); | |
| svg.setAttribute('focusable', 'false'); | |
| svg.setAttribute('data-prefix', 'fas'); | |
| svg.setAttribute('data-icon', 'circle-exclamation'); | |
| svg.setAttribute('role', 'img'); | |
| svg.setAttribute('viewBox', '0 0 512 512'); | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| path.setAttribute('class', ''); | |
| path.setAttribute('fill', 'currentColor'); | |
| path.setAttribute('d', 'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z'); | |
| svg.appendChild(path); | |
| iconSpan.appendChild(svg); | |
| prioritySpan.appendChild(iconSpan); | |
| // Create text span | |
| const textSpan = document.createElement('span'); | |
| // Add Vue data attributes to text span | |
| if (textDataAttrs) { | |
| const tempDiv = document.createElement('div'); | |
| tempDiv.innerHTML = `<span ${textDataAttrs}></span>`; | |
| const tempSpanTemp = tempDiv.firstChild; | |
| Array.from(tempSpanTemp.attributes).forEach(attr => { | |
| if (attr.name.startsWith('data-v-')) { | |
| textSpan.setAttribute(attr.name, attr.value); | |
| } | |
| }); | |
| } | |
| // Set text based on priority level | |
| if (priority === 3) { | |
| textSpan.textContent = 'High'; | |
| } else if (priority === 4) { | |
| textSpan.textContent = 'Urgent'; | |
| } else if (priority === 5) { | |
| textSpan.textContent = 'DO NOW'; | |
| } | |
| prioritySpan.appendChild(textSpan); | |
| // Insert the priority indicator before the task link | |
| const taskLink = taskSpan.querySelector('a.task-link'); | |
| if (taskLink) { | |
| taskSpan.insertBefore(prioritySpan, taskLink); | |
| } else { | |
| // Fallback: prepend to the span | |
| taskSpan.insertBefore(prioritySpan, taskSpan.firstChild); | |
| } | |
| } | |
| } | |
| // Function to update task priority | |
| function updateTaskPriority(taskId, priority, taskElement) { | |
| log('updateTaskPriority', taskId, priority, taskElement); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return; | |
| } | |
| // First, fetch the current task details | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: `/api/v1/tasks/${taskId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status !== 200) { | |
| log('Failed to fetch task details:', response.statusText); | |
| alert('Failed to fetch task details before updating priority.'); | |
| return; | |
| } | |
| const task = JSON.parse(response.responseText); | |
| task.priority = priority; | |
| log('task to be updated with priority:', task, 'priority:', priority); | |
| // Now, update the task with the new priority | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: `/api/v1/tasks/${taskId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| data: JSON.stringify(task), | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| log(`Task ${taskId} priority updated to ${priority}`); | |
| // Update the task's priority display in the HTML | |
| updateTaskPriorityDisplay(taskElement, priority); | |
| } else { | |
| log(`Failed to update task priority:`, response); | |
| alert('Failed to update the task priority. Please try again.'); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error updating task priority:', error); | |
| alert('An error occurred while updating the task priority.'); | |
| } | |
| }); | |
| }, | |
| onerror: function(error) { | |
| log('Error fetching task details:', error); | |
| alert('An error occurred while fetching task details.'); | |
| } | |
| }); | |
| } | |
| // Function to move a task to a different project | |
| function moveTask(taskId, projectId, taskElement) { | |
| log('moveTask', taskId, projectId, taskElement); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return; | |
| } | |
| // First, fetch the current task details | |
| GM_xmlhttpRequest({ | |
| method: 'GET', | |
| url: `/api/v1/tasks/${taskId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status !== 200) { | |
| log('Failed to fetch task details:', response.statusText); | |
| alert('Failed to fetch task details before moving.'); | |
| return; | |
| } | |
| const task = JSON.parse(response.responseText); | |
| task.project_id = projectId; | |
| log('task to be updated:', task, 'projID:', projectId); | |
| // Now, update the task with the new project_id | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: `/api/v1/tasks/${taskId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| data: JSON.stringify(task), | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| log(`Task ${taskId} moved to project ${projectId}`); | |
| // Remove the task from the list only if we're in a specific project view with a real project ID | |
| const match = window.location.pathname.match(/\/projects\/(\d+)/); | |
| if (match && parseInt(match[1]) >= 0) { | |
| taskElement.remove(); | |
| } else { | |
| // Update the project link and text if task remains visible | |
| const projectLink = taskElement.querySelector('.task-project'); | |
| if (projectLink) { | |
| const targetProject = projects.find(p => p.id === projectId); | |
| if (targetProject) { | |
| projectLink.href = `/projects/${projectId}`; | |
| projectLink.textContent = targetProject.title; | |
| } | |
| } | |
| } | |
| } else { | |
| log(`Failed to move task:`); | |
| log(response); | |
| alert('Failed to move the task. Please try again.'); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error moving task:', error); | |
| alert('An error occurred while moving the task.'); | |
| } | |
| }); | |
| }, | |
| onerror: function(error) { | |
| log('Error fetching task details:', error); | |
| alert('An error occurred while fetching task details.'); | |
| } | |
| }); | |
| } | |
| // Function to delete a task | |
| function deleteTask(taskId, taskElement) { | |
| log('deleteTask', taskId); | |
| const token = getJwtToken(); | |
| if (!token) { | |
| log('JWT Token not found.'); | |
| return; | |
| } | |
| GM_xmlhttpRequest({ | |
| method: 'DELETE', | |
| url: `/api/v1/tasks/${taskId}`, | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| onload: function(response) { | |
| if (response.status === 204 || response.status === 200) { // Successfully deleted | |
| log(`Task ${taskId} deleted`); | |
| taskElement.remove(); | |
| } else { | |
| log(`Failed to delete task:`, response); | |
| alert('Failed to delete the task. Please try again.'); | |
| } | |
| }, | |
| onerror: function(error) { | |
| log('Error deleting task:', error); | |
| alert('An error occurred while deleting the task.'); | |
| } | |
| }); | |
| } | |
| // Function to create and show the priority selector | |
| function showPrioritySelector(event, taskElement) { | |
| const existingSelector = document.getElementById('priority-quick-selector'); | |
| if (existingSelector) { | |
| existingSelector.remove(); | |
| } | |
| const button = event.currentTarget; | |
| const rect = button.getBoundingClientRect(); | |
| const taskId = taskElement.querySelector('a.task-link').href.split('/').pop(); | |
| const selectorContainer = document.createElement('div'); | |
| selectorContainer.id = 'priority-quick-selector'; | |
| selectorContainer.className = 'priority-selector'; | |
| selectorContainer.style.position = 'absolute'; | |
| selectorContainer.style.top = `${window.scrollY + rect.bottom}px`; | |
| selectorContainer.style.left = `${window.scrollX + rect.right - 200}px`; // Position to the left of the button | |
| selectorContainer.style.width = '200px'; | |
| selectorContainer.style.zIndex = '10000'; | |
| selectorContainer.style.backgroundColor = 'var(--white, #fff)'; | |
| selectorContainer.style.border = '1px solid var(--primary, #1FB6F6)'; | |
| selectorContainer.style.borderRadius = '4px'; | |
| selectorContainer.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; | |
| const priorities = [ | |
| { value: 0, label: 'Unset', color: '#6b7280' }, | |
| { value: 1, label: 'Low', color: '#10b981' }, | |
| { value: 2, label: 'Medium', color: '#f59e0b' }, | |
| { value: 3, label: 'High', color: '#ef4444' }, | |
| { value: 4, label: 'Urgent', color: '#dc2626' }, | |
| { value: 5, label: 'DO NOW!', color: '#991b1b' } | |
| ]; | |
| priorities.forEach((priority, index) => { | |
| const priorityButton = document.createElement('button'); | |
| priorityButton.type = 'button'; | |
| priorityButton.className = 'priority-option'; | |
| priorityButton.style.width = '100%'; | |
| priorityButton.style.display = 'flex'; | |
| priorityButton.style.alignItems = 'center'; | |
| priorityButton.style.padding = '.75rem'; | |
| priorityButton.style.border = 'none'; | |
| priorityButton.style.backgroundColor = 'transparent'; | |
| priorityButton.style.textAlign = 'left'; | |
| priorityButton.style.cursor = 'pointer'; | |
| priorityButton.style.transition = 'background-color .15s ease'; | |
| if (index > 0) { | |
| priorityButton.style.borderTop = '1px solid var(--grey-200, #f1f3f9)'; | |
| } | |
| const priorityIndicator = document.createElement('span'); | |
| priorityIndicator.className = 'priority-indicator'; | |
| priorityIndicator.style.display = 'inline-block'; | |
| priorityIndicator.style.width = '12px'; | |
| priorityIndicator.style.height = '12px'; | |
| priorityIndicator.style.borderRadius = '50%'; | |
| priorityIndicator.style.backgroundColor = priority.color; | |
| priorityIndicator.style.marginRight = '0.75rem'; | |
| priorityIndicator.style.flexShrink = '0'; | |
| const priorityText = document.createElement('span'); | |
| priorityText.textContent = priority.label; | |
| priorityText.style.fontWeight = '500'; | |
| priorityButton.appendChild(priorityIndicator); | |
| priorityButton.appendChild(priorityText); | |
| priorityButton.addEventListener('mouseenter', () => { | |
| priorityButton.style.backgroundColor = 'var(--grey-200, #f1f3f9)'; | |
| }); | |
| priorityButton.addEventListener('mouseleave', () => { | |
| priorityButton.style.backgroundColor = 'transparent'; | |
| }); | |
| priorityButton.addEventListener('click', () => { | |
| updateTaskPriority(taskId, priority.value, taskElement); | |
| selectorContainer.remove(); | |
| }); | |
| selectorContainer.appendChild(priorityButton); | |
| }); | |
| document.body.appendChild(selectorContainer); | |
| // Close the selector if clicking outside | |
| document.addEventListener('click', function closeSelector(e) { | |
| if (!selectorContainer.contains(e.target) && e.target !== button) { | |
| selectorContainer.remove(); | |
| document.removeEventListener('click', closeSelector); | |
| } | |
| }); | |
| // Close on escape key | |
| document.addEventListener('keydown', function closeOnEscape(e) { | |
| if (e.key === 'Escape') { | |
| selectorContainer.remove(); | |
| document.removeEventListener('keydown', closeOnEscape); | |
| } | |
| }); | |
| } | |
| // Function to create and show the project selection combobox | |
| function showProjectSelector(event, taskElement) { | |
| const existingSelector = document.getElementById('project-quick-switcher'); | |
| if (existingSelector) { | |
| existingSelector.remove(); | |
| } | |
| const button = event.currentTarget; | |
| const rect = button.getBoundingClientRect(); | |
| const selectorContainer = document.createElement('div'); | |
| selectorContainer.id = 'project-quick-switcher'; | |
| selectorContainer.className = 'project-selector'; | |
| selectorContainer.style.position = 'absolute'; | |
| selectorContainer.style.top = `${window.scrollY + rect.bottom}px`; | |
| selectorContainer.style.left = `${window.scrollX + rect.left}px`; | |
| selectorContainer.style.width = '250px'; | |
| selectorContainer.style.zIndex = '10000'; | |
| const searchInput = document.createElement('input'); | |
| searchInput.type = 'text'; | |
| searchInput.className = 'project-search-input'; | |
| searchInput.placeholder = 'Search projects...'; | |
| selectorContainer.appendChild(searchInput); | |
| const projectList = document.createElement('ul'); | |
| projectList.className = 'project-list'; | |
| function populateProjectList(filter = '') { | |
| projectList.innerHTML = ''; | |
| const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(filter.toLowerCase())); | |
| filteredProjects.forEach(project => { | |
| const listItem = document.createElement('li'); | |
| listItem.className = 'project-option'; | |
| listItem.textContent = project.title; | |
| listItem.addEventListener('click', () => { | |
| const taskId = taskElement.querySelector('a.task-link').href.split('/').pop(); | |
| moveTask(taskId, project.id, taskElement); | |
| selectorContainer.remove(); | |
| }); | |
| projectList.appendChild(listItem); | |
| }); | |
| } | |
| populateProjectList(); | |
| selectorContainer.appendChild(projectList); | |
| searchInput.addEventListener('input', (e) => { | |
| populateProjectList(e.target.value); | |
| }); | |
| searchInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| const query = searchInput.value.trim(); | |
| if (query.length > 1) { | |
| const filteredProjects = projects.filter(p => p.id >= 0 && p.title.toLowerCase().includes(query.toLowerCase())); | |
| if (filteredProjects.length > 0) { | |
| const taskId = taskElement.querySelector('a.task-link').href.split('/').pop(); | |
| moveTask(taskId, filteredProjects[0].id, taskElement); | |
| selectorContainer.remove(); | |
| } | |
| } | |
| } | |
| }); | |
| document.body.appendChild(selectorContainer); | |
| // Focus the input on non-touch devices | |
| if (!('ontouchstart' in window)) { | |
| searchInput.focus(); | |
| } | |
| // Close the selector if clicking outside | |
| document.addEventListener('click', function closeSelector(e) { | |
| if (!selectorContainer.contains(e.target) && e.target !== button) { | |
| selectorContainer.remove(); | |
| document.removeEventListener('click', closeSelector); | |
| } | |
| }); | |
| } | |
| // Function to add the control buttons to a task | |
| function addControlButtons(taskElement) { | |
| if (taskElement.querySelector('.control-btn')) { | |
| return; // Buttons already added | |
| } | |
| const favoriteButton = taskElement.querySelector('.favorite'); | |
| if (favoriteButton) { | |
| const buttonContainer = document.createElement('span'); | |
| buttonContainer.className = 'control-buttons'; | |
| // '#' button | |
| const tagButton = document.createElement('button'); | |
| tagButton.textContent = '#'; | |
| tagButton.className = 'base-button base-button--type-button favorite control-btn'; | |
| tagButton.addEventListener('click', (event) => { | |
| event.stopPropagation(); | |
| showLabelSelector(event, taskElement); | |
| }); | |
| buttonContainer.appendChild(tagButton); | |
| // '@' button | |
| const switchButton = document.createElement('button'); | |
| switchButton.textContent = '@'; | |
| switchButton.className = 'base-button base-button--type-button favorite control-btn quick-switch-btn'; | |
| switchButton.addEventListener('click', (event) => { | |
| event.stopPropagation(); | |
| showProjectSelector(event, taskElement); | |
| }); | |
| buttonContainer.appendChild(switchButton); | |
| // '!' button (Priority) | |
| const priorityButton = document.createElement('button'); | |
| priorityButton.textContent = '!'; | |
| priorityButton.className = 'base-button base-button--type-button favorite control-btn priority-btn'; | |
| priorityButton.addEventListener('click', (event) => { | |
| event.stopPropagation(); | |
| showPrioritySelector(event, taskElement); | |
| }); | |
| buttonContainer.appendChild(priorityButton); | |
| // Delete button | |
| const deleteButton = document.createElement('button'); | |
| deleteButton.textContent = 'X'; | |
| deleteButton.className = 'base-button base-button--type-button favorite control-btn delete-btn'; | |
| let deleteTimeout = null; | |
| deleteButton.addEventListener('click', (event) => { | |
| event.stopPropagation(); | |
| if (deleteButton.classList.contains('delete-confirm')) { | |
| clearTimeout(deleteTimeout); | |
| const taskId = taskElement.querySelector('a.task-link').href.split('/').pop(); | |
| deleteTask(taskId, taskElement); | |
| } else { | |
| deleteButton.classList.add('delete-confirm'); | |
| // Blur the button to remove focus, so hover-out works correctly | |
| deleteButton.blur(); | |
| deleteTimeout = setTimeout(() => { | |
| deleteButton.classList.remove('delete-confirm'); | |
| }, 3000); | |
| } | |
| }); | |
| buttonContainer.appendChild(deleteButton); | |
| favoriteButton.parentNode.insertBefore(buttonContainer, favoriteButton); | |
| } | |
| } | |
| // Use MutationObserver to detect when tasks are added to the DOM | |
| let observer = null; | |
| let currentTargetNode = null; | |
| let currentUrl = window.location.href; | |
| function createObserver() { | |
| return new MutationObserver((mutationsList, observer) => { | |
| for (const mutation of mutationsList) { | |
| if (mutation.type === 'childList') { | |
| mutation.addedNodes.forEach(node => { | |
| if (node.nodeType === 1) { // ELEMENT_NODE | |
| if (node.matches('.task.single-task')) { | |
| addControlButtons(node); | |
| } | |
| node.querySelectorAll('.task.single-task').forEach(addControlButtons); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| // Start observing the main content area for changes | |
| function startObserver() { | |
| // First, disconnect any existing observer | |
| if (observer) { | |
| observer.disconnect(); | |
| } | |
| const targetNode = document.querySelector('.card-content'); | |
| if (targetNode && targetNode !== currentTargetNode) { | |
| currentTargetNode = targetNode; | |
| observer = createObserver(); | |
| observer.observe(targetNode, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| // Initial run for tasks already on the page | |
| targetNode.querySelectorAll('.task.single-task').forEach(addControlButtons); | |
| log('Observer started for target:', targetNode); | |
| } else if (!targetNode) { | |
| // If the target node is not yet available, try again after a short delay | |
| currentTargetNode = null; | |
| setTimeout(startObserver, 500); | |
| } | |
| } | |
| // Add a periodic check to ensure the observer is still working | |
| function ensureObserverActive() { | |
| const newUrl = window.location.href; | |
| const targetNode = document.querySelector('.card-content'); | |
| // Check if URL changed (client-side navigation) | |
| if (newUrl !== currentUrl) { | |
| log('URL changed, restarting observer'); | |
| currentUrl = newUrl; | |
| currentTargetNode = null; | |
| startObserver(); | |
| return; | |
| } | |
| // Check if target node changed or disappeared | |
| if (targetNode && targetNode !== currentTargetNode) { | |
| log('Target node changed, restarting observer'); | |
| startObserver(); | |
| } else if (!targetNode && currentTargetNode) { | |
| log('Target node removed, will restart when available'); | |
| currentTargetNode = null; | |
| if (observer) { | |
| observer.disconnect(); | |
| observer = null; | |
| } | |
| startObserver(); | |
| } | |
| } | |
| // Initial setup | |
| log('Userscript loaded.'); | |
| fetchProjects(); | |
| fetchAllLabels(); | |
| startObserver(); | |
| // Check every 2 seconds if the observer is still active | |
| setInterval(ensureObserverActive, 2000); | |
| // Add some basic styling for our buttons and label selector | |
| GM_addStyle(` | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); } | |
| 20%, 40%, 60%, 80% { transform: translateX(2px); } | |
| } | |
| .delete-btn.delete-confirm { | |
| color: red !important; | |
| animation: shake 0.5s !important; | |
| } | |
| .control-btn { | |
| text-align: center; | |
| width: 27px; | |
| transition: opacity .15s ease,color .15s ease; | |
| border-radius: 4px; | |
| opacity: 1; | |
| margin-right: 5px; | |
| } | |
| /* Responsive padding for small screens */ | |
| @media (max-width: 768px) { | |
| .control-btn { | |
| margin-right: -8px; | |
| } | |
| } | |
| @media (hover: hover) and (pointer: fine) { | |
| .task .control-btn { | |
| opacity: 0; | |
| } | |
| .task:hover .control-btn, .task:focus-within .control-btn { | |
| opacity: 1; | |
| } | |
| } | |
| /* Label selector styling to match native Vikunja */ | |
| #label-quick-selector { | |
| position: relative; | |
| } | |
| #label-quick-selector .control { | |
| box-sizing: border-box; | |
| clear: both; | |
| font-size: 1rem; | |
| position: relative; | |
| text-align: inherit; | |
| } | |
| #label-quick-selector .input-wrapper { | |
| padding: .25rem !important; | |
| background: var(--white, #fff); | |
| border-color: var(--primary, #1FB6F6); | |
| border: 1px solid; | |
| border-radius: 4px 4px 0 0; | |
| flex-wrap: wrap; | |
| height: auto; | |
| display: flex; | |
| align-items: flex-start; | |
| min-height: 2.5em; | |
| } | |
| #label-quick-selector .input-wrapper .input { | |
| flex: 1; | |
| min-width: 100px; | |
| margin: 0; | |
| padding: calc(.5em - 1px) 0; | |
| height: auto; | |
| } | |
| #label-quick-selector .search-results { | |
| background: var(--white, #fff); | |
| border: 1px solid var(--primary, #1FB6F6); | |
| border-top: none; | |
| border-radius: 0 0 4px 4px; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| } | |
| #label-quick-selector .search-result-button { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: .5rem; | |
| border: none; | |
| background: transparent; | |
| text-align: left; | |
| cursor: pointer; | |
| transition: background-color .15s ease; | |
| } | |
| #label-quick-selector .search-result-button:hover { | |
| background-color: var(--grey-200, #f1f3f9) !important; | |
| } | |
| #label-quick-selector .tag { | |
| display: inline-flex; | |
| align-items: center; | |
| padding: .25em .75em; | |
| border-radius: 4px; | |
| font-size: .75rem; | |
| font-weight: 600; | |
| line-height: 1; | |
| white-space: nowrap; | |
| } | |
| #label-quick-selector .tag.search-result { | |
| margin-right: .5rem; | |
| } | |
| #label-quick-selector .hint-text { | |
| color: var(--text-light, #7a7a7a); | |
| font-size: .875rem; | |
| } | |
| #label-quick-selector .delete { | |
| background: none; | |
| border: none; | |
| color: inherit; | |
| cursor: pointer; | |
| opacity: .7; | |
| transition: opacity .15s ease; | |
| padding: 0; | |
| margin-left: .5rem; | |
| width: 12px; | |
| height: 12px; | |
| position: relative; | |
| } | |
| #label-quick-selector .delete:hover { | |
| opacity: 1; | |
| } | |
| #label-quick-selector .delete::before, | |
| #label-quick-selector .delete::after { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 8px; | |
| height: 1px; | |
| background: currentColor; | |
| transform: translate(-50%, -50%) rotate(45deg); | |
| } | |
| #label-quick-selector .delete::after { | |
| transform: translate(-50%, -50%) rotate(-45deg); | |
| } | |
| /* Handle dark mode if present */ | |
| :root.dark #label-quick-selector .input-wrapper { | |
| background: var(--grey-50, #0D1117); | |
| border-color: var(--primary, #1FB6F6); | |
| } | |
| :root.dark #label-quick-selector .search-results { | |
| background: var(--grey-50, #0D1117); | |
| border-color: var(--primary, #1FB6F6); | |
| } | |
| :root.dark #label-quick-selector .search-result-button:hover { | |
| background-color: var(--grey-100, #21262D) !important; | |
| } | |
| /* Priority selector styling to match native Vikunja */ | |
| #priority-quick-selector { | |
| position: relative; | |
| } | |
| #priority-quick-selector .priority-option:hover { | |
| background-color: var(--grey-200, #f1f3f9) !important; | |
| } | |
| /* Handle dark mode for priority selector */ | |
| :root.dark #priority-quick-selector { | |
| background-color: var(--grey-50, #0D1117) !important; | |
| border-color: var(--primary, #1FB6F6) !important; | |
| } | |
| :root.dark #priority-quick-selector .priority-option { | |
| border-top-color: var(--grey-100, #21262D) !important; | |
| color: var(--text, #fff) !important; | |
| } | |
| :root.dark #priority-quick-selector .priority-option:hover { | |
| background-color: var(--grey-100, #21262D) !important; | |
| } | |
| /* Project selector styling to match native Vikunja */ | |
| #project-quick-switcher { | |
| position: relative; | |
| background: var(--white, #fff); | |
| border: 1px solid var(--primary, #1FB6F6); | |
| border-radius: 4px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| } | |
| #project-quick-switcher .project-search-input { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: .75rem; | |
| border: none; | |
| border-bottom: 1px solid var(--grey-200, #f1f3f9); | |
| border-radius: 4px 4px 0 0; | |
| background: transparent; | |
| font-size: .875rem; | |
| outline: none; | |
| } | |
| #project-quick-switcher .project-list { | |
| list-style: none; | |
| margin: 0; | |
| padding: 0; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| } | |
| #project-quick-switcher .project-option { | |
| padding: .75rem; | |
| cursor: pointer; | |
| transition: background-color .15s ease; | |
| font-size: .875rem; | |
| border-top: 1px solid var(--grey-200, #f1f3f9); | |
| } | |
| #project-quick-switcher .project-option:first-child { | |
| border-top: none; | |
| } | |
| #project-quick-switcher .project-option:hover { | |
| background-color: var(--grey-200, #f1f3f9) !important; | |
| } | |
| /* Handle dark mode for project selector */ | |
| :root.dark #project-quick-switcher { | |
| background-color: var(--grey-50, #0D1117) !important; | |
| border-color: var(--primary, #1FB6F6) !important; | |
| } | |
| :root.dark #project-quick-switcher .project-search-input { | |
| border-bottom-color: var(--grey-100, #21262D) !important; | |
| color: var(--text, #fff) !important; | |
| } | |
| :root.dark #project-quick-switcher .project-option { | |
| border-top-color: var(--grey-100, #21262D) !important; | |
| color: var(--text, #fff) !important; | |
| } | |
| :root.dark #project-quick-switcher .project-option:hover { | |
| background-color: var(--grey-100, #21262D) !important; | |
| } | |
| `); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Updated (v1.3): Priority Switching
Supported right now:
Not yet supported:
Original Script (Version 1.2)