Added airline and clipboard support
This commit is contained in:
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
type usernameQuery = {
|
type usernameQuery = {
|
||||||
fr24user: string
|
fr24user: string
|
||||||
|
searchType: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AirportResponseData = obj[][]
|
type AirportResponseData = obj[][]
|
||||||
|
|||||||
@@ -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,9 +34,22 @@ let index content =
|
|||||||
_hxTarget "#results"
|
_hxTarget "#results"
|
||||||
_hxSwap "innerHTML show:top"
|
_hxSwap "innerHTML show:top"
|
||||||
] [
|
] [
|
||||||
input [_type "text"; _name "fr24user"; _autocomplete "off"; _placeholder "Enter your MyFlightRadar24 username..."; _required; _hxValidate "true"; _pattern ".{4,}"]
|
section [] [
|
||||||
button [_type "submit";] [str "Let's find out!"]
|
input [_type "text"; _name "fr24user"; _autocomplete "off"; _placeholder "Enter your MyFlightRadar24 username..."; _required; _hxValidate "true"; _pattern ".{4,}"]
|
||||||
img [_class "loading"; _alt "Loading..."; _width "200"; _height "30"; _src "/images/loading.svg"]
|
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 [] [
|
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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,26 +68,45 @@ 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
|
||||||
flex-basis: 75%
|
@include flex
|
||||||
height: 100%
|
flex-basis: 50%
|
||||||
font-size: 2em
|
width: 100%
|
||||||
padding-left: 1em
|
> input
|
||||||
color: #666
|
flex-basis: 75%
|
||||||
|
height: 100%
|
||||||
|
font-size: 2em
|
||||||
|
padding-left: 1em
|
||||||
|
color: #666
|
||||||
|
|
||||||
button
|
> label
|
||||||
flex-basis: 25%
|
@include flex
|
||||||
font-size: 2em
|
flex-basis: 10%
|
||||||
height: 100%
|
color: white
|
||||||
color: white
|
border: solid 1px white
|
||||||
background: grey
|
height: 100%
|
||||||
|
|
||||||
.loading
|
> input
|
||||||
display: none
|
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
|
@include mobile
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user