library(shiny) library(shinyTree) # ---- UI ---- ui <- fluidPage( tags$head( tags$style(HTML(" @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@700&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; } body { background: #0f0f0f; color: #e8e4dc; font-family: 'DM Mono', monospace; min-height: 100vh; padding: 40px 20px; } .app-title { font-family: 'Playfair Display', serif; font-size: 2.8rem; color: #f0e6c8; letter-spacing: -1px; margin-bottom: 6px; } .app-subtitle { font-size: 0.75rem; color: #5a5550; letter-spacing: 4px; text-transform: uppercase; margin-bottom: 40px; } .main-container { max-width: 900px; margin: 0 auto; } .panel { background: #1a1a18; border: 1px solid #2e2b26; border-radius: 4px; padding: 28px; margin-bottom: 20px; } .panel-title { font-size: 0.65rem; letter-spacing: 5px; text-transform: uppercase; color: #8a7e6e; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #2e2b26; } .form-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; } .form-group { display: flex; flex-direction: column; gap: 6px; } .form-group label { font-size: 0.65rem; letter-spacing: 3px; text-transform: uppercase; color: #6b6158; } input[type='text'], select { background: #0f0f0f; border: 1px solid #2e2b26; color: #e8e4dc; font-family: 'DM Mono', monospace; font-size: 0.85rem; padding: 10px 14px; border-radius: 3px; outline: none; transition: border-color 0.2s; min-width: 200px; } input[type='text']:focus, select:focus { border-color: #c8a96e; } select option { background: #1a1a18; } .btn-add { background: #c8a96e; color: #0f0f0f; border: none; font-family: 'DM Mono', monospace; font-size: 0.75rem; font-weight: 500; letter-spacing: 3px; text-transform: uppercase; padding: 11px 22px; border-radius: 3px; cursor: pointer; transition: background 0.2s, transform 0.1s; } .btn-add:hover { background: #dbb97e; } .btn-add:active { transform: scale(0.97); } .btn-del { background: transparent; color: #8a4040; border: 1px solid #3d2020; font-family: 'DM Mono', monospace; font-size: 0.7rem; letter-spacing: 2px; text-transform: uppercase; padding: 10px 18px; border-radius: 3px; cursor: pointer; transition: all 0.2s; } .btn-del:hover { background: #2a1515; border-color: #8a4040; color: #c46060; } /* shinyTree overrides */ #todo_tree { margin-top: 10px; } .jstree-default .jstree-anchor { font-family: 'DM Mono', monospace !important; font-size: 0.88rem !important; color: #c8c0b0 !important; padding: 3px 6px !important; } .jstree-default .jstree-hovered { background: #252520 !important; border-radius: 3px !important; box-shadow: none !important; } .jstree-default .jstree-clicked { background: #2a2820 !important; border-left: 2px solid #c8a96e !important; border-radius: 3px !important; box-shadow: none !important; color: #f0e6c8 !important; } .jstree-default .jstree-icon { color: #5a5048 !important; } .jstree-default .jstree-node { margin: 2px 0 !important; } /* category badges */ .badge-arbeit { color: #7ab8d4; } .badge-privat { color: #9dbb7a; } .badge-einkauf { color: #d4a87a; } .badge-projekt { color: #c47ab8; } .status-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; font-size: 0.7rem; color: #5a5550; letter-spacing: 2px; text-transform: uppercase; } #selected_info { font-size: 0.78rem; color: #8a7e6e; padding: 8px 0; min-height: 28px; } ")) ), div(class = "main-container", tags$h1(class = "app-title", "TODO"), tags$p(class = "app-subtitle", "hierarchische aufgabenliste"), # ADD FORM div(class = "panel", div(class = "panel-title", "Neue Aufgabe"), div(class = "form-row", div(class = "form-group", tags$label(`for` = "task_name", "Aufgabe"), textInput("task_name", label = NULL, placeholder = "Aufgabe eingeben…", width = "280px") ), div(class = "form-group", tags$label(`for` = "task_cat", "Kategorie"), selectInput("task_cat", label = NULL, choices = c("Arbeit", "Privat", "Einkauf", "Projekt"), width = "160px") ), div(class = "form-group", tags$label(`for` = "parent_choice", "Unter-Aufgabe von"), selectInput("parent_choice", label = NULL, choices = c("— (Hauptaufgabe)" = ""), width = "220px") ), div(style = "margin-bottom: 1px;", actionButton("add_task", "Hinzufügen", class = "btn-add") ) ) ), # TREE div(class = "panel", div(class = "panel-title", "Aufgaben"), div(id = "selected_info", "Klicke auf eine Aufgabe zum Auswählen"), shinyTree("todo_tree", checkbox = TRUE, theme = "default", themeIcons = TRUE, themeDots = TRUE, dragAndDrop = TRUE ), tags$br(), div(class = "status-bar", div(id = "task_count", ""), actionButton("delete_task", "Auswahl löschen", class = "btn-del") ) ) ) ) # ---- SERVER ---- server <- function(input, output, session) { # Reactive store: list of tasks # Each task: list(id, label, category, parent_id) tasks <- reactiveVal(list( list(id = "t1", label = "Projektplanung", category = "Projekt", parent_id = ""), list(id = "t2", label = "Meeting vorbereiten", category = "Arbeit", parent_id = "t1"), list(id = "t3", label = "Folien erstellen", category = "Arbeit", parent_id = "t1"), list(id = "t4", label = "Einkaufsliste", category = "Einkauf", parent_id = ""), list(id = "t5", label = "Milch & Eier", category = "Einkauf", parent_id = "t4"), list(id = "t6", label = "Sport", category = "Privat", parent_id = "") )) id_counter <- reactiveVal(7) # Helper: build nested list for shinyTree from flat task list build_tree <- function(tlist, parent_id = "") { children <- Filter(function(t) t$parent_id == parent_id, tlist) if (length(children) == 0) return(NULL) result <- lapply(children, function(t) { subtree <- build_tree(tlist, t$id) icon_class <- switch(tolower(t$category), "arbeit" = "glyphicon glyphicon-briefcase", "privat" = "glyphicon glyphicon-heart", "einkauf" = "glyphicon glyphicon-shopping-cart", "projekt" = "glyphicon glyphicon-star", "glyphicon glyphicon-ok" ) node <- list( text = paste0(t$label, " [", t$category, "]"), icon = icon_class, `data-id` = t$id ) attr(node, "stopSign") <- FALSE if (!is.null(subtree)) node$children <- subtree node }) names(result) <- sapply(children, function(t) t$label) result } # Render tree output$todo_tree <- renderTree({ build_tree(tasks()) }) # Update parent selector observe({ tlist <- tasks() choices <- c("— (Hauptaufgabe)" = "") for (t in tlist) choices[t$label] <- t$id updateSelectInput(session, "parent_choice", choices = choices) }) # Task count observe({ n <- length(tasks()) updateTextInput(session, "task_count_dummy") # just trigger }) output$task_count <- renderText(NULL) observe({ n <- length(tasks()) session$sendCustomMessage("updateCount", list(id = "task_count", text = paste(n, "aufgaben gesamt"))) }) tags$script(" Shiny.addCustomMessageHandler('updateCount', function(msg) { document.getElementById(msg.id).textContent = msg.text; }); ") # Add task observeEvent(input$add_task, { req(input$task_name != "") new_id <- paste0("t", id_counter()) id_counter(id_counter() + 1) new_task <- list( id = new_id, label = trimws(input$task_name), category = input$task_cat, parent_id = input$parent_choice ) tasks(c(tasks(), list(new_task))) updateTextInput(session, "task_name", value = "") }) # Show selected observeEvent(input$todo_tree, { sel <- get_selected(input$todo_tree, format = "names") if (length(sel) > 0) { session$sendCustomMessage("updateCount", list(id = "selected_info", text = paste0("✦ Ausgewählt: ", paste(sel, collapse = ", ")))) } else { session$sendCustomMessage("updateCount", list(id = "selected_info", text = "Klicke auf eine Aufgabe zum Auswählen")) } }) # Delete selected tasks (by label matching) observeEvent(input$delete_task, { sel <- get_selected(input$todo_tree, format = "names") if (length(sel) == 0) return() tlist <- tasks() # Get labels without category suffix sel_labels <- trimws(gsub("\\s*\\[.*?\\]$", "", sel)) # IDs to delete (including children recursively) delete_ids <- function(ids_to_delete, all_tasks) { children <- Filter(function(t) t$parent_id %in% ids_to_delete, all_tasks) if (length(children) == 0) return(ids_to_delete) child_ids <- sapply(children, function(t) t$id) c(ids_to_delete, delete_ids(child_ids, all_tasks)) } matched_ids <- sapply( Filter(function(t) t$label %in% sel_labels, tlist), function(t) t$id ) all_del <- delete_ids(matched_ids, tlist) tasks(Filter(function(t) !(t$id %in% all_del), tlist)) }) } shinyApp(ui, server)