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

'; + } + } + }, 300); + }) + .catch(error => { + console.error('Error completing task:', error); + alert('Nie udało się oznaczyć zadania jako wykonane'); + this.checked = false; + }); + }); + }); + + // Handle edit buttons + widget.querySelectorAll('.vikunja-edit-btn').forEach(btn => { + btn.addEventListener('click', function() { + const row = this.closest('tr'); + const taskID = parseInt(row.dataset.taskId); + const taskTitle = this.dataset.taskTitle; + const taskDueDate = this.dataset.taskDueDate; + + // Get current labels + const currentLabels = Array.from(row.querySelectorAll('.label')).map(label => + parseInt(label.dataset.labelId) + ); + + openEditModal(widgetID, taskID, taskTitle, taskDueDate, currentLabels, row); + }); + }); + } +} + +function openEditModal(widgetID, taskID, title, dueDate, currentLabelIDs, row) { + const modal = document.getElementById('vikunja-edit-modal'); + const titleInput = document.getElementById('vikunja-edit-title'); + const dueDateInput = document.getElementById('vikunja-edit-due-date'); + const labelsContainer = document.getElementById('vikunja-labels-container'); + + // Set current values + titleInput.value = title || ''; + + // Convert date format from "2006-01-02 15:04" to datetime-local format "2006-01-02T15:04" + if (dueDate) { + dueDateInput.value = dueDate.replace(' ', 'T'); + } else { + dueDateInput.value = ''; + } + + // Fetch and display labels + labelsContainer.innerHTML = '

Ł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 }} - +
+ + {{ range .Tasks }} - + + @@ -21,13 +26,21 @@ {{ if .Labels }}
{{ range .Labels }} - {{ .Title }} + {{ .Title }} {{ end }}
{{ else }} - {{ end }} + {{ end }} @@ -37,4 +50,34 @@

Brak zadań do wykonania

{{ end }} + + + {{ end }} diff --git a/internal/glance/widget-vikunja.go b/internal/glance/widget-vikunja.go index e7f3177..c31943a 100644 --- a/internal/glance/widget-vikunja.go +++ b/internal/glance/widget-vikunja.go @@ -1,7 +1,9 @@ package glance import ( + "bytes" "context" + "encoding/json" "fmt" "html/template" "net/http" @@ -20,6 +22,7 @@ type vikunjaWidget struct { } type vikunjaTask struct { + ID int Title string DueDate time.Time Done bool @@ -30,6 +33,7 @@ type vikunjaTask struct { } type vikunjaLabel struct { + ID int Title string Color string } @@ -44,6 +48,7 @@ type vikunjaAPITask struct { } type vikunjaAPILabel struct { + ID int `json:"id"` Title string `json:"title"` HexColor string `json:"hex_color"` } @@ -100,6 +105,7 @@ func (widget *vikunjaWidget) fetchTasks() ([]vikunjaTask, error) { } task := vikunjaTask{ + ID: apiTask.ID, Title: apiTask.Title, Done: apiTask.Done, PercentDone: int(apiTask.PercentDone), @@ -122,6 +128,7 @@ func (widget *vikunjaWidget) fetchTasks() ([]vikunjaTask, error) { color = "#" + color } task.Labels[i] = vikunjaLabel{ + ID: label.ID, Title: label.Title, Color: color, } @@ -191,3 +198,84 @@ func formatTimeLeft(now, dueDate time.Time) string { func (widget *vikunjaWidget) Render() template.HTML { return widget.renderTemplate(widget, vikunjaWidgetTemplate) } + +func (widget *vikunjaWidget) completeTask(taskID int) error { + url := fmt.Sprintf("%s/api/v1/tasks/%d", widget.URL, taskID) + + payload := map[string]interface{}{ + "done": true, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + request.Header.Set("Authorization", "Bearer "+widget.Token) + request.Header.Set("Content-Type", "application/json") + + _, err = decodeJsonFromRequest[vikunjaAPITask](defaultHTTPClient, request) + return err +} + +func (widget *vikunjaWidget) updateTask(taskID int, title string, dueDate string, labelIDs []int) error { + url := fmt.Sprintf("%s/api/v1/tasks/%d", widget.URL, taskID) + + payload := map[string]interface{}{ + "title": title, + } + + if dueDate != "" { + payload["due_date"] = dueDate + } + + if labelIDs != nil { + // Vikunja expects label objects with IDs + labels := make([]map[string]interface{}, len(labelIDs)) + for i, labelID := range labelIDs { + labels[i] = map[string]interface{}{ + "id": labelID, + } + } + payload["labels"] = labels + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + request.Header.Set("Authorization", "Bearer "+widget.Token) + request.Header.Set("Content-Type", "application/json") + + _, err = decodeJsonFromRequest[vikunjaAPITask](defaultHTTPClient, request) + return err +} + +func (widget *vikunjaWidget) fetchAllLabels() ([]vikunjaAPILabel, error) { + url := widget.URL + "/api/v1/labels" + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+widget.Token) + + labels, err := decodeJsonFromRequest[[]vikunjaAPILabel](defaultHTTPClient, request) + if err != nil { + return nil, err + } + + return labels, nil +}
Koniec za Treść zadania Etykiety
+ + {{ if .TimeLeft }}{{ .TimeLeft }}{{ else }}-{{ end }} + +