Added airline and clipboard support

This commit is contained in:
2024-11-02 15:23:19 +10:00
parent bd6c9c56a5
commit 4c12ee2979
8 changed files with 170 additions and 38 deletions

View File

@@ -1,5 +1,6 @@
module Controller module Controller
open System
open System.Net.Http open System.Net.Http
open System.Text.RegularExpressions open System.Text.RegularExpressions
open Giraffe.ViewEngine.HtmlElements open Giraffe.ViewEngine.HtmlElements
@@ -51,6 +52,27 @@ let getAirportAbbrTags airportGroup =
|> Array.sortBy _.Code |> Array.sortBy _.Code
|> Array.map View.airportAbbr |> 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[]) = let processAirports (alphabet: char[]) (allAirports: Airport[]) =
@@ -79,27 +101,36 @@ let processAirports (alphabet: char[]) (allAirports: Airport[]) =
| _ -> key, [||]) | _ -> key, [||])
dictionary dictionary
let RenderAirportList (user: usernameQuery) = let RenderAirportList (user: usernameQuery) =
let username = parseUsername user.fr24user let username = parseUsername user.fr24user
let alphabet = [|'A'..'Z'|] let alphabet = [|'A'..'Z'|]
let airports = let airports =
$"username={username}&listType=airports&order=no&limit=0" $"username={username}&listType={user.searchType}&order=no&limit=0"
|> makePostRequest<AirportResponseData> "https://my.flightradar24.com/public-scripts/profileToplist" |> makePostRequest<AirportResponseData> "https://my.flightradar24.com/public-scripts/profileToplist"
|> decodeResponseData |> decodeResponseData
|> processAirports alphabet |> processAirports alphabet
let numberOfAirportsNotFlown = let numberOfAirportsNotFlown =
airports airports
|> Array.filter (fun (_, nodes) -> Array.isEmpty nodes) |> Array.filter (fun (_, nodes) -> Array.isEmpty nodes)
|> Array.length |> Array.length
let percentageOfLettersWithNoAirports = (float numberOfAirportsNotFlown / float alphabet.Length) * 100.0 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 airports
|> Array.map (fun (letter, nodes) -> View.tableRow letter nodes) |> Array.map (fun (letter, nodes) -> View.tableRow letter nodes)
|> View.table message username |> View.table message title plaintextAirports
let RenderPageWithUser (username: string) = let RenderPageWithUserAndSearchType (searchType: string) (username: string) =
let airportList = RenderAirportList {fr24user = username} let airportList = RenderAirportList {fr24user = username; searchType = searchType}
View.index [|airportList|] View.index [|airportList|]
let RenderPageWithUser = RenderPageWithUserAndSearchType "airports"

View File

@@ -1,22 +1,42 @@
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open Saturn open Saturn
open Giraffe open Giraffe
open Types open Types
open System.Net
open System.Net.Sockets
module Program = module Program =
let router = router { let router = router {
not_found_handler (setStatusCode 404 >=> text "404") not_found_handler (setStatusCode 404 >=> text "404")
get "/" ( (View.index [||]) |> htmlView) get "/" ( (View.index [||]) |> htmlView)
getf "/%s" (fun username -> htmlView(Controller.RenderPageWithUser username)) getf "/%s" (fun username -> htmlView(Controller.RenderPageWithUser username))
getf "/%s/%s" (fun (username, searchType) -> htmlView(Controller.RenderPageWithUserAndSearchType searchType username))
post "/search" (bindJson<usernameQuery> (fun username -> post "/search" (bindJson<usernameQuery> (fun username ->
fun next ctx -> 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 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 = let app =
application { application {
use_mime_types [(".woff", "application/font-woff")] use_mime_types [(".woff", "application/font-woff")]
@@ -24,6 +44,7 @@ module Program =
use_router router use_router router
use_developer_exceptions use_developer_exceptions
service_config ServiceConfig service_config ServiceConfig
url "http://0.0.0.0:5001"
} }
run app run app

View File

@@ -2,6 +2,7 @@
type usernameQuery = { type usernameQuery = {
fr24user: string fr24user: string
searchType: string
} }
type AirportResponseData = obj[][] type AirportResponseData = obj[][]

View File

@@ -16,10 +16,10 @@ let index content =
head [] [ head [] [
meta [_name "viewport"; _content "width=device-width"] meta [_name "viewport"; _content "width=device-width"]
title [] [str "Have you flown the alphabet?"] 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/htmx.min.js"] []
script [_src "/scripts/json-enc.js"] [] script [_src "/scripts/json-enc.js"] []
script [_src "/scripts/index.js"] [] script [_src "/scripts/index.js?v=2"] []
] ]
body [] [ body [] [
div [_id "pageContainer"] [ div [_id "pageContainer"] [
@@ -34,10 +34,23 @@ let index content =
_hxTarget "#results" _hxTarget "#results"
_hxSwap "innerHTML show:top" _hxSwap "innerHTML show:top"
] [ ] [
section [] [
input [_type "text"; _name "fr24user"; _autocomplete "off"; _placeholder "Enter your MyFlightRadar24 username..."; _required; _hxValidate "true"; _pattern ".{4,}"] 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!"] button [_type "submit";] [str "Let's find out!"]
img [_class "loading"; _alt "Loading..."; _width "200"; _height "30"; _src "/images/loading.svg"] 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"
]
]
]
] ]
] ]
section [_id "results"; _class hide; attr "hx-on::after-swap" "document.getElementById('results').style.display='flex'"] [ section [_id "results"; _class hide; attr "hx-on::after-swap" "document.getElementById('results').style.display='flex'"] [
@@ -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 [] [ article [] [
h1 [] [str message] h1 [] [str message]
table [] [ table [] [
tr [] [ 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 yield! rows
] ]

View File

@@ -68,17 +68,36 @@ h1
font-size: 5em font-size: 5em
form form
@include flex @include flex-column
flex-basis: 25% flex-basis: 50%
width: 100% width: 100%
gap: 0.6em
input section
@include flex
flex-basis: 50%
width: 100%
> input
flex-basis: 75% flex-basis: 75%
height: 100% height: 100%
font-size: 2em font-size: 2em
padding-left: 1em padding-left: 1em
color: #666 color: #666
> label
@include flex
flex-basis: 10%
color: white
border: solid 1px white
height: 100%
> input
display: none
> label.checked
color: #666666
background-color: white
button button
flex-basis: 25% flex-basis: 25%
font-size: 2em font-size: 2em
@@ -150,6 +169,9 @@ h1
background-color: gray background-color: gray
color: white color: white
small
cursor: pointer
td, th td, th
padding: 0.3em padding: 0.3em

View File

@@ -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());

View File

@@ -69,24 +69,49 @@ h1 {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: 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%; width: 100%;
} }
#pageContainer section#input article form input { #pageContainer section#input article form section > input {
flex-basis: 75%; flex-basis: 75%;
height: 100%; height: 100%;
font-size: 2em; font-size: 2em;
padding-left: 1em; padding-left: 1em;
color: #666; 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%; flex-basis: 25%;
font-size: 2em; font-size: 2em;
height: 100%; height: 100%;
color: white; color: white;
background: grey; background: grey;
} }
#pageContainer section#input article form .loading { #pageContainer section#input article form section .loading {
display: none; display: none;
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
@@ -149,6 +174,9 @@ h1 {
background-color: gray; background-color: gray;
color: white; color: white;
} }
#pageContainer section#results article table th small {
cursor: pointer;
}
#pageContainer section#results article table td, #pageContainer section#results article table th { #pageContainer section#results article table td, #pageContainer section#results article table th {
padding: 0.3em; padding: 0.3em;
} }

View File

@@ -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"} {"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"}