This commit is contained in:
2024-03-20 19:28:35 +10:00
commit dc1df70790
19 changed files with 867 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

16
AirportAlphabetGame.sln Normal file
View File

@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AirportAlphabetGame", "AirportAlphabetGame\AirportAlphabetGame.fsproj", "{80323801-F224-4E26-B6A0-5232AED36275}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{80323801-F224-4E26-B6A0-5232AED36275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80323801-F224-4E26-B6A0-5232AED36275}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80323801-F224-4E26-B6A0-5232AED36275}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80323801-F224-4E26-B6A0-5232AED36275}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Types.fs" />
<Compile Include="Svg.fs" />
<Compile Include="Htmx.fs" />
<Compile Include="View.fs" />
<Compile Include="Controller.fs" />
<Compile Include="Router.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Saturn" Version="0.16.1" />
<PackageReference Include="Thoth.Json.Net" Version="11.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="sass\core.sass" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,106 @@
module Controller
open System.Net.Http
open System.Text.RegularExpressions
open Giraffe.ViewEngine.HtmlElements
open Types
open System.Text
open Thoth.Json.Net
let parseUsername (username: string) =
let pattern = @"https?://(?:www\.)?my\.flightradar24\.com/([^/?#]+)"
let matches = Regex.Match(username, pattern)
if matches.Success then matches.Groups.[1].Value
else username
let getJsonResult result =
match result with
| Error _ -> failwith "Invalid Json"
| Ok finalResult -> finalResult
let makePostRequest<'x> (url: string) payload =
async {
use httpClient = new HttpClient()
let content = new StringContent(payload, Encoding.UTF8, "application/x-www-form-urlencoded")
let request = new HttpRequestMessage(HttpMethod.Post, url)
request.Content <- content
let! response = httpClient.SendAsync(request) |> Async.AwaitTask
let! responseContent = response.Content.ReadAsStringAsync() |> Async.AwaitTask
return responseContent
}
|> Async.RunSynchronously
|> Decode.Auto.fromString<'x>
|> getJsonResult
let decodeResponseData (data: AirportResponseData) =
data
|> Array.map (fun airport -> {Code=string airport[0];City=string airport[1]})
let groupAirportsByLetter (airports: Airport[]) =
airports
|> Array.groupBy (_.Code[0])
|> Array.map snd
let getAirportAbbrTags airportGroup =
airportGroup
|> Array.distinct
|> Array.sortBy (_.Code)
|> Array.map View.airportAbbr
let processAirports (alphabet: char[]) (allAirports: Airport[]) =
let intersperseCommas (nodes: XmlNode seq) =
nodes
|> Seq.mapi (fun i node -> if i < Seq.length nodes - 1 then [node; View.comma] else [node])
|> Seq.concat
|> Seq.toArray
// Group airports by the first letter of their code
let groupedAirports =
allAirports
|> Seq.groupBy (fun airport -> airport.Code.[0])
|> Seq.map (fun (key, group) ->
let sortedGroup = group |> Seq.sortBy (fun airport -> airport.Code)
key, (sortedGroup |> Seq.map View.airportAbbr |> intersperseCommas))
|> dict
let dictionary =
alphabet
|> Array.map (fun letter ->
let key = letter.ToString().ToUpper()
match groupedAirports.TryGetValue(letter) with
| (true, value) -> key, value
| _ -> 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"
|> makePostRequest<AirportResponseData> "https://my.flightradar24.com/public-scripts/profileToplist"
|> decodeResponseData
|> processAirports alphabet
let numberOfAirportsNotFlown =
airports
|> 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!"
airports
|> Array.map (fun (letter, nodes) -> View.tableRow letter nodes)
|> View.table message username
let RenderPageWithUser (username: string) =
let airportList = RenderAirportList {fr24user = username}
View.index [|airportList|]

View File

@@ -0,0 +1,12 @@
module Htmx
open Giraffe.ViewEngine
let _hxGet = attr "data-hx-get"
let _hxPost = attr "data-hx-post"
let _hxTrigger = attr "data-hx-trigger"
let _hxTarget = attr "data-hx-target"
let _hxExt = attr "data-hx-ext"
let _hxSwap = attr "data-hx-swap"
let _hxReplaceUrl = attr "data-hx-replace-url"
let _hxValidate = attr "data-hx-validate"

View File

@@ -0,0 +1,37 @@
open Microsoft.AspNetCore.Mvc
open Microsoft.Extensions.DependencyInjection
open Microsoft.AspNetCore.Http
open Saturn
open Giraffe
open Types
module Program =
let pipeline = pipeline {
use_warbler
}
let router = router {
pipe_through pipeline
not_found_handler (setStatusCode 404 >=> text "404")
get "/" ( (View.index [||]) |> htmlView)
getf "/%s" (fun username -> htmlView(Controller.RenderPageWithUser username))
post "/search" (bindJson<usernameQuery> (fun username ->
fun next ctx ->
ctx.Response.Headers.Add("HX-Replace-Url", Controller.parseUsername username.fr24user)
htmlView (Controller.RenderAirportList username) next ctx
))
}
let ServiceConfig (services: IServiceCollection) = services.AddHttpContextAccessor()
let ipAddress = System.Net.Dns.GetHostEntry(System.Net.Dns.GetHostName()).AddressList[2];
let app =
application {
use_mime_types [(".woff", "application/font-woff")]
use_static "public"
use_router router
service_config ServiceConfig
url "http://*:5001"
}
run app

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:4828",
"sslPort": 44396
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7051;http://localhost:5001",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,93 @@
module Router
open System
open System.Linq
open System.Threading.Tasks
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
let inline (~%) (x: ^A) : ^B = (^B : (static member From: ^A -> ^B) x)
type RouteMethod = Get | Post
type Static<'x> =
| HtmlDocument of (unit -> Giraffe.ViewEngine.HtmlElements.XmlNode)
| HtmlElement of (unit -> Giraffe.ViewEngine.HtmlElements.XmlNode)
| HtmlElements of (unit -> Giraffe.ViewEngine.HtmlElements.XmlNode list)
| HtmlString of (unit -> string)
| Text of (unit -> string)
| Json of (unit -> 'x)
type Dynamic<'x, 'y> =
| HtmlDocument of ('x -> Giraffe.ViewEngine.HtmlElements.XmlNode)
| HtmlElement of ('x -> Giraffe.ViewEngine.HtmlElements.XmlNode)
| HtmlElements of ('x -> Giraffe.ViewEngine.HtmlElements.XmlNode list)
| HtmlString of ('x -> string)
| Text of ('x -> string)
| Json of ('x -> 'y)
let private GetMimeTypeFromStaticResponseFunction (func: Static<'x>) =
match func with
| Static.HtmlDocument _
| Static.HtmlElements _
| Static.HtmlElement _
| Static.HtmlString _ -> "text/html"
| Static.Json _ -> "application/json"
| Static.Text _ -> "text/plain"
let private GetMimeTypeFromDynamicResponseFunction (func: Dynamic<'x, 'y>) =
match func with
| Dynamic.HtmlDocument _
| Dynamic.HtmlElements _
| Dynamic.HtmlElement _
| Dynamic.HtmlString _ -> "text/html"
| Dynamic.Json _ -> "application/json"
| Dynamic.Text _ -> "text/plain"
let StaticView (func: unit -> Giraffe.ViewEngine.HtmlElements.XmlNode) = Static.HtmlDocument func
let BuildApp (args: string[]) =
WebApplication
.CreateBuilder(args)
.Build()
let AddStaticRoute (route: string) (func: Static<'x>) (app: WebApplication) =
let htmlResponseFunc (context: HttpContext) =
let htmlContent =
match func with
| Static.HtmlDocument view -> view () |> Giraffe.ViewEngine.RenderView.AsString.htmlDocument
| Static.HtmlElement view -> view () |> Giraffe.ViewEngine.RenderView.AsString.htmlNode
| Static.HtmlElements view -> view () |> Giraffe.ViewEngine.RenderView.AsString.htmlNodes
| Static.HtmlString view -> view ()
| Static.Text view -> view ()
| Static.Json view -> view () |> Thoth.Json.Net.Encode.Auto.toString
context.Response.ContentType <- GetMimeTypeFromStaticResponseFunction func
context.Response.WriteAsync(htmlContent)
app.MapGet(route, Func<HttpContext, Task>(htmlResponseFunc))
|> ignore
app
let AddDynamicRoute (route: string) (func: Dynamic<'x, 'y>) (app: WebApplication) =
let dynamicResponseFunc (context: HttpContext) =
let param = context.Request.RouteValues.Values.First() :?> 'x
let content =
match func with
| Dynamic.HtmlDocument view -> view param |> Giraffe.ViewEngine.RenderView.AsString.htmlDocument
| Dynamic.HtmlElement view -> view param |> Giraffe.ViewEngine.RenderView.AsString.htmlNode
| Dynamic.HtmlElements view -> view param |> Giraffe.ViewEngine.RenderView.AsString.htmlNodes
| Dynamic.HtmlString view -> view param
| Dynamic.Text view -> view param
| Dynamic.Json view -> view param |> Thoth.Json.Net.Encode.Auto.toString
context.Response.ContentType <- GetMimeTypeFromDynamicResponseFunction func
context.Response.WriteAsync(content)
app.MapGet(route, dynamicResponseFunc) |> ignore
app
let Run (app: WebApplication) =
app.Run()
0

View File

@@ -0,0 +1,15 @@
module Svg
open Giraffe.ViewEngine
let svg = tag "svg"
let rect = tag "rect"
let animate = tag "animate"
let _attributeName = attr "attributeName"
let _attributeType = attr "attributeType"
let _values = attr "values"
let _dur = attr "dur"
let _fill = attr "fill"
let _begin = attr "begin"
let _repeatCount = attr "repeatCount"

View File

@@ -0,0 +1,12 @@
module Types
type usernameQuery = {
fr24user: string
}
type AirportResponseData = obj[][]
type Airport = {
Code: string
City: string
}

View File

@@ -0,0 +1,84 @@
module View
open System
open Htmx
open Giraffe.ViewEngine
open Giraffe.Core
open Types
let index content =
let hide =
match content |> Seq.length with
| 0 -> "hide"
| _ -> ""
html [] [
head [] [
meta [_name "viewport"; _content "width=device-width"]
title [] [str "Have you flown the alphabet?"]
link [_rel "stylesheet" ; _href "/styles/core.css"]
script [_src "/scripts/htmx.min.js"] []
script [_src "/scripts/json-enc.js"] []
script [_src "/scripts/index.js"] []
]
body [] [
div [_id "pageContainer"] [
section [_id "input"] [
article [] [
h1 [] [str "Have you flown the alphabet?"]
form [
_id "username"
_hxTrigger "submit"
_hxExt "json-enc"
_hxPost "/search"
_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!"]
Svg.svg [_width "200"; _height "30"] [
Svg.rect [_width "200"; _height "30"; Svg._fill "lightgray"] []
Svg.rect [_width "50"; _height "30"; Svg._fill "white"] [
Svg.animate [Svg._attributeName "x"; Svg._attributeType "XML"; Svg._values "0;150;0"; Svg._dur "2s"; Svg._begin "0s"; Svg._repeatCount "indefinite"] []
]
]
]
]
]
section [_id "results"; _class hide; attr "hx-on::after-swap" "document.getElementById('results').style.display='flex'"] [
yield! content
]
]
]
]
let airportAbbr airport = abbr [_title airport.City] [str airport.Code]
let comma = str ", "
let tableRow (letter: string ) (codes: XmlNode[]) =
let className =
match codes.Length with
| 0 -> "not-flown"
| _ -> "flown"
tr [_class className] [
th [] [str letter]
td [] [
yield! codes
]
]
let table (message) (user: string) (rows: XmlNode[]) =
article [] [
h1 [] [str message]
table [] [
tr [] [
th [_colspan "2"] [str $"{user}'s Airports Flown"]
]
yield! rows
]
]
let name (name:usernameQuery) : HttpHandler = h1 [] [str name.fr24user] |> htmlView
let error = b [] [str "Error"]

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded", (event) => document.getElementById('results').scrollIntoView());

View File

@@ -0,0 +1,12 @@
htmx.defineExtension('json-enc', {
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = "application/json";
}
},
encodeParameters : function(xhr, parameters, elt) {
xhr.overrideMimeType('text/json');
return (JSON.stringify(parameters));
}
});

View File

@@ -0,0 +1,198 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
scroll-behavior: smooth;
font-family: manrope, sans-serif;
overscroll-behavior: none;
}
a {
cursor: pointer;
}
h1 {
display: flex;
justify-content: center;
align-items: center;
word-wrap: break-word;
white-space: normal;
text-align: center;
}
#pageContainer::-webkit-scrollbar {
width: 0;
height: 0;
}
#pageContainer {
height: 100dvh;
width: 100dvw;
max-width: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
}
#pageContainer abbr {
cursor: pointer;
}
#pageContainer section {
display: flex;
justify-content: center;
align-items: center;
height: 100dvh;
width: 100dvw;
max-width: 100%;
scroll-snap-align: start;
}
#pageContainer section.hide {
display: none;
}
#pageContainer section#input {
background: lightcoral;
}
#pageContainer section#input article {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 25%;
}
#pageContainer section#input article h1 {
flex-basis: 75%;
color: white;
font-size: 5em;
}
#pageContainer section#input article form {
display: flex;
justify-content: center;
align-items: center;
flex-basis: 25%;
width: 100%;
}
#pageContainer section#input article form input {
flex-basis: 75%;
height: 100%;
font-size: 2em;
padding-left: 1em;
color: #666;
}
#pageContainer section#input article form button {
flex-basis: 25%;
font-size: 2em;
height: 100%;
color: white;
background: grey;
}
#pageContainer section#input article form svg {
display: none;
}
@media screen and (max-width: 900px) {
#pageContainer section#input article {
gap: 2em;
height: 100%;
}
#pageContainer section#input article h1 {
font-size: 4em;
}
#pageContainer section#input article form {
flex-direction: column;
flex-basis: 60%;
width: 90%;
justify-content: flex-start;
}
#pageContainer section#input article form input, #pageContainer section#input article form button {
flex-basis: 25%;
width: 100%;
font-size: 1em;
}
}
#pageContainer section#input article form.htmx-request input, #pageContainer section#input article form.htmx-request button {
display: none;
}
#pageContainer section#input article form.htmx-request svg {
display: block;
}
#pageContainer section#results {
background: lightblue;
min-height: 100dvh;
height: auto;
}
#pageContainer section#results h1 {
color: white;
font-size: 5em;
}
#pageContainer section#results article {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 1em;
}
#pageContainer section#results article table {
border: solid 2px black;
background: white;
border-collapse: collapse;
min-width: 50%;
max-width: 90%;
}
#pageContainer section#results article table tr th:first-child,
#pageContainer section#results article table tr td:first-child {
width: 10%;
min-width: 10%;
max-width: 10%;
word-break: break-all;
}
#pageContainer section#results article table th {
background-color: gray;
color: white;
}
#pageContainer section#results article table td, #pageContainer section#results article table th {
padding: 0.3em;
}
#pageContainer section#results article table tr.flown th {
border-right: 1px solid gray;
}
#pageContainer section#results article table tr.flown th, #pageContainer section#results article table tr.flown td {
background-color: lightgreen;
color: black;
}
#pageContainer section#results article table tr.not-flown th {
border-right: 1px solid gray;
}
#pageContainer section#results article table tr.not-flown th, #pageContainer section#results article table tr.not-flown td {
background-color: lightcoral;
color: black;
}
@media screen and (max-width: 900px) {
#pageContainer section#results article {
min-height: 100%;
}
#pageContainer section#results article h1 {
width: 90%;
flex-basis: 25%;
font-size: 2em;
}
#pageContainer section#results article table {
width: 100dvw;
}
#pageContainer section#results article table tr th:first-child,
#pageContainer section#results article table tr td:first-child {
width: 10dvw;
min-width: 10dvw;
max-width: 10dvw;
word-break: break-all;
}
#pageContainer section#results article table tr th:nth-child(2),
#pageContainer section#results article table tr td:nth-child(2) {
width: 90dvw;
min-width: 90dvw;
max-width: 90dvw;
white-space: normal;
word-break: break-all;
}
}
/*# sourceMappingURL=core.css.map */

View File

@@ -0,0 +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"}

View File

@@ -0,0 +1,193 @@
*
margin: 0
padding: 0
box-sizing: border-box
scroll-behavior: smooth
font-family: manrope, sans-serif
overscroll-behavior: none
a
cursor: pointer
@mixin mobile
@media screen and (max-width: 900px)
@content
@mixin flex
display: flex
justify-content: center
align-items: center
@mixin flex-column
@include flex
flex-direction: column
h1
@include flex
word-wrap: break-word
white-space: normal
text-align: center
#pageContainer::-webkit-scrollbar
width: 0
height: 0
#pageContainer
height: 100dvh
width: 100dvw
max-width: 100%
overflow-y: auto
scroll-snap-type: y mandatory
white-space: nowrap
scrollbar-width: none /* Firefox */
-ms-overflow-style: none /* Internet Explorer 10+ */
abbr
cursor: pointer
section
@include flex
height: 100dvh
width: 100dvw
max-width: 100%
scroll-snap-align: start
section.hide
display: none
section#input
background: lightcoral
article
@include flex-column
height: 25%
h1
flex-basis: 75%
color: white
font-size: 5em
form
@include flex
flex-basis: 25%
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
svg
display: none
@include mobile
gap: 2em
height: 100%
h1
font-size: 4em
form
flex-direction: column
flex-basis: 60%
width: 90%
justify-content: flex-start
input, button
flex-basis: 25%
width: 100%
font-size: 1em
form.htmx-request
input, button
display: none
svg
display: block
section#results
background: lightblue
min-height: 100dvh
height: auto
h1
color: white
font-size: 5em
article
@include flex-column
gap: 1em
table
border: solid 2px black
background: white
border-collapse: collapse
min-width: 50%
max-width: 90%
tr th:first-child,
tr td:first-child
width: 10%
min-width: 10%
max-width: 10%
word-break: break-all
th
background-color: gray
color: white
td, th
padding: 0.3em
tr.flown
th
border-right: 1px solid gray
th, td
background-color: lightgreen
color: black
tr.not-flown
th
border-right: 1px solid gray
th, td
background-color: lightcoral
color: black
@include mobile
min-height: 100%
h1
width: 90%
flex-basis: 25%
font-size: 2em
table
width: 100dvw
tr th:first-child,
tr td:first-child
width: 10dvw
min-width: 10dvw
max-width: 10dvw
word-break: break-all
tr th:nth-child(2),
tr td:nth-child(2)
width: 90dvw
min-width: 90dvw
max-width: 90dvw
white-space: normal
word-break: break-all