Spaces:
Runtime error
Runtime error
| library(shiny) | |
| library(readr) | |
| library(dplyr) | |
| library(ggplot2) | |
| library(jsonlite) | |
| library(httr) | |
| library(base64enc) | |
| github_user <- "jameson-bodenburg" | |
| github_repo <- "NBA_Top100" | |
| github_file <- "nba_votes.csv" | |
| github_branch <- "main" | |
| nba_players <- read_csv("nba_players_df.csv")$Player | |
| get_github_file <- function() { | |
| url <- paste0("https://api.github.com/repos/", | |
| github_user, "/", github_repo, | |
| "/contents/", github_file, | |
| "?ref=", github_branch) | |
| res <- GET(url, add_headers(Authorization = paste("token", Sys.getenv("GITHUB_PAT")))) | |
| stop_for_status(res) | |
| content(res) | |
| } | |
| update_github_file <- function(new_data) { | |
| res <- tryCatch({ | |
| file_info <- get_github_file() | |
| sha <- file_info$sha | |
| tmp <- tempfile(fileext = ".csv") | |
| write_csv(new_data, tmp) | |
| file_contents <- base64encode(tmp) | |
| url <- paste0("https://api.github.com/repos/", github_user, "/", github_repo, | |
| "/contents/", github_file) | |
| body <- list( | |
| message = paste("Update votes at", Sys.time()), | |
| content = file_contents, | |
| sha = sha, | |
| branch = github_branch | |
| ) | |
| r <- PUT(url, | |
| add_headers(Authorization = paste("token", Sys.getenv("GITHUB_PAT"))), | |
| body = body, | |
| encode = "json") | |
| stop_for_status(r) | |
| TRUE | |
| }, error = function(e) { | |
| message("GitHub update failed: ", e$message) | |
| FALSE | |
| }) | |
| return(res) | |
| } | |
| all_votes <- reactiveVal({ | |
| file_info <- get_github_file() | |
| raw_csv <- rawToChar(base64decode(file_info$content)) | |
| df <- read_csv(I(raw_csv), show_col_types = FALSE) | |
| df <- df %>% mutate(rank = as.integer(rank)) | |
| df | |
| }) | |
| ui <- navbarPage( | |
| "NBA Top 100", | |
| tabPanel( | |
| "Submit Ranking", | |
| fluidPage( | |
| tags$head( | |
| tags$script(src = "https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"), | |
| tags$script(src = "https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.min.js"), | |
| tags$link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/awesomplete/1.1.5/awesomplete.css"), | |
| tags$style(HTML(" | |
| #slots_container { | |
| counter-reset: slot-counter; | |
| max-height: 75vh; | |
| overflow-y: auto; | |
| padding: 12px; | |
| border: 1px solid #dcdcdc; | |
| border-radius: 10px; | |
| background: #f9f9f9; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.05); | |
| position: relative; /* ensures dropdown can layer properly */ | |
| } | |
| .slot-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 8px 10px; | |
| border: 1px solid #e0e0e0; | |
| background: #ffffff; | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| transition: all 0.2s ease; | |
| } | |
| .slot-item:hover { | |
| background: #f0f8ff; | |
| transform: translateY(-1px); | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.08); | |
| } | |
| .slot-item::before { | |
| counter-increment: slot-counter; | |
| content: counter(slot-counter) \".\"; | |
| width: 40px; | |
| text-align: right; | |
| font-weight: 700; | |
| margin-right: 6px; | |
| color: #555; | |
| font-family: 'Segoe UI', sans-serif; | |
| } | |
| .drag-handle { | |
| cursor: grab; | |
| user-select: none; | |
| padding: 6px 8px; | |
| border-radius: 4px; | |
| background: #eaeaea; | |
| border: 1px solid #ccc; | |
| font-size: 14px; | |
| transition: background 0.2s ease; | |
| } | |
| .drag-handle:hover { | |
| background: #d0d0d0; | |
| } | |
| .player-box { | |
| flex: 1; | |
| padding: 6px 8px; | |
| border: 1px solid #ccc; | |
| border-radius: 6px; | |
| font-size: 14px; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease; | |
| } | |
| .player-box:focus { | |
| border-color: #3399ff; | |
| box-shadow: 0 0 5px rgba(51,153,255,0.3); | |
| outline: none; | |
| } | |
| .error-msg { | |
| color: #c00; | |
| font-weight: bold; | |
| margin-top: 6px; | |
| } | |
| #success_message { | |
| color: #2a9d8f; | |
| font-weight: bold; | |
| margin-top: 6px; | |
| } | |
| .instructions p { | |
| font-size: 14px; | |
| line-height: 1.5; | |
| color: #333; | |
| } | |
| ")) | |
| ), | |
| titlePanel("Top 100 Players Submission"), | |
| fluidRow( | |
| column( | |
| 4, | |
| h4("Instructions"), | |
| p("Type a player name in each box (autofill helps). Use the β° handle to drag slots up/down; numbers update automatically."), | |
| textInput("user_name", "Your Name (required):", value = ""), | |
| actionButton("submit", "Submit Rankings", class = "btn-primary"), | |
| br(), | |
| textOutput("success_message"), | |
| br(), | |
| textOutput("error_message") | |
| ), | |
| column( | |
| 8, | |
| h4("Ranked Top 100"), | |
| uiOutput("slots_ui") | |
| ) | |
| ) | |
| ) | |
| ), | |
| tabPanel( | |
| "Player Votes", | |
| sidebarLayout( | |
| sidebarPanel( | |
| selectizeInput("selected_player", "Choose a Player:", choices = nba_players, multiple = FALSE) | |
| ), | |
| mainPanel( | |
| plotOutput("player_density"), | |
| textOutput("player_message") | |
| ) | |
| ) | |
| ), | |
| tabPanel( | |
| "Overall Rankings", | |
| mainPanel( | |
| tableOutput("overall_table") | |
| ) | |
| ) | |
| ) | |
| server <- function(input, output, session) { | |
| output$slots_ui <- renderUI({ | |
| div( | |
| id = "slots_container", | |
| lapply(seq_len(100), function(i) { | |
| div( | |
| class = "slot-item", | |
| span(class = "drag-handle", "\u2630"), | |
| tags$input(type = "text", class = "player-box", placeholder = "Type player name...") | |
| ) | |
| }), | |
| tags$script(HTML(sprintf(" | |
| var players = %s; | |
| var container = document.getElementById('slots_container'); | |
| if(container){ | |
| // Attach autocomplete + input listener | |
| container.querySelectorAll('.player-box').forEach(function(input){ | |
| new Awesomplete(input, { list: players, minChars: 2, autoFirst: true }); | |
| input.addEventListener('awesomplete-selectcomplete', function(){ | |
| var values = Array.from(container.querySelectorAll('.player-box')) | |
| .map(el => el.value); | |
| Shiny.setInputValue('slot_values', values, {priority: 'event'}); | |
| }); | |
| input.addEventListener('blur', function(){ | |
| if(players.indexOf(input.value) === -1){ | |
| input.value = ''; // clear invalid entry | |
| } | |
| var values = Array.from(container.querySelectorAll('.player-box')) | |
| .map(el => el.value); | |
| Shiny.setInputValue('slot_values', values, {priority: 'event'}); | |
| }); | |
| }); | |
| // Make sortable | |
| var sortable = Sortable.create(container, { | |
| animation: 150, | |
| handle: '.drag-handle', | |
| onSort: function () { | |
| var values = Array.from(container.querySelectorAll('.player-box')) | |
| .map(el => el.value); | |
| Shiny.setInputValue('slot_values', values, {priority: 'event'}); | |
| } | |
| }); | |
| } | |
| ", jsonlite::toJSON(nba_players)))) | |
| ) | |
| }) | |
| observeEvent(input$submit, { | |
| if (is.null(input$user_name) || input$user_name == "") { | |
| output$error_message <- renderText("Error: Please enter your name before submitting.") | |
| output$success_message <- renderText("") | |
| return() | |
| } | |
| req(input$slot_values) | |
| ranks <- input$slot_values[input$slot_values != ""] | |
| if(any(duplicated(ranks))) { | |
| output$error_message <- renderText("Error: A player was selected more than once.") | |
| output$success_message <- renderText("") | |
| return() | |
| } | |
| output$error_message <- renderText("") | |
| new_data <- data.frame( | |
| user = input$user_name, | |
| player = ranks, | |
| rank = as.integer(seq_along(ranks)), | |
| stringsAsFactors = FALSE | |
| ) | |
| df <- bind_rows(all_votes(), new_data) | |
| ok <- update_github_file(df) | |
| if (isTRUE(ok)) { | |
| all_votes(df) | |
| output$success_message <- renderText("β Your vote was successfully submitted!") | |
| } else { | |
| output$error_message <- renderText("β Failed to submit vote β check your GitHub token or network.") | |
| output$success_message <- renderText("") | |
| } | |
| }) | |
| output$player_density <- renderPlot({ | |
| req(input$selected_player) | |
| df <- all_votes() %>% filter(player == input$selected_player) | |
| if(nrow(df) == 0) return(NULL) | |
| ggplot(df, aes(x = rank)) + | |
| geom_density(fill = "blue", alpha = 0.4) + | |
| xlim(0, 100) + | |
| labs(title = paste("Vote Distribution for", input$selected_player), | |
| x = "Rank", y = "Density") + | |
| theme_minimal() + | |
| theme( | |
| axis.text = element_text(size = 14), | |
| axis.title = element_text(size = 16, face = "bold"), | |
| plot.title = element_text(size = 20, face = "bold", hjust = 0.5) | |
| ) | |
| }) | |
| output$player_message <- renderText({ | |
| req(input$selected_player) | |
| df <- all_votes() %>% filter(player == input$selected_player) | |
| if(nrow(df) == 0) "No votes received" else NULL | |
| }) | |
| output$overall_table <- renderTable({ | |
| df <- all_votes() | |
| if(nrow(df) == 0) return(NULL) | |
| all_players <- unique(df$player) | |
| users <- unique(df$user) | |
| complete_df <- lapply(users, function(u) { | |
| user_df <- df %>% filter(user == u) | |
| missing_players <- setdiff(all_players, user_df$player) | |
| if(length(missing_players) > 0) { | |
| user_df <- bind_rows( | |
| user_df, | |
| data.frame(user = u, player = missing_players, rank = 120) | |
| ) | |
| } | |
| user_df | |
| }) %>% bind_rows() | |
| complete_df %>% | |
| group_by(player) %>% | |
| summarise(avg_rank = mean(rank), votes = sum(rank != 120), .groups = "drop") %>% | |
| arrange(avg_rank) %>% | |
| mutate(Rank = row_number()) %>% | |
| rename(Player = player, | |
| `Average Rank` = avg_rank, | |
| Votes = votes) %>% | |
| select(Rank, Player, `Average Rank`, Votes) | |
| }) | |
| } | |
| shinyApp(ui, server) |