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
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<AirportResponseData> "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}
let RenderPageWithUserAndSearchType (searchType: string) (username: string) =
let airportList = RenderAirportList {fr24user = username; searchType = searchType}
View.index [|airportList|]
let RenderPageWithUser = RenderPageWithUserAndSearchType "airports"

View File

@@ -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<usernameQuery> (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

View File

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

View File

@@ -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
]

View File

@@ -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

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;
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;
}

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