diff --git a/glance b/glance index 9f759a5..10395a7 100755 Binary files a/glance and b/glance differ diff --git a/internal/glance/glance.go b/internal/glance/glance.go index d6a8e90..a751ff4 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "fmt" "log" "net/http" @@ -450,6 +451,9 @@ func (a *application) server() (func() error, func() error) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("GET /api/audio-proxy", a.handleAudioProxyRequest) + mux.HandleFunc("POST /api/vikunja/{widgetID}/complete-task", a.handleVikunjaCompleteTask) + mux.HandleFunc("POST /api/vikunja/{widgetID}/update-task", a.handleVikunjaUpdateTask) + mux.HandleFunc("GET /api/vikunja/{widgetID}/labels", a.handleVikunjaGetLabels) if a.RequiresAuth { mux.HandleFunc("GET /login", a.handleLoginPageRequest) @@ -515,3 +519,126 @@ func (a *application) server() (func() error, func() error) { return start, stop } + +func (a *application) handleVikunjaCompleteTask(w http.ResponseWriter, r *http.Request) { + widgetIDStr := r.PathValue("widgetID") + widgetID, err := strconv.ParseUint(widgetIDStr, 10, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid widget ID")) + return + } + + widget, exists := a.widgetByID[widgetID] + if !exists { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Widget not found")) + return + } + + vikunjaWidget, ok := widget.(*vikunjaWidget) + if !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Widget is not a Vikunja widget")) + return + } + + var request struct { + TaskID int `json:"task_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid request body")) + return + } + + if err := vikunjaWidget.completeTask(request.TaskID); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} + +func (a *application) handleVikunjaUpdateTask(w http.ResponseWriter, r *http.Request) { + widgetIDStr := r.PathValue("widgetID") + widgetID, err := strconv.ParseUint(widgetIDStr, 10, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid widget ID")) + return + } + + widget, exists := a.widgetByID[widgetID] + if !exists { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Widget not found")) + return + } + + vikunjaWidget, ok := widget.(*vikunjaWidget) + if !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Widget is not a Vikunja widget")) + return + } + + var request struct { + TaskID int `json:"task_id"` + Title string `json:"title"` + DueDate string `json:"due_date"` + LabelIDs []int `json:"label_ids"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid request body")) + return + } + + if err := vikunjaWidget.updateTask(request.TaskID, request.Title, request.DueDate, request.LabelIDs); err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success": true}`)) +} + +func (a *application) handleVikunjaGetLabels(w http.ResponseWriter, r *http.Request) { + widgetIDStr := r.PathValue("widgetID") + widgetID, err := strconv.ParseUint(widgetIDStr, 10, 64) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Invalid widget ID")) + return + } + + widget, exists := a.widgetByID[widgetID] + if !exists { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("Widget not found")) + return + } + + vikunjaWidget, ok := widget.(*vikunjaWidget) + if !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Widget is not a Vikunja widget")) + return + } + + labels, err := vikunjaWidget.fetchAllLabels() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(labels) +} diff --git a/internal/glance/static/css/widget-vikunja.css b/internal/glance/static/css/widget-vikunja.css index 3b51cd4..10f5838 100644 --- a/internal/glance/static/css/widget-vikunja.css +++ b/internal/glance/static/css/widget-vikunja.css @@ -111,3 +111,183 @@ border: 2px solid; white-space: nowrap; } + +/* Checkbox styling */ +.vikunja-task-checkbox { + cursor: pointer; + width: 18px; + height: 18px; +} + +/* Edit button styling */ +.vikunja-edit-btn { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-base); + padding: 0.4rem; + border-radius: 0.3rem; + transition: background-color 0.2s, color 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.vikunja-edit-btn:hover { + background-color: var(--color-widget-background-highlight); + color: var(--color-primary); +} + +/* Modal styling */ +.vikunja-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; +} + +.vikunja-modal-content { + background-color: var(--color-widget-background); + border-radius: 0.5rem; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.vikunja-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--color-separator); +} + +.vikunja-modal-header h3 { + margin: 0; + color: var(--color-text-base); + font-size: 1.5rem; +} + +.vikunja-modal-close { + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: var(--color-text-base); + padding: 0; + width: 2rem; + height: 2rem; + line-height: 2rem; + text-align: center; + transition: color 0.2s; +} + +.vikunja-modal-close:hover { + color: var(--color-negative); +} + +.vikunja-modal-body { + padding: 1.5rem; +} + +.vikunja-form-group { + margin-bottom: 1.5rem; +} + +.vikunja-form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--color-text-base); + font-weight: 500; +} + +.vikunja-input { + width: 100%; + padding: 0.7rem; + border: 1px solid var(--color-separator); + border-radius: 0.3rem; + background-color: var(--color-widget-background-highlight); + color: var(--color-text-base); + font-size: 1rem; + box-sizing: border-box; +} + +.vikunja-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.vikunja-labels-selector { + display: flex; + flex-direction: column; + gap: 0.8rem; + max-height: 200px; + overflow-y: auto; + padding: 0.5rem; + border: 1px solid var(--color-separator); + border-radius: 0.3rem; + background-color: var(--color-widget-background-highlight); +} + +.vikunja-label-option { + display: flex; + align-items: center; + gap: 0.8rem; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.3rem; + transition: background-color 0.2s; +} + +.vikunja-label-option:hover { + background-color: var(--color-widget-background); +} + +.vikunja-label-option input[type="checkbox"] { + cursor: pointer; + width: 18px; + height: 18px; +} + +.vikunja-modal-footer { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1.5rem; + border-top: 1px solid var(--color-separator); +} + +.vikunja-btn { + padding: 0.7rem 1.5rem; + border: none; + border-radius: 0.3rem; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: opacity 0.2s, transform 0.1s; +} + +.vikunja-btn:hover { + opacity: 0.9; +} + +.vikunja-btn:active { + transform: scale(0.98); +} + +.vikunja-btn-cancel { + background-color: var(--color-separator); + color: var(--color-text-base); +} + +.vikunja-btn-save { + background-color: var(--color-primary); + color: white; +} diff --git a/internal/glance/static/js/page.js b/internal/glance/static/js/page.js index 69229b3..3196206 100644 --- a/internal/glance/static/js/page.js +++ b/internal/glance/static/js/page.js @@ -664,6 +664,17 @@ async function setupRadyjko() { } } +async function setupVikunja() { + const elems = Array.from(document.getElementsByClassName("widget-type-vikunja")); + if (elems.length == 0) return; + + const vikunja = await import ('./vikunja.js'); + + for (let i = 0; i < elems.length; i++){ + vikunja.default(elems[i]); + } +} + function setupTruncatedElementTitles() { const elements = document.querySelectorAll(".text-truncate, .single-line-titles .title, .text-truncate-2-lines, .text-truncate-3-lines"); @@ -847,6 +858,7 @@ async function setupPage() { await setupCalendars(); await setupTodos(); await setupRadyjko(); + await setupVikunja(); setupCarousels(); setupSearchBoxes(); setupCollapsibleLists(); diff --git a/internal/glance/static/js/vikunja.js b/internal/glance/static/js/vikunja.js new file mode 100644 index 0000000..a649d7a --- /dev/null +++ b/internal/glance/static/js/vikunja.js @@ -0,0 +1,228 @@ +// Vikunja widget interactivity +export default function(widget) { + if (!widget) return; + + { + const widgetID = widget.querySelector('.vikunja-table')?.dataset.widgetId; + if (!widgetID) return; + + // Handle task completion checkboxes + widget.querySelectorAll('.vikunja-task-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', function(e) { + if (!this.checked) { + this.checked = false; + return; + } + + const row = this.closest('tr'); + const taskID = parseInt(row.dataset.taskId); + + if (!confirm('Czy na pewno chcesz oznaczyć to zadanie jako wykonane?')) { + this.checked = false; + return; + } + + // Call API to complete task + fetch(`/api/vikunja/${widgetID}/complete-task`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ task_id: taskID }) + }) + .then(response => { + if (!response.ok) throw new Error('Failed to complete task'); + return response.json(); + }) + .then(data => { + // Remove the row with animation + row.style.transition = 'opacity 0.3s ease'; + row.style.opacity = '0'; + setTimeout(() => { + row.remove(); + // Check if there are no more tasks + const tbody = widget.querySelector('.vikunja-table tbody'); + if (!tbody || tbody.children.length === 0) { + const table = widget.querySelector('.vikunja-table'); + if (table) { + table.innerHTML = '
Brak zadań do wykonania
Ładowanie etykiet...
'; + + fetch(`/api/vikunja/${widgetID}/labels`) + .then(response => response.json()) + .then(labels => { + labelsContainer.innerHTML = ''; + + if (labels && labels.length > 0) { + labels.forEach(label => { + const labelCheckbox = document.createElement('label'); + labelCheckbox.className = 'vikunja-label-option'; + + const color = label.hex_color && label.hex_color[0] !== '#' + ? '#' + label.hex_color + : label.hex_color || '#666'; + + const isChecked = currentLabelIDs.includes(label.id); + + labelCheckbox.innerHTML = ` + + ${label.title} + `; + + labelsContainer.appendChild(labelCheckbox); + }); + } else { + labelsContainer.innerHTML = 'Brak dostępnych etykiet
'; + } + }) + .catch(error => { + console.error('Error fetching labels:', error); + labelsContainer.innerHTML = 'Nie udało się załadować etykiet
'; + }); + + modal.style.display = 'flex'; + + // Handle close button + const closeBtn = modal.querySelector('.vikunja-modal-close'); + const cancelBtn = modal.querySelector('.vikunja-btn-cancel'); + const saveBtn = modal.querySelector('.vikunja-btn-save'); + + function closeModal() { + modal.style.display = 'none'; + // Remove event listeners + closeBtn.removeEventListener('click', closeModal); + cancelBtn.removeEventListener('click', closeModal); + saveBtn.removeEventListener('click', saveTask); + } + + function saveTask() { + const newTitle = titleInput.value.trim(); + const newDueDate = dueDateInput.value; + + // Get selected label IDs + const selectedLabels = Array.from(labelsContainer.querySelectorAll('input[type="checkbox"]:checked')) + .map(checkbox => parseInt(checkbox.value)); + + if (!newTitle) { + alert('Tytuł zadania nie może być pusty'); + return; + } + + // Convert datetime-local format to RFC3339 + let formattedDueDate = ''; + if (newDueDate) { + const date = new Date(newDueDate); + formattedDueDate = date.toISOString(); + } + + // Call API to update task + fetch(`/api/vikunja/${widgetID}/update-task`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + task_id: taskID, + title: newTitle, + due_date: formattedDueDate, + label_ids: selectedLabels + }) + }) + .then(response => { + if (!response.ok) throw new Error('Failed to update task'); + return response.json(); + }) + .then(data => { + // Update the row with new data + const titleCell = row.querySelector('.vikunja-title'); + if (titleCell) { + titleCell.textContent = newTitle; + } + + // Update the edit button's data attributes + const editBtn = row.querySelector('.vikunja-edit-btn'); + if (editBtn) { + editBtn.dataset.taskTitle = newTitle; + if (newDueDate) { + editBtn.dataset.taskDueDate = newDueDate.replace('T', ' '); + } + } + + // Update labels (simplified - would need full refresh for accurate display) + const labelsCell = row.querySelector('.vikunja-labels'); + if (labelsCell && selectedLabels.length > 0) { + const labelContainer = labelsCell.querySelector('.label-container'); + if (labelContainer) { + // For now, just show that labels were updated + // A full page refresh would show the actual labels + alert('Zadanie zostało zaktualizowane. Odśwież stronę, aby zobaczyć wszystkie zmiany.'); + } + } + + closeModal(); + }) + .catch(error => { + console.error('Error updating task:', error); + alert('Nie udało się zaktualizować zadania'); + }); + } + + closeBtn.addEventListener('click', closeModal); + cancelBtn.addEventListener('click', closeModal); + saveBtn.addEventListener('click', saveTask); + + // Close modal when clicking outside + modal.addEventListener('click', function(e) { + if (e.target === modal) { + closeModal(); + } + }); +} diff --git a/internal/glance/templates/vikunja.html b/internal/glance/templates/vikunja.html index f865292..c87c1db 100644 --- a/internal/glance/templates/vikunja.html +++ b/internal/glance/templates/vikunja.html @@ -2,17 +2,22 @@ {{ define "widget-content" }} {{ if .Tasks }} -| Koniec za | Treść zadania | Etykiety | +||
|---|---|---|---|---|
| + + | {{ if .TimeLeft }}{{ .TimeLeft }}{{ else }}-{{ end }} | @@ -21,13 +26,21 @@ {{ if .Labels }}+ + |