diff --git a/global.R b/global.R new file mode 100644 index 0000000..e890c1d --- /dev/null +++ b/global.R @@ -0,0 +1,2 @@ +# global.R + diff --git a/server.R b/server.R index 0cfe296..a57e42b 100644 --- a/server.R +++ b/server.R @@ -25,6 +25,7 @@ library(promises) # load the sensincluesr package library(devtools) devtools::install_github("sensingclues/sensingcluesr@v1.0.3", upgrade = "never") +#library(sensingcluesr) # dynamic color maps for more then 12 colors library(colorRamps) @@ -36,7 +37,7 @@ plan(multisession) # source function lib source("functions.R") - +source("ui_login.R") ## Some language package stuff ### CB: added these lines for treeToJSON @@ -157,51 +158,16 @@ server <- function(input, output, session) { # -- MAKE POP UP MODAL FOR ENTERING USER CREDENTIALS AND DATA - # Return the UI for a modal dialog with data selection input. If 'failed' # is TRUE, then display a message that the previous value was invalid. - dataModal <- function(failed = FALSE) { - modalDialog( - tags$script(HTML(js)), - title = i18n$t("labels.clueyCredentials"), #"Cluey credentials", - # the selectbox for a server will only show in apps for testing - if (grepl("test", session$clientData$url_pathname)) { - message(paste("Adding selectbox for server because we are running on", session$clientData$url_pathname)) - selectInput("server", label = "Server", - choices = c("focus.sensingclues", "focus.test.sensingclues"), - selected = "focus.sensingclues") - }, - selectInput("lang", label = i18n$t("labels.chooseLanguage"), - choices = language_table$lang_short, selected = session$userData$sel_lang), #lang_short), - textInput("username", i18n$t("labels.Cluey-username")), - passwordInput("password", i18n$t("labels.Cluey-password")), - size = "s", - if (failed) - div(tags$b(i18n$t("labels.invalid-credential"), style = "color: red;")), - footer = tagList( - actionButton("ok", "OK") - ) - ) - } - - resetModal <- function() { + dataModal <- function() { modalDialog( - title = i18n$t("commands.logout"), - size = "s", - footer = tagList( - modalButton(i18n$t("commands.cancel")), - actionButton("reset", "OK") - ) - ) - } - resetModal <- function() { - modalDialog( - title = i18n$t("commands.logout"), - size = "s", - footer = tagList( - modalButton(i18n$t("commands.cancel")), - actionButton("reset", "OK") - ) + mod_login_ui("login", browser_path = session$clientData$url_pathname), + title = div(style = "text-align: center; width: 100%;", i18n$t("labels.clueyCredentials")), + size = "s", + footer = NULL, + easyClose = FALSE, + fade = TRUE ) } @@ -211,15 +177,15 @@ server <- function(input, output, session) { showModal(dataModal()) }) - # When OK button is pressed, attempt to authenticate. If successful, # remove the modal. obs2 <- observe({ req(input$ok) isolate({ - Username <- input$username - Password <- input$password + Username <- input$username + Password <- input$password + session$userData$clueyUser <- Username }) @@ -232,44 +198,51 @@ server <- function(input, output, session) { session$userData$url <- "https://focus.test.sensingclues.org/" } } - message(paste("LOGGING INTO", session$userData$url)) + message(paste0("LOGGING INTO ", session$userData$url)) - session$userData$cookie_mt <- sensingcluesr::login_cluey(username = Username, password = Password, url = session$userData$url) + session$userData$cookie_mt <- sensingcluesr::login_cluey(username = Username, + password = Password, + url = session$userData$url) if (!is.null(session$userData$cookie_mt)) { session$userData$authenticated <- TRUE obs1$suspend() removeModal() # after successful login - session$userData$hierarchy <- sensingcluesr::get_hierarchy(url = session$userData$url, lang = session$userData$lang_short) + session$userData$hierarchy <- sensingcluesr::get_hierarchy(url = session$userData$url, + lang = session$userData$lang_short) session$userData$concepts <- session$userData$hierarchy$concepts - + # get groups needs to be done only once + # debug + # message(paste0("Get initial groups for ",session$userData$clueyUser,' from ',from,' to ',to)) + # session$userData$groups <- sensingcluesr::get_groups(from = from, + # to = to, + # cookie = session$userData$cookie_mt, + # url = session$userData$url) # put start en end date in dateRangeInput updateDateRangeInput(session, "DateRange", start = isolate(session$userData$date_from), - end = isolate(session$userData$date_to), - max = Sys.Date()) + end = isolate(session$userData$date_to)) # which layers are available to the user - layers <- sensingcluesr::get_layer_details(cookie = session$userData$cookie_mt, url = session$userData$url) - # filter layers of the (Multi)Polygon type for the per area tab - session$userData$layers <- layers %>% filter(geometryType %in% c("Polygon", "MultiPolygon")) + session$userData$layers <- sensingcluesr::get_layer_details(cookie = session$userData$cookie_mt, url = session$userData$url) # message(paste0("LAYERS ", paste(session$userData$layers, sep = "|"))) + updateSelectInput(session, "MapLayers", choices = c(i18n$t("labels.noneSelected"), sort(unlist(session$userData$layers$layerName)))) + session$userData$selectedLayer <- i18n$t("labels.noneSelected") # enable input fields/buttons enable("DateRange") enable("GroupListDiv") - enable("GetData") + enable("BuildMap") + enable("MapLayers") } else { session$userData$authenticated <- FALSE # inform user - showModal(dataModal(failed = TRUE)) - } + showNotification(i18n$t("labels.invalid-credential"), type = "error") + } } ) - # -- End modal stuff -- - # Evt. taal wijzigen obs3 <- observe({ @@ -287,15 +260,30 @@ server <- function(input, output, session) { # ------- OUTPUT SIDE - SIDE PANEL SHOWING GROUPS, DATA DIVIDED IN TREE --------- output$userstatus <- renderUI({ - # session$user is non-NULL only in authenticated sessions req(input$ok) isolate({ Username <- input$username }) - if (session$userData$authenticated) { - strong(paste(i18n$t("labels.connectedAs"), Username)) + if (isTRUE(session$userData$authenticated)) { + tags$div( + style = "color: white; display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 150px;", + tags$strong(paste(i18n$t("labels.connectedAs"), Username)), + tags$a( + href = "#", + id = "logout_link", + onclick = "event.preventDefault(); Shiny.setInputValue('logout_link', Math.random());", + style = "color: white; text-decoration: underline; cursor: pointer;", + i18n$t("Logout") + ) + ) + } else { + NULL } }) + observeEvent(input$logout_link, { + # Your logout code here, e.g.: + session$reload() + }) # observe group select box observeEvent(input$GroupList, { # input is defined in ui.R @@ -467,7 +455,7 @@ server <- function(input, output, session) { if (rm11_node %in% top_nodes) {invisible(session$userData$tree$RemoveChild(rm11_node))} if (rm12_node %in% top_nodes) {invisible(session$userData$tree$RemoveChild(rm12_node))} - + # Access the top-level nodes top_children <- session$userData$tree$children @@ -493,9 +481,18 @@ server <- function(input, output, session) { } } - - if (length(session$userData$tree$children) == 0) {session$userData$tree <- list()} + + if (length(session$userData$tree$children) > 0) { + # Keep fauna as first-levgel node as the root + session$userData$tree <- session$userData$tree$children[[1]] + + # Optionally, set a friendly name for clarity + session$userData$tree$name <- paste0("Root: ", session$userData$tree$name) + } + jsonTree <- shinyTree::treeToJSON(session$userData$tree, pretty = TRUE, createNewId = FALSE) + + enable_all(c("DateRange", "GroupListDiv", "GetData")) return(jsonTree) }, error = function(e) { @@ -506,6 +503,7 @@ server <- function(input, output, session) { }) }) # end tree + output$conceptTree <- renderTree({ # only render after we have selected a group and retrieved the counts from that group req(session$userData$counts, session$userData$concepts) @@ -867,10 +865,20 @@ server <- function(input, output, session) { session$userData$plot_data() %...>% { df <- . - # Summarise data based on conceptlabel - df <- df %>% - group_by(conceptLabel) %>% - summarise(Counts = n(), .groups = 'drop') + time_input <- input$time_input + seasons <- user_defined_seasons() + + # Seasonal grouping if "season" view is selected + if (time_input == "season") { + df <- df %>% + filter(Period %in% names(seasons)) %>% + group_by(conceptLabel) %>% + summarise(Counts = n(), .groups = 'drop') + } else { + df <- df %>% + group_by(conceptLabel) %>% + summarise(Counts = n(), .groups = 'drop') + } message(paste( "Debug - bar_data(): Bar data transformation complete with", @@ -878,12 +886,13 @@ server <- function(input, output, session) { "rows." )) + # Apply top X row filter topX <- input$topX if (topX > 0) { df <- df %>% top_n(topX, Counts) %>% - arrange(desc(Counts)) + arrange(desc(Counts)) } # Order species according to frequency of detection for the bar chart @@ -964,7 +973,6 @@ server <- function(input, output, session) { }) - ## --- MAIN OUTPUT ------ # Create a reactiveValues container to hold the data frames @@ -982,7 +990,8 @@ server <- function(input, output, session) { # Calculate max count for dynamic axis range max_count <- max(heatmap_data_df$Counts, na.rm = TRUE) - xaxis_range <- c(0, max_count + max_count * 0.1) # 10% padding + max_count_bar <- max( bar_data_df$Counts, na.rm = TRUE) + xaxis_range <- c(0, max_count_bar + max_count_bar * 0.1) # 10% padding # Create the bar chart bar_chart <- plot_ly( @@ -1070,7 +1079,7 @@ server <- function(input, output, session) { # Download handler for the Plotly HTML plot output$download_plotly <- downloadHandler( filename = function() { - paste("combined_plot", ".html", sep = "") + paste("activity_pattern_",session$userData$selectedGroupValue, "_", session$userData$date_from, "_", session$userData$date_to, ".html", sep = "") }, content = function(file) { p <- plot_cache() @@ -1088,8 +1097,8 @@ server <- function(input, output, session) { }, content = function(file) { tmpdir <- tempdir() - bar_file <- file.path(tmpdir, "bar_data.csv") - heatmap_file <- file.path(tmpdir, "heatmap_data.csv") + bar_file <- file.path(tmpdir, paste0("bar_data_",session$userData$selectedGroupValue, "_", session$userData$date_from, "_", session$userData$date_to,".csv")) + heatmap_file <- file.path(tmpdir, paste0("heatmap_data_",session$userData$selectedGroupValue, "_", session$userData$date_from, "_", session$userData$date_to,".csv")) # Check that plot_data has been updated if (is.null(plot_data$bar_data) || is.null(plot_data$heatmap_data)) { @@ -1133,55 +1142,55 @@ server <- function(input, output, session) { th(i18n$t("columns.longitude"), title = i18n$t("labels.WGS84Lon")) )))) - # Raw concept table - output$tableRawConcepts <- DT::renderDataTable({ - message("Rendering raw concept table") - # req(session$userData$processed_obsdata) - - session$userData$processed_obsdata() %...>% { - df <- . - - # convert POSIXct to character that includes timezone, for datatable - df$when <- format(df$when, format = "%Y-%m-%dT%H:%M:%S%z") # %z shows as +0100 etc. - - df <- df %>% - select(conceptLabel, - when, - agentName, - observationId, - observationType, - lat, - lon) - # colnr <- which(names(df) == "when") # need for formatDate/toLocaleString - - DT::datatable( - df, - container = RawConceptsHovertext, - extensions = "Buttons", - options = list( - language = list( - url = paste0( - '//cdn.datatables.net/plug-ins/1.10.11/i18n/', - session$userData$lang_long, - '.json' - ) - ), - paging = TRUE, - searching = TRUE, - fixedColumns = TRUE, - autoWidth = TRUE, - ordering = TRUE, - dom = 'frtip<"sep">B', - #'ip>', #"ftripB", #'<"sep">frtipB', # dom = - pageLength = 999, - buttons = list( - list(extend = "copy", text = i18n$t("commands.copy")), - list(extend = "csv", text = i18n$t("commands.csv")) - ) - ) - ) # %>% DT::formatDate(colnr, "toLocaleString") does not include timezone in locale string - } - }) # end output tableRawConcepts + #' # Raw concept table + #' output$tableRawConcepts <- DT::renderDataTable({ + #' message("Rendering raw concept table") + #' # req(session$userData$processed_obsdata) + #' + #' session$userData$processed_obsdata() %...>% { + #' df <- . + #' + #' # convert POSIXct to character that includes timezone, for datatable + #' df$when <- format(df$when, format = "%Y-%m-%dT%H:%M:%S%z") # %z shows as +0100 etc. + #' + #' df <- df %>% + #' select(conceptLabel, + #' when, + #' agentName, + #' observationId, + #' observationType, + #' lat, + #' lon) + #' # colnr <- which(names(df) == "when") # need for formatDate/toLocaleString + #' + #' DT::datatable( + #' df, + #' container = RawConceptsHovertext, + #' extensions = "Buttons", + #' options = list( + #' language = list( + #' url = paste0( + #' '//cdn.datatables.net/plug-ins/1.10.11/i18n/', + #' session$userData$lang_long, + #' '.json' + #' ) + #' ), + #' paging = TRUE, + #' searching = TRUE, + #' fixedColumns = TRUE, + #' autoWidth = TRUE, + #' ordering = TRUE, + #' dom = 'frtip<"sep">B', + #' #'ip>', #"ftripB", #'<"sep">frtipB', # dom = + #' pageLength = 999, + #' buttons = list( + #' list(extend = "copy", text = i18n$t("commands.copy")), + #' list(extend = "csv", text = i18n$t("commands.csv")) + #' ) + #' ) + #' ) # %>% DT::formatDate(colnr, "toLocaleString") does not include timezone in locale string + #' } + #' }) # end output tableRawConcepts ### TAB RAW OBSERVATIONS diff --git a/ui.R b/ui.R index 2717578..aa95805 100644 --- a/ui.R +++ b/ui.R @@ -14,7 +14,7 @@ library(plotly) # multi language -#source("ui_header.R") + tryCatch({ # try to get online version @@ -60,7 +60,7 @@ ui <- fluidPage( tags$a( href = "https://sensingclues.org", target = "_blank", - class = "logo", img(src = "logo_white.png")), + class = "logo", img(src = "logo_white.png")) ), # titel @@ -69,18 +69,18 @@ ui <- fluidPage( "ACTIVITY PATTERN", style = "font-size: 18px;" ), - br(),br(),br(),br(),br(),br(),br() + # Right side: user status + div( + style = "min-width: 150px; text-align: right;", + uiOutput("userstatus") + ) ) ), + div(class = "content", sidebarLayout( sidebarPanel( width = 3, - HTML( - paste0( - "
", - "
" - ) - ), + style = "height: 85vh; overflow-y: auto;", # --- Collapsible About Box --- tags$head( tags$link(rel = "stylesheet", href = "https://fonts.googleapis.com/icon?family=Material+Icons"), @@ -121,10 +121,10 @@ ui <- fluidPage( class = "collapsible-header", HTML('Aboutexpand_more') ), - p("This app lets you explore animal observation data for any period. Use the heatmap to reveal activity trends by hour, month, or season, view total counts per species, and download the underlying dataset."), + p("With this app you can explore pattern in animal observation data. Use the matrix to reveal activity trends by hour or month, view total counts per species or download the underlying datasets."), tags$a( "Learn more", - href = "https://www.sensingclues.org/about-activity-pattern", # Change to your real link + href = "https://www.sensingclues.org/about-activity-pattern", class = "readmore", target = "_blank" ) @@ -158,6 +158,7 @@ ui <- fluidPage( tags$style( "#login{background-color:#FB8C00; color:white; font-size:100%}" ), + tags$style( "#message_more_dates{color: red; font-size: 20px; font-style: italic}" ), @@ -166,9 +167,6 @@ ui <- fluidPage( ), ), br(), - # Remove old heading h3(i18n$t("labels.obsReport")) - uiOutput("userstatus"), - br(), # --- Filter Sections --- div(class = "filter-section time-period-box", @@ -185,6 +183,7 @@ ui <- fluidPage( ), div(class = "filter-section data-sources-box", + style = "position: relative; z-index: 7000;", h4("Data Sources"), disabled(div( class = "choosechannel", @@ -206,6 +205,7 @@ ui <- fluidPage( br(), div(class = "filter-section concepts-box", + style = "position: relative; z-index: 5000;", h4("Concepts"), p("Select concepts (one or more)"), shinyTree("conceptTree", checkbox = TRUE, theme = "proton") @@ -221,13 +221,14 @@ ui <- fluidPage( mainPanel( width = 9, + style = "height: 85%; overflow-y: auto;", tags$head(tags$style( # Corrected escaping for the CSS content within HTML() - HTML(".sep { - width: 20px; - height: 1px; - float: left; - }") + # HTML(".sep { + # width: 20px; + # height: 1px; + # float: left; + # }") )), tabsetPanel( type = "tabs", @@ -243,14 +244,14 @@ ui <- fluidPage( # Time Interval Box div( - style = "width: 100px;", + style = "width: 150px;", selectInput( inputId = "time_input", label = i18n$t("Time interval"), choices = list( "Hourly" = "hourly", - "Monthly" = "monthly", - "Seasonal" = "season" + "Monthly" = "monthly" + # "Seasonal" = "season" ), selected = "hourly", width = "100%" @@ -263,7 +264,7 @@ ui <- fluidPage( div( style = "display: flex; align-items: center; gap: 20px;", div( - style = "width: 100px;", + style = "width: 150px;", numericInput( "num_seasons", "# Seasons:", @@ -297,17 +298,18 @@ ui <- fluidPage( # Top X Filter Box (match width to Time Interval box) div( - style = "width: 100px;", + style = "width: 150px;", numericInput( inputId = "topX", label = "Top rows:", - value = 10, + value = 5, min = 1, + max = 20, step = 1, width = "100%" ) ), - + # Aligned Radio Buttons (inline, vertically centered) div( style = "display: flex; align-items: flex-end; height: 58px;", # Adjust height to match input height @@ -330,52 +332,60 @@ ui <- fluidPage( plotlyOutput("combined_plot") ) ), - - # === Download Button Row === - fluidRow( - column( - 12, - div( - style = "margin-top: 20px;", - downloadButton("download_plotly", "Download plot (.html)"), - downloadButton("download_csv", "Download Data (.csv)") - ) - ) - ) - ) - ), - tabPanel( - i18n$t("labels.rawConceptsTab"), - fluidRow(column( - 12, DT::dataTableOutput("tableRawConcepts") - )), - div( - style = "position: fixed; top: 45%; left: 60%; transform: translate(-50%, -50%);", - add_busy_spinner( - spin = "fading-circle", - width = "100px", - height = "100px" - ) - ) - ), - - # endpanel - - tabPanel( - i18n$t("labels.rawData"), - br(), - column(2, br(), br(), downloadButton( - "downloadData", i18n$t("commands.download") - )), - fluidRow(column( - 12, DT::dataTableOutput("tableRawObservations") - )), - div( - style = "position: fixed; top: 45%; left: 60%; transform: translate(-50%, -50%);", - add_busy_spinner( - spin = "fading-circle", - width = "100px", - height = "100px" + fluidRow( + # style = paste( + # "position: fixed;", + # "bottom: 0;", + # "left: 0;", + # "width: 100%;", + # "background-color: #fff;", + # "padding: 10px;", + # "box-shadow: 0 -2px 5px rgba(0,0,0,0.1);", + # "text-align: center;", + # "z-index: 2000;", + # sep = " " + # ), + column( + 12, + downloadButton("download_plotly", "Download activity pattern plot (.html)"), + downloadButton("download_csv", "Download Data (.csv)") + ) + ) + ) + ), + # tabPanel( + # i18n$t("labels.rawConceptsTab"), + # fluidRow(column( + # 12, DT::dataTableOutput("tableRawConcepts") + # )), + # div( + # style = "position: fixed; top: 45%; left: 60%; transform: translate(-50%, -50%);", + # add_busy_spinner( + # spin = "fading-circle", + # width = "100px", + # height = "100px" + # ) + # ) + # ), + # + # endpanel + + tabPanel( + i18n$t("labels.rawData"), + br(), + column(2, br(), br(), downloadButton( + "downloadData", i18n$t("commands.download") + )), + fluidRow(column( + 12, DT::dataTableOutput("tableRawObservations") + )), + div( + style = "position: fixed; top: 45%; left: 60%; transform: translate(-50%, -50%);", + add_busy_spinner( + spin = "fading-circle", + width = "100px", + height = "100px" + ) ) ) ) diff --git a/ui_login.R b/ui_login.R new file mode 100644 index 0000000..e986c8c --- /dev/null +++ b/ui_login.R @@ -0,0 +1,147 @@ +# ui/mod_login_ui.R + +mod_login_ui <- function(id, browser_path) { + # ns <- NS(id) + + # Main container with set width + div(style = "margin-left: 10px; margin-right: 10px;", + + # Server selector, only shows up for test version + if (grepl("test", browser_path)) { + shinyWidgets::pickerInput( + inputId = "server", + label = "Server", + choices = c("focus.sensingclues", "focus.test.sensingclues"), + selected = "focus.sensingclues", + multiple = FALSE, + width = "100%" + ) + }, + + # LANGUAGE SELECTOR + shinyWidgets::pickerInput( + inputId = "lang", + label = "Select your language", + choices = c("Dutch" = "nl", "English"= "en", "French" = "fr"), + # c("Dutch", "English", "French", "German", "Hindi", "Romanian", "Spanish", "Swahili", "Ukrainian"), + selected = "en", + multiple = FALSE, + options = shinyWidgets::pickerOptions( + # noneSelectedText = "Select your language...", + liveSearch = TRUE, + liveSearchPlaceholder = "Type to search..." + ), + width = "100%" + ), + br(), + + # Username input (full width) + textInput( + inputId = "username", + label = "Enter your Cluey username", + placeholder = "Username", + width = "100%" + ), + + # Password input with toggle icon + tags$div(style = "position: relative; margin-top: 10px;", + # Label + tags$label(`for` = "password", "Password"), + # Eigen input ipv shiny::passwordInput om custom styling mogelijk te maken + tags$input( + id = "password", + type = "password", + class = "form-control", + placeholder = "Password", + style = "width: 100%; padding-right: 30px;" + ), + # Oogje voor toggle + tags$span( + class = "toggle-password", + tags$i(class = "fa fa-eye fa-lg"), + style = "position: absolute; top: 34px; right: 10px; cursor: pointer;" + ) + ), + + # Remember me checkbox onder de login knop + tags$div( + style = "margin-top: 15px; font-size: 0.9em;", + checkboxInput( + inputId = "remember_me", + label = "Remember me", + value = FALSE, + width = NULL + ) + ), + + # Login button (full width) + actionButton( + inputId = "ok", + label = "Log in", + class = "btn btn-primary", + style = "width: 100%; color: white; background-color: #004d40;" + ), + + # "Forgot password" link + tags$a( + href = "https://sensingclues.freshdesk.com/support/solutions/articles/48001167031-forgot-password", + "Forgot password", + target = "_blank", + style = "display: block; text-align: right; margin-top: 8px; + color: #004d40; text-decoration: none; font-weight: 300;" + ), + + # "Create account" link + tags$div( + style = "margin-top: 20px; border-top: 1px solid #ccc; padding-top: 10px; text-align: center; font-size: 0.9em; color: #666;", + "No account yet? ", + tags$a( + href = "https://sensingclues.freshdesk.com/support/solutions/articles/48000944302-create-an-account", + "Create one.", + target = "_blank", + style = "color: #004d40; text-decoration: none; font-weight: 500;" + ) + ), + + # Script to toggle password visibility + tags$script(HTML( + "$(document).on('click', '.toggle-password', function() {\n", + " var input = $(this).siblings('input');\n", + " var type = input.attr('type') === 'password' ? 'text' : 'password';\n", + " input.attr('type', type);\n", + " $(this).find('i').toggleClass('fa-eye fa-eye-slash');\n", + "});" + )), + + # Script to close modal on clicking enter + tags$script(HTML( + ' $(document).keyup(function(event) { + if ($("#password").is(":focus") && (event.keyCode == 13)) { + $("#ok").click(); + } + });' + )), + + tags$script(HTML(glue::glue(" + $(document).ready(function() {{ + // Bij laden van de pagina: check localStorage + if (localStorage.getItem('remember_me') === 'true') {{ + $('#{'remember_me'}').prop('checked', true); + $('#{'username'}').val(localStorage.getItem('username')); + }} + + // Bij klikken op login: onthoud gegevens als aangevinkt + $('#{'ok'}').on('click', function() {{ + if ($('#{'remember_me'}').is(':checked')) {{ + localStorage.setItem('remember_me', 'true'); + localStorage.setItem('username', $('#{'username'}').val()); + }} else {{ + localStorage.removeItem('remember_me'); + localStorage.removeItem('username'); + }} + }}); + }}); + ")) + ) + ) +} \ No newline at end of file diff --git a/www/style.css b/www/style.css index d0900ef..e714dc9 100644 --- a/www/style.css +++ b/www/style.css @@ -23,16 +23,30 @@ body { } +.container-fluid { + padding-left: 0; + padding-right: 0; +} +.row { + margin-left: 0px; + margin-right: 5px; +} +.row > [class*="col-"] { + padding-left: 0px; + padding-right: 5px; +} + + /* ============================ Header (20% viewport height with 10% horizontal padding) ============================ */ .header { display: flex; - align-items: center; /* Center elements vertically */ + align-items: flex-start; justify-content: space-between; /* Distribute items across the header */ background-color: #004d40; - height: 11vh; + height: 20vh; padding: 2rem 7vw; /* Adjust horizontal padding */ border-bottom: 2px solid #00332b; position: relative; @@ -52,6 +66,24 @@ body { text-align: center; /* Center the title text */ } +body { + z-index: 1000; + position: relative; /* z-index only works on positioned elements */ +} + +.content { + position: relative; /* enable positioning */ + top: -10vh; /* pull up over the header by 20px (adjust as needed) */ + margin: 0 50px; /* xx px left/right margins */ + z-index: 2; /* above .header (which should be z-index:1 or 0) */ + background: white; /* or whatever bg you need */ + border-radius: 10px; /* rounded corners */ + box-shadow: 0 2px 8px rgba(0,0,0,0.2); /* optional drop-shadow */ + overflow: hidden; /* clip child content to rounded corners */ + height: 85vh; /* hoogte = xx% van viewport */ + overflow-y: auto; /* scrollen als inhoud groter is */ +} + .well { background-color: #ECEFF1;