From 4c12ee29794d66058eadb3c6ec035039d0086275 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 2 Nov 2024 15:23:19 +1000 Subject: [PATCH] Added airline and clipboard support --- AirportAlphabetGame/Controller.fs | 45 +++++++++++++--- AirportAlphabetGame/Program.fs | 25 ++++++++- AirportAlphabetGame/Types.fs | 1 + AirportAlphabetGame/View.fs | 30 ++++++++--- AirportAlphabetGame/sass/core.sass | 54 +++++++++++++------ AirportAlphabetGame/wwwroot/scripts/index.js | 15 +++++- AirportAlphabetGame/wwwroot/styles/core.css | 36 +++++++++++-- .../wwwroot/styles/core.css.map | 2 +- 8 files changed, 170 insertions(+), 38 deletions(-) diff --git a/AirportAlphabetGame/Controller.fs b/AirportAlphabetGame/Controller.fs index 3db3227..547b2fa 100644 --- a/AirportAlphabetGame/Controller.fs +++ b/AirportAlphabetGame/Controller.fs @@ -1,5 +1,6 @@ module Controller +open System open System.Net.Http open System.Text.RegularExpressions open Giraffe.ViewEngine.HtmlElements @@ -51,6 +52,27 @@ let getAirportAbbrTags airportGroup = |> Array.sortBy _.Code |> Array.map View.airportAbbr +let rec extractTextValues (node: XmlNode) = + match node with + | Text code when code.Trim().Length > 1 -> [| code |] // Only include codes with exactly three letters + | ParentNode (_, children) -> + children + |> List.toArray + |> Array.collect extractTextValues // Collect all text values from children + | _ -> [| |] // Ignore any other node types + + +let formatAirportsPlainText (airports: (string * XmlNode[])[]) (message: string) = + message + "\n" + (airports + |> Array.map (fun (letter, nodes) -> + let airportCodes = + nodes + |> Array.collect extractTextValues + match airportCodes with + | [||] -> letter + | _ -> String.concat ", " airportCodes + ) + |> String.concat "\n") let processAirports (alphabet: char[]) (allAirports: Airport[]) = @@ -79,27 +101,36 @@ let processAirports (alphabet: char[]) (allAirports: Airport[]) = | _ -> key, [||]) dictionary + + let RenderAirportList (user: usernameQuery) = let username = parseUsername user.fr24user let alphabet = [|'A'..'Z'|] let airports = - $"username={username}&listType=airports&order=no&limit=0" + $"username={username}&listType={user.searchType}&order=no&limit=0" |> makePostRequest "https://my.flightradar24.com/public-scripts/profileToplist" |> decodeResponseData |> processAirports alphabet + let numberOfAirportsNotFlown = airports - |> Array.filter(fun (_, nodes) -> Array.isEmpty nodes) + |> Array.filter (fun (_, nodes) -> Array.isEmpty nodes) |> Array.length let percentageOfLettersWithNoAirports = (float numberOfAirportsNotFlown / float alphabet.Length) * 100.0 - let message = $"{username} has flown {System.Math.Round(100. - percentageOfLettersWithNoAirports, 1)}%% of the alphabet!" + let percentageOfAlphabetString = $"{System.Math.Round(100. - percentageOfLettersWithNoAirports, 1)}%% of the alphabet!" + let message = $"{username} has flown {percentageOfAlphabetString}" + let messagePlainText = $"I've flown {percentageOfAlphabetString}" + let title = $"{username}'s {user.searchType} flown" + let plaintextAirports = formatAirportsPlainText airports messagePlainText airports |> Array.map (fun (letter, nodes) -> View.tableRow letter nodes) - |> View.table message username + |> View.table message title plaintextAirports -let RenderPageWithUser (username: string) = - let airportList = RenderAirportList {fr24user = username} - View.index [|airportList|] \ No newline at end of file +let RenderPageWithUserAndSearchType (searchType: string) (username: string) = + let airportList = RenderAirportList {fr24user = username; searchType = searchType} + View.index [|airportList|] + +let RenderPageWithUser = RenderPageWithUserAndSearchType "airports" \ No newline at end of file diff --git a/AirportAlphabetGame/Program.fs b/AirportAlphabetGame/Program.fs index 4f20abd..dc59928 100644 --- a/AirportAlphabetGame/Program.fs +++ b/AirportAlphabetGame/Program.fs @@ -1,22 +1,42 @@ +open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection open Saturn open Giraffe open Types +open System.Net +open System.Net.Sockets + module Program = let router = router { not_found_handler (setStatusCode 404 >=> text "404") get "/" ( (View.index [||]) |> htmlView) getf "/%s" (fun username -> htmlView(Controller.RenderPageWithUser username)) + getf "/%s/%s" (fun (username, searchType) -> htmlView(Controller.RenderPageWithUserAndSearchType searchType username)) post "/search" (bindJson (fun username -> fun next ctx -> - ctx.Response.Headers.Add("HX-Replace-Url", Controller.parseUsername username.fr24user) + ctx.Response.Headers.Add("HX-Push-Url", $"/{Controller.parseUsername username.fr24user}/{username.searchType}") htmlView (Controller.RenderAirportList username) next ctx )) } - let ServiceConfig (services: IServiceCollection) = services.AddHttpContextAccessor() + let ServiceConfig (services: IServiceCollection) = + // Get the server IP address + let serverIpAddress = + match Dns.GetHostEntry(Dns.GetHostName()).AddressList |> Array.tryFind(fun ip -> ip.AddressFamily = AddressFamily.InterNetwork) with + | Some ip -> ip.ToString() + | None -> "IP address not found" + + let boldCode = "\u001b[1m" + let greenCode = "\u001b[32m" + let resetCode = "\u001b[0m" + + // Print the server IP address + printfn $"{boldCode}Now Running On: {greenCode}%s{serverIpAddress}{resetCode}" + services.AddHttpContextAccessor() + + let app = application { use_mime_types [(".woff", "application/font-woff")] @@ -24,6 +44,7 @@ module Program = use_router router use_developer_exceptions service_config ServiceConfig + url "http://0.0.0.0:5001" } run app diff --git a/AirportAlphabetGame/Types.fs b/AirportAlphabetGame/Types.fs index 934d199..55ee8eb 100644 --- a/AirportAlphabetGame/Types.fs +++ b/AirportAlphabetGame/Types.fs @@ -2,6 +2,7 @@ type usernameQuery = { fr24user: string + searchType: string } type AirportResponseData = obj[][] diff --git a/AirportAlphabetGame/View.fs b/AirportAlphabetGame/View.fs index 3fe267c..f449868 100644 --- a/AirportAlphabetGame/View.fs +++ b/AirportAlphabetGame/View.fs @@ -16,10 +16,10 @@ let index content = head [] [ meta [_name "viewport"; _content "width=device-width"] title [] [str "Have you flown the alphabet?"] - link [_rel "stylesheet" ; _href "/styles/core.css"] + link [_rel "stylesheet" ; _href "/styles/core.css?v=2"] script [_src "/scripts/htmx.min.js"] [] script [_src "/scripts/json-enc.js"] [] - script [_src "/scripts/index.js"] [] + script [_src "/scripts/index.js?v=2"] [] ] body [] [ div [_id "pageContainer"] [ @@ -34,9 +34,22 @@ let index content = _hxTarget "#results" _hxSwap "innerHTML show:top" ] [ - input [_type "text"; _name "fr24user"; _autocomplete "off"; _placeholder "Enter your MyFlightRadar24 username..."; _required; _hxValidate "true"; _pattern ".{4,}"] - button [_type "submit";] [str "Let's find out!"] - img [_class "loading"; _alt "Loading..."; _width "200"; _height "30"; _src "/images/loading.svg"] + section [] [ + input [_type "text"; _name "fr24user"; _autocomplete "off"; _placeholder "Enter your MyFlightRadar24 username..."; _required; _hxValidate "true"; _pattern ".{4,}"] + button [_type "submit";] [str "Let's find out!"] + img [_class "loading"; _alt "Loading..."; _width "200"; _height "30"; _src "/images/loading.svg"] + ] + section [] [ + label [_class "checked"] [ + input [_checked; _type "radio"; _name "searchType"; _value "airports"] + str "Airports" + ] + label [] [ + input [_type "radio"; _name "searchType"; _value "airlines"] + str "Airlines" + ] + ] + ] ] ] @@ -63,12 +76,15 @@ let tableRow (letter: string ) (codes: XmlNode[]) = ] ] -let table (message) (user: string) (rows: XmlNode[]) = +let table (message) (title: string) (plaintextAirports: string) (rows: XmlNode[]) = article [] [ h1 [] [str message] table [] [ tr [] [ - th [_colspan "2"] [str $"{user}'s Airports Flown"] + th [_colspan "2"] [ + str title + small [(attr "data-airportList") plaintextAirports; _onclick "navigator.clipboard.writeText(this.getAttribute('data-airportList')).then(() => { const originalText = this.innerText; this.innerText = '✅'; this.style.color = 'green'; setTimeout(() => { this.innerText = originalText; this.style.color = ''; }, 1000); })"] [str "📋"] + ] ] yield! rows ] diff --git a/AirportAlphabetGame/sass/core.sass b/AirportAlphabetGame/sass/core.sass index 2d6e010..7064533 100644 --- a/AirportAlphabetGame/sass/core.sass +++ b/AirportAlphabetGame/sass/core.sass @@ -68,26 +68,45 @@ h1 font-size: 5em form - @include flex - flex-basis: 25% + @include flex-column + flex-basis: 50% width: 100% + gap: 0.6em - input - flex-basis: 75% - height: 100% - font-size: 2em - padding-left: 1em - color: #666 + section + @include flex + flex-basis: 50% + width: 100% + > input + flex-basis: 75% + height: 100% + font-size: 2em + padding-left: 1em + color: #666 - button - flex-basis: 25% - font-size: 2em - height: 100% - color: white - background: grey + > label + @include flex + flex-basis: 10% + color: white + border: solid 1px white + height: 100% - .loading - display: none + > input + display: none + + > label.checked + color: #666666 + background-color: white + + button + flex-basis: 25% + font-size: 2em + height: 100% + color: white + background: grey + + .loading + display: none @include mobile @@ -150,6 +169,9 @@ h1 background-color: gray color: white + small + cursor: pointer + td, th padding: 0.3em diff --git a/AirportAlphabetGame/wwwroot/scripts/index.js b/AirportAlphabetGame/wwwroot/scripts/index.js index a4a8f44..6bc8130 100644 --- a/AirportAlphabetGame/wwwroot/scripts/index.js +++ b/AirportAlphabetGame/wwwroot/scripts/index.js @@ -1 +1,14 @@ -document.addEventListener("DOMContentLoaded", (event) => document.getElementById('results').scrollIntoView()); +document.addEventListener("DOMContentLoaded", (event) => { + document.querySelectorAll('input[type="radio"]').forEach((radio) => { + radio.addEventListener('change', () => { + document.querySelectorAll(`input[name="${radio.name}"]`).forEach((input) => { + input.parentElement.classList.remove('checked'); + }); + + if (radio.checked) { + radio.parentElement.classList.add('checked'); + } + }); + }); +}); +document.addEventListener("DOMContentLoaded", (event) => document.getElementById('results').scrollIntoView()); diff --git a/AirportAlphabetGame/wwwroot/styles/core.css b/AirportAlphabetGame/wwwroot/styles/core.css index e9780b5..f335b77 100644 --- a/AirportAlphabetGame/wwwroot/styles/core.css +++ b/AirportAlphabetGame/wwwroot/styles/core.css @@ -69,24 +69,49 @@ h1 { display: flex; justify-content: center; align-items: center; - flex-basis: 25%; + flex-direction: column; + flex-basis: 50%; + width: 100%; + gap: 0.6em; +} +#pageContainer section#input article form section { + display: flex; + justify-content: center; + align-items: center; + flex-basis: 50%; width: 100%; } -#pageContainer section#input article form input { +#pageContainer section#input article form section > input { flex-basis: 75%; height: 100%; font-size: 2em; padding-left: 1em; color: #666; } -#pageContainer section#input article form button { +#pageContainer section#input article form section > label { + display: flex; + justify-content: center; + align-items: center; + flex-basis: 10%; + color: white; + border: solid 1px white; + height: 100%; +} +#pageContainer section#input article form section > label > input { + display: none; +} +#pageContainer section#input article form section > label.checked { + color: #666666; + background-color: white; +} +#pageContainer section#input article form section button { flex-basis: 25%; font-size: 2em; height: 100%; color: white; background: grey; } -#pageContainer section#input article form .loading { +#pageContainer section#input article form section .loading { display: none; } @media screen and (max-width: 900px) { @@ -149,6 +174,9 @@ h1 { background-color: gray; color: white; } +#pageContainer section#results article table th small { + cursor: pointer; +} #pageContainer section#results article table td, #pageContainer section#results article table th { padding: 0.3em; } diff --git a/AirportAlphabetGame/wwwroot/styles/core.css.map b/AirportAlphabetGame/wwwroot/styles/core.css.map index d387ad8..8a83258 100644 --- a/AirportAlphabetGame/wwwroot/styles/core.css.map +++ b/AirportAlphabetGame/wwwroot/styles/core.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["../../sass/core.sass"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;EACA;EACA;;;AACJ;EACI;;;AAeJ;EARI;EACA;EACA;EAQA;EACA;EACA;;;AAEJ;EACI;EACA;;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAEJ;EA/BA;EACA;EACA;EA+BI;EACA;EACA;EACA;;AAEJ;EACI;;AAGJ;EACI;;AAEA;EA7CJ;EACA;EACA;EAIA;EAyCQ;;AAEA;EACI;EACA;EACA;;AAEJ;EAtDR;EACA;EACA;EAsDY;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;;AA9EhB;EAiDI;IAiCQ;IACA;;EAEA;IACI;;EAEJ;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;;;AAKR;EACI;;AAEJ;EACI;;AAEhB;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EAlHJ;EACA;EACA;EAIA;EA8GQ;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;AAAA;EAEI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEJ;EACI;;AAGA;EACI;;AAEJ;EACI;EACA;;AAGJ;EACI;;AACJ;EACI;EACA;;AA7JpB;EAsHI;IAyCQ;;EACA;IACI;IACA;IACA;;EAEJ;IACI;;EAEA;AAAA;IAEI;IACA;IACA;IACA;;EAEJ;AAAA;IAEI;IACA;IACA;IACA;IACA","file":"core.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["../../sass/core.sass"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;EACA;EACA;;;AACJ;EACI;;;AAeJ;EARI;EACA;EACA;EAQA;EACA;EACA;;;AAEJ;EACI;EACA;;;AAEJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAEJ;EA/BA;EACA;EACA;EA+BI;EACA;EACA;EACA;;AAEJ;EACI;;AAGJ;EACI;;AAEA;EA7CJ;EACA;EACA;EAIA;EAyCQ;;AAEA;EACI;EACA;EACA;;AAEJ;EAtDR;EACA;EACA;EAIA;EAkDY;EACA;EACA;;AAEA;EA5DZ;EACA;EACA;EA4DgB;EACA;;AACA;EACI;EACA;EACA;EACA;EACA;;AAEJ;EAvEhB;EACA;EACA;EAuEoB;EACA;EACA;EACA;;AAEA;EACI;;AAER;EACI;EACA;;AAEJ;EACI;EACA;EACA;EACA;EACA;;AAEJ;EACI;;AAjGpB;EAiDI;IAoDQ;IACA;;EAEA;IACI;;EAEJ;IACI;IACA;IACA;IACA;;EAEA;IACI;IACA;IACA;;;AAKR;EACI;;AAEJ;EACI;;AAEhB;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EArIJ;EACA;EACA;EAIA;EAiIQ;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;AAAA;EAEI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;;AAER;EACI;;AAGA;EACI;;AAEJ;EACI;EACA;;AAGJ;EACI;;AACJ;EACI;EACA;;AAnLpB;EAyII;IA4CQ;;EACA;IACI;IACA;IACA;;EAEJ;IACI;;EAEA;AAAA;IAEI;IACA;IACA;IACA;;EAEJ;AAAA;IAEI;IACA;IACA;IACA;IACA","file":"core.css"} \ No newline at end of file