NBA_Top100 / app.R
jameson-bodenburg's picture
Update app.R
14ee187 verified
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)