Initial commit of RailMilesNG
This commit is contained in:
commit
c2faaa938f
59 changed files with 6560 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
run/
|
40
go.mod
Normal file
40
go.mod
Normal file
|
@ -0,0 +1,40 @@
|
|||
module github.com/codemicro/railmiles
|
||||
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
git.tdpain.net/pkg/cfger v0.1.0
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/carlmjohnson/requests v0.23.4
|
||||
github.com/gofiber/fiber/v2 v2.48.0
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/zerolog v1.29.1
|
||||
github.com/uptrace/bun v1.1.14
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.14
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/fatih/color v1.15.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.16.3 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.48.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
114
go.sum
Normal file
114
go.sum
Normal file
|
@ -0,0 +1,114 @@
|
|||
git.tdpain.net/pkg/cfger v0.1.0 h1:Yhs2DaFIdbcSrbyywhsoxrHPevDEEBEKbqJqrUa3eso=
|
||||
git.tdpain.net/pkg/cfger v0.1.0/go.mod h1:Kq5/hsUnYSYM2BVGFtXMlYEDIsIYiZTz4MUIlqZeX0k=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/carlmjohnson/requests v0.23.4 h1:AxcvapfB9RPXLSyvAHk9YJoodQ43ZjzNHj6Ft3tQGdg=
|
||||
github.com/carlmjohnson/requests v0.23.4/go.mod h1:Qzp6tW4DQyainPP+tGwiJTzwxvElTIKm0B191TgTtOA=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v2 v2.48.0 h1:cRVMCb9aUJDsyHxGFLwz/sGzDggdailZZyptU9F9cU0=
|
||||
github.com/gofiber/fiber/v2 v2.48.0/go.mod h1:xqJgfqrc23FJuqGOW6DVgi3HyZEm2Mn9pRqUb2kHSX8=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY=
|
||||
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
|
||||
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
|
||||
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.14 h1:9OCGfP9ZDlh41u6OLerWdhBtJAVGXHr0xtxO4xWi6t0=
|
||||
github.com/uptrace/bun/extra/bundebug v1.1.14/go.mod h1:lto3guzS2v6mnQp1+akyE+ecBLOltevDDe324NXEYdw=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
|
||||
github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
|
||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
48
railmiles/internal/config/config.go
Normal file
48
railmiles/internal/config/config.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.tdpain.net/pkg/cfger"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Debug bool
|
||||
HTTP struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
RealTimeTrains struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
Database struct {
|
||||
DSN string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) HTTPAddress() string {
|
||||
return fmt.Sprintf("%s:%d", c.HTTP.Host, c.HTTP.Port)
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cl := cfger.New()
|
||||
const configFilename = "config.yml"
|
||||
if err := cl.Load(configFilename); err != nil {
|
||||
return nil, util.Wrap(err, "loading config from %s", configFilename)
|
||||
}
|
||||
|
||||
conf := new(Config)
|
||||
|
||||
conf.Debug = cl.WithDefault("debug", false).AsBool()
|
||||
|
||||
conf.HTTP.Host = cl.WithDefault("http.host", "127.0.0.1").AsString()
|
||||
conf.HTTP.Port = cl.WithDefault("http.port", 8080).AsInt()
|
||||
|
||||
conf.RealTimeTrains.Username = cl.Required("realtimetrains.username").AsString()
|
||||
conf.RealTimeTrains.Password = cl.Required("realtimetrains.password").AsString()
|
||||
|
||||
conf.Database.DSN = cl.WithDefault("database.dsn", "railmiles.db").AsString()
|
||||
|
||||
return conf, nil
|
||||
}
|
52
railmiles/internal/core/core.go
Normal file
52
railmiles/internal/core/core.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/config"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
)
|
||||
|
||||
type Core struct {
|
||||
config *config.Config
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func New(conf *config.Config, database *db.DB) *Core {
|
||||
return &Core{
|
||||
config: conf,
|
||||
db: database,
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed stationData.json
|
||||
var stationDataRaw []byte
|
||||
|
||||
type StationDetail struct {
|
||||
Name string
|
||||
Lat float32
|
||||
Lon float32
|
||||
}
|
||||
|
||||
var (
|
||||
stationData map[string]*StationDetail
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = json.Unmarshal(stationDataRaw, &stationData)
|
||||
}
|
||||
|
||||
func GetStationName(short string) string {
|
||||
x := short
|
||||
if ff, found := stationData[short]; found {
|
||||
x = ff.Name
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func GetStationDetail(short string) *StationDetail {
|
||||
if x, found := stationData[short]; found {
|
||||
return x
|
||||
}
|
||||
return nil
|
||||
}
|
81
railmiles/internal/core/geojson.go
Normal file
81
railmiles/internal/core/geojson.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
)
|
||||
|
||||
func (c *Core) GenerateJourneyGeoJSON(journeys []*db.Journey) string {
|
||||
var stations []string
|
||||
{
|
||||
for _, journey := range journeys {
|
||||
stations = append(stations, journey.To.Shortcode, journey.From.Shortcode)
|
||||
}
|
||||
stations = util.Deduplicate(stations)
|
||||
}
|
||||
|
||||
var res []any
|
||||
|
||||
journeyLoop:
|
||||
for _, journey := range journeys {
|
||||
feature := make(map[string]any)
|
||||
feature["type"] = "LineString"
|
||||
feature["properties"] = map[string]string{"id": journey.ID.String()}
|
||||
|
||||
routeStations := []string{journey.From.Shortcode}
|
||||
routeStations = append(routeStations, util.Map(journey.Via, func(x *db.StationName) string {
|
||||
return x.Shortcode
|
||||
})...)
|
||||
routeStations = append(routeStations, journey.To.Shortcode)
|
||||
|
||||
var route []string
|
||||
|
||||
for i := 0; i < len(routeStations)-1; i += 1 {
|
||||
a := routeStations[i]
|
||||
b := routeStations[i+1]
|
||||
|
||||
route = append(route, a)
|
||||
|
||||
points, err := c.GetCallingPoints(a, b)
|
||||
if err == nil {
|
||||
route = append(route, points...)
|
||||
}
|
||||
}
|
||||
|
||||
route = append(route, routeStations[len(routeStations)-1])
|
||||
|
||||
var coords [][]float32
|
||||
{
|
||||
last := len(route) - 1
|
||||
for i, point := range route {
|
||||
details := GetStationDetail(point)
|
||||
if details == nil {
|
||||
if i == 0 || i == last {
|
||||
continue journeyLoop
|
||||
}
|
||||
continue
|
||||
}
|
||||
coords = append(coords, []float32{details.Lon, details.Lat})
|
||||
}
|
||||
}
|
||||
|
||||
feature["coordinates"] = coords
|
||||
res = append(res, feature)
|
||||
}
|
||||
|
||||
for _, station := range stations {
|
||||
stationDetails := GetStationDetail(station)
|
||||
if stationDetails == nil {
|
||||
continue
|
||||
}
|
||||
res = append(res, map[string]any{
|
||||
"type": "Feature",
|
||||
"properties": map[string]any{"name": station + " " + GetStationName(station)},
|
||||
"geometry": map[string]any{"type": "Point", "coordinates": []float32{stationDetails.Lon, stationDetails.Lat}},
|
||||
})
|
||||
}
|
||||
|
||||
o, _ := json.Marshal(res)
|
||||
return string(o)
|
||||
}
|
37
railmiles/internal/core/getStationData.py
Normal file
37
railmiles/internal/core/getStationData.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
url = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
query = """[out:json][timeout:25];
|
||||
(node["ref:crs"];);
|
||||
out body;"""
|
||||
|
||||
print("Querying Overpass", file=sys.stderr)
|
||||
|
||||
r = requests.post(url, data={"data": query})
|
||||
r.raise_for_status()
|
||||
|
||||
rj = r.json()
|
||||
|
||||
out = {}
|
||||
|
||||
print("Processing results", file=sys.stderr)
|
||||
|
||||
for elem in rj.get("elements", []):
|
||||
tags = elem.get("tags", {})
|
||||
|
||||
crs = tags.get("ref:crs")
|
||||
if crs is None:
|
||||
continue
|
||||
|
||||
out[crs] = {
|
||||
"lat": elem.get("lat", 0),
|
||||
"lon": elem.get("lon", 0),
|
||||
"name": tags.get("name", ""),
|
||||
}
|
||||
|
||||
json.dump(out, open("stationData.json", "w"))
|
149
railmiles/internal/core/journeys.go
Normal file
149
railmiles/internal/core/journeys.go
Normal file
|
@ -0,0 +1,149 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type timeSince uint8
|
||||
|
||||
const (
|
||||
AllTime timeSince = iota
|
||||
LastMonth
|
||||
YearToDate
|
||||
)
|
||||
|
||||
func (ts timeSince) SQLDuration() (string, error) {
|
||||
var dur string
|
||||
switch ts {
|
||||
case AllTime:
|
||||
case LastMonth:
|
||||
dur = "-1 month"
|
||||
case YearToDate:
|
||||
dur = "start of year"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown timeSince %d", ts)
|
||||
}
|
||||
return dur, nil
|
||||
}
|
||||
|
||||
type GetJourneysArgs struct {
|
||||
Since timeSince
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (c *Core) GetJourneys(args *GetJourneysArgs) ([]*db.Journey, error) {
|
||||
var journeys []*db.Journey
|
||||
|
||||
q := c.db.DB.NewSelect().
|
||||
Model(&journeys).
|
||||
OrderExpr(`"journey"."date" DESC`)
|
||||
|
||||
if args.Offset != 0 {
|
||||
q = q.Offset(args.Offset)
|
||||
}
|
||||
|
||||
if args.Limit != 0 {
|
||||
q = q.Limit(args.Limit)
|
||||
}
|
||||
|
||||
dur, err := args.Since.SQLDuration()
|
||||
if err != nil {
|
||||
return nil, util.Wrap(err, "getting journeys")
|
||||
}
|
||||
|
||||
if dur != "" {
|
||||
q = q.Where(`"journey"."date" > date('now', ?)`, dur)
|
||||
}
|
||||
|
||||
if err := q.Scan(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("querying past journeys: %w", err)
|
||||
}
|
||||
return journeys, nil
|
||||
}
|
||||
|
||||
type JourneyStats struct {
|
||||
Count int `json:"count"`
|
||||
// RawCount is the count as in the number of rows, not the count as in the
|
||||
// number of trips (ie. counts return journeys as one)
|
||||
RawCount int `json:"rawCount"`
|
||||
Miles float32 `json:"miles"`
|
||||
}
|
||||
|
||||
func (c *Core) GetJourneyStats(since timeSince) (*JourneyStats, error) {
|
||||
var (
|
||||
rtns []bool
|
||||
distQtys []float32
|
||||
countQtys []int
|
||||
)
|
||||
|
||||
q := c.db.DB.NewSelect().
|
||||
Model((*db.Journey)(nil)).
|
||||
ColumnExpr("return, sum(distance), count(*)").
|
||||
Group("return")
|
||||
|
||||
dur, err := since.SQLDuration()
|
||||
if err != nil {
|
||||
return nil, util.Wrap(err, "getting journey stats")
|
||||
}
|
||||
|
||||
if dur != "" {
|
||||
q = q.Where(`"journey"."date" > date('now', ?)`, dur)
|
||||
}
|
||||
|
||||
if err := q.Scan(context.Background(), &rtns, &distQtys, &countQtys); err != nil {
|
||||
return nil, fmt.Errorf("querying total miles: %w", err)
|
||||
}
|
||||
|
||||
js := new(JourneyStats)
|
||||
for i := 0; i < len(rtns); i += 1 {
|
||||
if rtns[i] { // if this is a return journey:
|
||||
js.Count += countQtys[i] * 2
|
||||
js.Miles += distQtys[i] * 2
|
||||
} else {
|
||||
js.Count += countQtys[i]
|
||||
js.Miles += distQtys[i]
|
||||
}
|
||||
js.RawCount += countQtys[i]
|
||||
}
|
||||
|
||||
return js, nil
|
||||
}
|
||||
|
||||
func PopulateFullStationNames(journeys []*db.Journey) {
|
||||
for _, journey := range journeys {
|
||||
journey.From.Full = GetStationName(journey.From.Shortcode)
|
||||
journey.To.Full = GetStationName(journey.To.Shortcode)
|
||||
for _, via := range journey.Via {
|
||||
via.Full = GetStationName(via.Shortcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Core) GetJourney(id uuid.UUID) (*db.Journey, error) {
|
||||
j := new(db.Journey)
|
||||
err := c.db.DB.NewSelect().Model(j).Where("id = ?", id).Scan(context.Background(), j)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func (c *Core) DeleteJourney(id uuid.UUID) error {
|
||||
_, err := c.db.DB.NewDelete().Model((*db.Journey)(nil)).Where("id = ?", id).Exec(context.Background())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Core) InsertJourney(journey *db.Journey) error {
|
||||
_, err := c.db.DB.NewInsert().Model(journey).Exec(context.Background())
|
||||
return err
|
||||
}
|
173
railmiles/internal/core/realtimetrains.go
Normal file
173
railmiles/internal/core/realtimetrains.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/carlmjohnson/requests"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Core) GetRouteDistance(stations []string, services []string, date time.Time) (float32, error) {
|
||||
year := strconv.Itoa(time.Now().Year())
|
||||
month := strconv.Itoa(int(time.Now().Month()))
|
||||
if len(month) == 1 {
|
||||
month = "0" + month
|
||||
}
|
||||
day := strconv.Itoa(time.Now().Day())
|
||||
if len(day) == 1 {
|
||||
day = "0" + day
|
||||
}
|
||||
todayRunDate := date.Format("2006-01-02")
|
||||
|
||||
for i := 0; i < len(stations)-1; i += 1 {
|
||||
if services[i] == "" {
|
||||
var rttResp struct {
|
||||
Services []struct {
|
||||
ServiceUid string `json:"serviceUid"`
|
||||
IsPassenger bool `json:"isPassenger"`
|
||||
RunDate string `json:"runDate"`
|
||||
LocationDetail struct {
|
||||
DisplayAs string `json:"displayAs"`
|
||||
} `json:"locationDetail"`
|
||||
} `json:"services"`
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
err := requests.
|
||||
URL("https://api.rtt.io").
|
||||
Pathf("/api/v1/json/search/%s/to/%s/%s/%s/%s", stations[i], stations[i+1], year, month, day).
|
||||
ToJSON(&rttResp).
|
||||
BasicAuth(c.config.RealTimeTrains.Username, c.config.RealTimeTrains.Password).
|
||||
Fetch(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("search for service %s->%s: %w", stations[i], stations[i+1], err)
|
||||
}
|
||||
|
||||
uid := ""
|
||||
for _, service := range rttResp.Services {
|
||||
if service.RunDate == todayRunDate && // If this train started on a different date and runs through midnight
|
||||
!strings.EqualFold(service.LocationDetail.DisplayAs, "CANCELLED_CALL") && // If this train was cancelled
|
||||
service.IsPassenger {
|
||||
uid = service.ServiceUid
|
||||
break
|
||||
}
|
||||
}
|
||||
if uid == "" {
|
||||
return 0, errors.New("no route found")
|
||||
}
|
||||
services[i] = uid
|
||||
}
|
||||
}
|
||||
|
||||
var total float32
|
||||
for i := 0; i < len(stations)-1; i += 1 {
|
||||
dist, err := c.getSingleTrainDistance(services[i], stations[i], stations[i+1], date)
|
||||
if err != nil {
|
||||
return 0, util.Wrap(err, "scraping train")
|
||||
}
|
||||
total += dist
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
var shortcodeRegexp = regexp.MustCompile(`[A-Z]{3}`)
|
||||
|
||||
func (c *Core) getSingleTrainDistance(uid, departure, destination string, date time.Time) (float32, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
var htmlContent string
|
||||
err := requests.
|
||||
URL("https://www.realtimetrains.co.uk").
|
||||
Pathf("/service/gb-nr:%s/%s/detailed", uid, date.Format("2006-01-02")).
|
||||
ToString(&htmlContent).
|
||||
Fetch(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("fetch train with UID %s: %w", uid, err)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(htmlContent))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load RTT HTML: %w", err)
|
||||
}
|
||||
|
||||
var waypoints [][3]string
|
||||
|
||||
doc.Find(".location.call,.location.pass").Each(func(i int, selection *goquery.Selection) {
|
||||
shortcode := shortcodeRegexp.FindString(
|
||||
selection.Find(".location a").Text(),
|
||||
)
|
||||
|
||||
if shortcode == "" {
|
||||
return
|
||||
}
|
||||
|
||||
waypoints = append(waypoints, [3]string{
|
||||
shortcode,
|
||||
strings.TrimSpace(selection.Find("span.miles").Text()),
|
||||
strings.TrimSpace(selection.Find("span.chains").Text()),
|
||||
})
|
||||
})
|
||||
|
||||
var distances []float32
|
||||
|
||||
for _, wp := range waypoints {
|
||||
if strings.EqualFold(wp[0], departure) || strings.EqualFold(wp[0], destination) {
|
||||
if wp[1] == "" || wp[2] == "" {
|
||||
return 0, util.UserError(fmt.Errorf("no distance information provided for %s -> %s (%s) - manual distance required", departure, destination, uid))
|
||||
}
|
||||
miles, err := strconv.Atoi(wp[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse miles: %w (%#v)", err, wp[1])
|
||||
}
|
||||
chains, err := strconv.Atoi(wp[2])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse chains: %w (%#v)", err, wp[2])
|
||||
}
|
||||
distances = append(distances, float32(miles)+util.ChainsToMiles(chains))
|
||||
}
|
||||
}
|
||||
|
||||
if len(distances) != 2 {
|
||||
return 0, fmt.Errorf("unexpected number of occurences of source/dest stations in RTT HTML (got %d, expected 2)", len(distances))
|
||||
}
|
||||
|
||||
var route []string
|
||||
{
|
||||
var inbetweenTerminii bool
|
||||
for _, wp := range waypoints {
|
||||
if strings.EqualFold(wp[0], departure) {
|
||||
inbetweenTerminii = true
|
||||
} else if strings.EqualFold(wp[0], destination) {
|
||||
if !inbetweenTerminii {
|
||||
return 0, errors.New("unexpectedly formatted route: destination before departure")
|
||||
}
|
||||
break
|
||||
} else if inbetweenTerminii {
|
||||
route = append(route, wp[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = c.InsertRoute(&db.Route{
|
||||
From: departure,
|
||||
To: destination,
|
||||
Route: route,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed to save route")
|
||||
}
|
||||
|
||||
return float32(math.Abs(float64(distances[1] - distances[0]))), nil
|
||||
}
|
35
railmiles/internal/core/routes.go
Normal file
35
railmiles/internal/core/routes.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
)
|
||||
|
||||
func (c *Core) GetCallingPoints(from, to string) ([]string, error) {
|
||||
route := new(db.Route)
|
||||
err := c.db.DB.NewSelect().Model(route).Where(`"from" = ? and "to" = ?`, from, to).Scan(context.Background(), route)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err := c.db.DB.NewSelect().Model(route).Where(`"from" = ? and "to" = ?`, to, from).Scan(context.Background(), route)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// reverse route
|
||||
for i, j := 0, len(route.Route)-1; i < j; i, j = i+1, j-1 {
|
||||
route.Route[i], route.Route[j] = route.Route[j], route.Route[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return route.Route, err
|
||||
}
|
||||
|
||||
func (c *Core) InsertRoute(route *db.Route) error {
|
||||
_, err := c.db.DB.NewInsert().Ignore().Model(route).Exec(context.Background())
|
||||
return err
|
||||
}
|
1
railmiles/internal/core/stationData.json
Normal file
1
railmiles/internal/core/stationData.json
Normal file
File diff suppressed because one or more lines are too long
26
railmiles/internal/db/20230514165643_initialise.go
Normal file
26
railmiles/internal/db/20230514165643_initialise.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
if _, err := db.NewCreateTable().Model((*Journey)(nil)).Exec(ctx); err != nil {
|
||||
return util.Wrap(err, "creating journey table")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
if _, err := db.NewDropTable().Model((*Journey)(nil)).Exec(ctx); err != nil {
|
||||
return util.Wrap(err, "dropping journey table")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
31
railmiles/internal/db/20230610145848_routes.go
Normal file
31
railmiles/internal/db/20230610145848_routes.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.NewRaw(`CREATE TABLE "railmiles_routes" (
|
||||
"from" VARCHAR COLLATE NOCASE,
|
||||
"to" VARCHAR COLLATE NOCASE,
|
||||
"route" VARCHAR,
|
||||
PRIMARY KEY ("from", "to")
|
||||
)
|
||||
`).Exec(ctx)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "creating routes table")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
if _, err := db.NewDropTable().Model((*Route)(nil)).Exec(ctx); err != nil {
|
||||
return util.Wrap(err, "dropping routes table")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
65
railmiles/internal/db/db.go
Normal file
65
railmiles/internal/db/db.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/config"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
"github.com/uptrace/bun/extra/bundebug"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
"golang.org/x/exp/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Migrations = migrate.NewMigrations()
|
||||
|
||||
type DB struct {
|
||||
DB *bun.DB
|
||||
}
|
||||
|
||||
func New(conf *config.Config) (*DB, error) {
|
||||
dsn := conf.Database.DSN
|
||||
slog.Info("connecting to database")
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1) // https://github.com/mattn/go-sqlite3/issues/274#issuecomment-191597862
|
||||
|
||||
b := bun.NewDB(db, sqlitedialect.New())
|
||||
|
||||
if conf.Debug {
|
||||
b.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
|
||||
}
|
||||
|
||||
return &DB{b}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Migrate() error {
|
||||
slog.Info("running database migrations")
|
||||
|
||||
mig := migrate.NewMigrator(db.DB, Migrations)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := mig.Init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := mig.Migrate(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsZero() {
|
||||
slog.Info("no migrations applied (database up to date)")
|
||||
} else {
|
||||
slog.Info("migrations applied")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
65
railmiles/internal/db/types.go
Normal file
65
railmiles/internal/db/types.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Journey struct {
|
||||
bun.BaseModel `bun:"table:railmiles_journeys" json:"-"`
|
||||
|
||||
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
|
||||
|
||||
From *StationName `json:"from"`
|
||||
To *StationName `json:"to"`
|
||||
Via []*StationName `bun:",nullzero" json:"via"`
|
||||
Distance float32 `json:"distance"`
|
||||
Date time.Time `json:"date"`
|
||||
Return bool `json:"return"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
bun.BaseModel `bun:"table:railmiles_routes"`
|
||||
|
||||
From string
|
||||
To string
|
||||
Route []string `bun:",nullzero"`
|
||||
}
|
||||
|
||||
type StationName struct {
|
||||
Shortcode string
|
||||
Full string
|
||||
}
|
||||
|
||||
func (sn *StationName) UnmarshalJSON(x []byte) error {
|
||||
if bytes.Equal(x, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(x, &sn.Shortcode)
|
||||
}
|
||||
|
||||
func (sn *StationName) MarshalJSON() ([]byte, error) {
|
||||
if sn.Full == "" {
|
||||
return json.Marshal(sn.Shortcode)
|
||||
} else {
|
||||
return json.Marshal(map[string]string{"full": sn.Full, "shortcode": sn.Shortcode})
|
||||
}
|
||||
}
|
||||
|
||||
func (sn *StationName) Scan(src any) error {
|
||||
if t, ok := src.(string); !ok {
|
||||
return fmt.Errorf("(*StationName).Scan can only read strings (not %T)", src)
|
||||
} else {
|
||||
sn.Shortcode = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sn *StationName) Value() (driver.Value, error) {
|
||||
return sn.Shortcode, nil
|
||||
}
|
52
railmiles/internal/httpsrv/dashboard.go
Normal file
52
railmiles/internal/httpsrv/dashboard.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/core"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (hs *httpServer) dashboardInfo(ctx *fiber.Ctx) error {
|
||||
var response = struct {
|
||||
GeoJSON json.RawMessage `json:"geoJSON,omitempty"`
|
||||
Stats struct {
|
||||
LastMonth *core.JourneyStats `json:"lastMonth"`
|
||||
YTD *core.JourneyStats `json:"ytd"`
|
||||
AllTime *core.JourneyStats `json:"allTime"`
|
||||
} `json:"stats"`
|
||||
Journeys []*db.Journey `json:"journeys"`
|
||||
}{}
|
||||
|
||||
journeys, err := hs.core.GetJourneys(&core.GetJourneysArgs{Since: core.LastMonth})
|
||||
if err != nil {
|
||||
return util.Wrap(err, "fetching journeys in the last month")
|
||||
}
|
||||
|
||||
response.GeoJSON = []byte(hs.core.GenerateJourneyGeoJSON(journeys))
|
||||
|
||||
lastMonthStats, err := hs.core.GetJourneyStats(core.LastMonth)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "fetching last month stats")
|
||||
}
|
||||
|
||||
ytdStats, err := hs.core.GetJourneyStats(core.YearToDate)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "fetching year-to-date stats")
|
||||
}
|
||||
|
||||
allTimeStats, err := hs.core.GetJourneyStats(core.AllTime)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "fetching all time stats")
|
||||
}
|
||||
|
||||
response.Stats.LastMonth = lastMonthStats
|
||||
response.Stats.YTD = ytdStats
|
||||
response.Stats.AllTime = allTimeStats
|
||||
|
||||
core.PopulateFullStationNames(journeys)
|
||||
response.Journeys = journeys
|
||||
|
||||
return ctx.JSON(response)
|
||||
}
|
48
railmiles/internal/httpsrv/httpsrv.go
Normal file
48
railmiles/internal/httpsrv/httpsrv.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
"github.com/codemicro/railmiles/railmiles/internal/config"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/core"
|
||||
webAssets "github.com/codemicro/railmiles/web"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type httpServer struct {
|
||||
config *config.Config
|
||||
core *core.Core
|
||||
}
|
||||
|
||||
func Run(conf *config.Config, c *core.Core) error {
|
||||
srv := &httpServer{
|
||||
config: conf,
|
||||
core: c,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
if conf.Debug {
|
||||
app.Use(cors.New())
|
||||
}
|
||||
srv.registerRoutes(app)
|
||||
|
||||
return app.Listen(conf.HTTPAddress())
|
||||
}
|
||||
|
||||
func (hs *httpServer) registerRoutes(app *fiber.App) {
|
||||
app.Get("/api/dashboard", hs.dashboardInfo)
|
||||
app.Get("/api/journeys", hs.journeyListing)
|
||||
app.Post("/api/journeys", hs.newJourney)
|
||||
app.Get("/api/journeys/:id", hs.getJourney)
|
||||
app.Delete("/api/journeys/:id", hs.deleteJourney)
|
||||
app.Use(filesystem.New(filesystem.Config{
|
||||
Root: http.FS(webAssets.Public),
|
||||
PathPrefix: "public",
|
||||
}))
|
||||
}
|
||||
|
||||
type StockResponse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
94
railmiles/internal/httpsrv/journeys.go
Normal file
94
railmiles/internal/httpsrv/journeys.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/core"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (hs *httpServer) journeyListing(ctx *fiber.Ctx) error {
|
||||
const pageSize = 20
|
||||
|
||||
var pageNumber uint
|
||||
{
|
||||
pageNumberStr := ctx.Query("page", "0")
|
||||
pageNumber64, _ := strconv.ParseUint(pageNumberStr, 10, 32)
|
||||
pageNumber = uint(pageNumber64)
|
||||
}
|
||||
|
||||
var response = struct {
|
||||
NumPages int `json:"numPages"`
|
||||
PageNumber uint `json:"pageNumber"`
|
||||
Data []*db.Journey `json:"data"`
|
||||
}{
|
||||
PageNumber: pageNumber,
|
||||
}
|
||||
|
||||
journeyStats, err := hs.core.GetJourneyStats(core.AllTime)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "getting all journey stats")
|
||||
}
|
||||
|
||||
fmt.Println(math.Ceil(float64(journeyStats.RawCount/pageSize)), journeyStats.RawCount, pageSize)
|
||||
response.NumPages = int(math.Ceil(float64(journeyStats.RawCount/pageSize))) + 1
|
||||
|
||||
if !(int(pageNumber*pageSize) > journeyStats.RawCount) {
|
||||
journeys, err := hs.core.GetJourneys(&core.GetJourneysArgs{Offset: int(pageSize * pageNumber), Limit: pageSize})
|
||||
if err != nil {
|
||||
return util.Wrap(err, "getting paginated journeys")
|
||||
}
|
||||
core.PopulateFullStationNames(journeys)
|
||||
response.Data = journeys
|
||||
}
|
||||
|
||||
return ctx.JSON(response)
|
||||
}
|
||||
|
||||
func (hs *httpServer) getJourney(ctx *fiber.Ctx) error {
|
||||
var response = struct {
|
||||
GeoJSON json.RawMessage `json:"geoJSON"`
|
||||
Data *db.Journey `json:"data"`
|
||||
}{}
|
||||
|
||||
id, err := uuid.Parse(ctx.Params("id"))
|
||||
if err != nil {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
journey, err := hs.core.GetJourney(id)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "fetching journey %s", id.String())
|
||||
}
|
||||
|
||||
if journey == nil {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
ja := []*db.Journey{journey}
|
||||
core.PopulateFullStationNames(ja)
|
||||
|
||||
response.Data = journey
|
||||
response.GeoJSON = []byte(hs.core.GenerateJourneyGeoJSON(ja))
|
||||
|
||||
return ctx.JSON(&response)
|
||||
}
|
||||
|
||||
func (hs *httpServer) deleteJourney(ctx *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(ctx.Params("id"))
|
||||
if err != nil {
|
||||
return fiber.ErrNotFound
|
||||
}
|
||||
|
||||
if err := hs.core.DeleteJourney(id); err != nil {
|
||||
return util.Wrap(err, "deleting journey %s", id.String())
|
||||
}
|
||||
|
||||
ctx.Status(204)
|
||||
return nil
|
||||
}
|
128
railmiles/internal/httpsrv/newJourney.go
Normal file
128
railmiles/internal/httpsrv/newJourney.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
package httpsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var locationsRegexp = regexp.MustCompile(`(?:(?:[A-Z]){3}(?:, ?[A-Z][0-9]{5})?\n?)+`)
|
||||
|
||||
func (hs *httpServer) newJourney(ctx *fiber.Ctx) error {
|
||||
var requestBody = struct {
|
||||
Date time.Time `json:"date"`
|
||||
Route string `json:"route"`
|
||||
ManualDistance float32 `json:"manualDistance"`
|
||||
IsReturn bool `json:"isReturn"`
|
||||
}{}
|
||||
|
||||
var response = struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
}{}
|
||||
|
||||
if !strings.EqualFold(ctx.Get("Content-Type"), "application/json") {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "invalid Content-Type (requires application/json)",
|
||||
})
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(ctx.Body(), &requestBody); err != nil {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "unable to parse request body",
|
||||
})
|
||||
}
|
||||
|
||||
if requestBody.Date.After(time.Now()) {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "Invalid date: occurs in the future",
|
||||
})
|
||||
}
|
||||
|
||||
requestBody.Date = requestBody.Date.UTC()
|
||||
|
||||
if !locationsRegexp.MatchString(requestBody.Route) {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "Invalid route format",
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
needsServiceUID = time.Now().UTC().Truncate(24*time.Hour) != requestBody.Date.Truncate(24*time.Hour)
|
||||
locations []string
|
||||
services []string
|
||||
)
|
||||
|
||||
{
|
||||
lines := strings.Split(requestBody.Route, "\n")
|
||||
for i, line := range lines {
|
||||
p := strings.Split(line, ",")
|
||||
if len(p) == 1 {
|
||||
if needsServiceUID && i != len(lines)-1 && requestBody.ManualDistance == 0 {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "Service UIDs required as services were run on a different day to today",
|
||||
})
|
||||
}
|
||||
services = append(services, "")
|
||||
} else {
|
||||
services = append(services, strings.TrimSpace(p[1]))
|
||||
}
|
||||
locations = append(locations, strings.TrimSpace(p[0]))
|
||||
}
|
||||
}
|
||||
|
||||
var dist float32
|
||||
if requestBody.ManualDistance != 0 {
|
||||
dist = requestBody.ManualDistance
|
||||
} else {
|
||||
var err error
|
||||
dist, err = hs.core.GetRouteDistance(locations, services, requestBody.Date)
|
||||
if err != nil {
|
||||
ctx.Status(400)
|
||||
return ctx.JSON(StockResponse{
|
||||
Ok: false,
|
||||
Message: "Unable to fetch distance: " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var via []string
|
||||
if len(locations) > 2 {
|
||||
via = locations[1 : len(locations)-1]
|
||||
}
|
||||
|
||||
j := &db.Journey{
|
||||
ID: uuid.New(),
|
||||
From: &db.StationName{Shortcode: locations[0]},
|
||||
To: &db.StationName{Shortcode: locations[len(locations)-1]},
|
||||
Via: util.Map(via, func(x string) *db.StationName {
|
||||
return &db.StationName{Shortcode: x}
|
||||
}),
|
||||
Distance: dist,
|
||||
Date: requestBody.Date,
|
||||
Return: requestBody.IsReturn,
|
||||
}
|
||||
|
||||
if err := hs.core.InsertJourney(j); err != nil {
|
||||
return fmt.Errorf("inserting new journey: %w", err)
|
||||
}
|
||||
|
||||
response.ID = j.ID
|
||||
|
||||
return ctx.JSON(&response)
|
||||
}
|
44
railmiles/internal/util/util.go
Normal file
44
railmiles/internal/util/util.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type UserError error
|
||||
|
||||
func Wrap(err error, msg string, args ...any) error {
|
||||
e := err
|
||||
for e != nil {
|
||||
if _, ok := e.(UserError); ok {
|
||||
return err
|
||||
}
|
||||
e = errors.Unwrap(err)
|
||||
}
|
||||
return fmt.Errorf(msg+": %w", append(args, err)...)
|
||||
}
|
||||
|
||||
func Deduplicate[T comparable](x []T) []T {
|
||||
var m = make(map[T]struct{})
|
||||
var res []T
|
||||
for _, v := range x {
|
||||
if _, found := m[v]; !found {
|
||||
res = append(res, v)
|
||||
m[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func Map[T, Q comparable](x []T, y func(T) Q) []Q {
|
||||
var res []Q
|
||||
for _, z := range x {
|
||||
res = append(res, y(z))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func ChainsToMiles(chains int) float32 {
|
||||
// 80 chains to a mile
|
||||
return float32(chains) / 80
|
||||
}
|
38
railmiles/main.go
Normal file
38
railmiles/main.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/codemicro/railmiles/railmiles/internal/config"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/core"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/db"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/httpsrv"
|
||||
"github.com/codemicro/railmiles/railmiles/internal/util"
|
||||
"golang.org/x/exp/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("unhandled error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
conf, err := config.Load()
|
||||
if err != nil {
|
||||
return util.Wrap(err, "loading configuration")
|
||||
}
|
||||
|
||||
database, err := db.New(conf)
|
||||
if err != nil {
|
||||
return util.Wrap(err, "opening database")
|
||||
}
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
return util.Wrap(err, "migrating database")
|
||||
}
|
||||
|
||||
c := core.New(conf, database)
|
||||
|
||||
return httpsrv.Run(conf, c)
|
||||
}
|
4
web/.gitignore
vendored
Normal file
4
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
/node_modules/
|
||||
/public/build/
|
||||
|
||||
.DS_Store
|
1679
web/package-lock.json
generated
Normal file
1679
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
web/package.json
Normal file
26
web/package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"start": "sirv public --no-clear"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^24.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"rollup": "^3.15.0",
|
||||
"rollup-plugin-css-only": "^4.3.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.1.2",
|
||||
"svelte": "^3.55.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"sirv-cli": "^2.0.0",
|
||||
"svelte-spa-router": "^3.3.0"
|
||||
}
|
||||
}
|
1981
web/public/assets/bootstrap-icons.css
vendored
Normal file
1981
web/public/assets/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
7
web/public/assets/bootstrap.bundle.min.js
vendored
Normal file
7
web/public/assets/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/public/assets/bootstrap.bundle.min.js.map
Normal file
1
web/public/assets/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
6
web/public/assets/bootstrap.min.css
vendored
Normal file
6
web/public/assets/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/public/assets/bootstrap.min.css.map
Normal file
1
web/public/assets/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
5
web/public/assets/icons/bootstrap-icons.min.css
vendored
Normal file
5
web/public/assets/icons/bootstrap-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/public/assets/icons/fonts/bootstrap-icons.woff
Normal file
BIN
web/public/assets/icons/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
web/public/assets/icons/fonts/bootstrap-icons.woff2
Normal file
BIN
web/public/assets/icons/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
BIN
web/public/assets/leaflet/images/layers-2x.png
Normal file
BIN
web/public/assets/leaflet/images/layers-2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
web/public/assets/leaflet/images/layers.png
Normal file
BIN
web/public/assets/leaflet/images/layers.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 696 B |
BIN
web/public/assets/leaflet/images/marker-icon-2x.png
Normal file
BIN
web/public/assets/leaflet/images/marker-icon-2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/assets/leaflet/images/marker-icon.png
Normal file
BIN
web/public/assets/leaflet/images/marker-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
web/public/assets/leaflet/images/marker-shadow.png
Normal file
BIN
web/public/assets/leaflet/images/marker-shadow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 618 B |
661
web/public/assets/leaflet/leaflet.css
Normal file
661
web/public/assets/leaflet/leaflet.css
Normal file
|
@ -0,0 +1,661 @@
|
|||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
3
web/public/assets/styles.css
Normal file
3
web/public/assets/styles.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@import "bootstrap.min.css";
|
||||
@import "icons/bootstrap-icons.min.css";
|
||||
@import "leaflet/leaflet.css";
|
BIN
web/public/favicon.png
Normal file
BIN
web/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
19
web/public/index.html
Normal file
19
web/public/index.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
|
||||
<title>RailMiles</title>
|
||||
|
||||
<link rel='icon' type='image/png' href='/favicon.png'>
|
||||
<link href="assets/styles.css" rel="stylesheet">
|
||||
<script defer src="assets/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<link rel='stylesheet' href='build/bundle.css'>
|
||||
<script defer src='build/bundle.js'></script>
|
||||
</head>
|
||||
|
||||
<body style="height: 100%;">
|
||||
</body>
|
||||
</html>
|
78
web/rollup.config.js
Normal file
78
web/rollup.config.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { spawn } from 'child_process';
|
||||
import svelte from 'rollup-plugin-svelte';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import livereload from 'rollup-plugin-livereload';
|
||||
import css from 'rollup-plugin-css-only';
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH;
|
||||
|
||||
function serve() {
|
||||
let server;
|
||||
|
||||
function toExit() {
|
||||
if (server) server.kill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
writeBundle() {
|
||||
if (server) return;
|
||||
server = spawn('npm', ['run', 'start', '--', '--dev'], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
shell: true
|
||||
});
|
||||
|
||||
process.on('SIGTERM', toExit);
|
||||
process.on('exit', toExit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
input: 'src/main.js',
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'iife',
|
||||
name: 'app',
|
||||
file: 'public/build/bundle.js'
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production
|
||||
}
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
css({ output: 'bundle.css' }),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ['svelte'],
|
||||
exportConditions: ['svelte']
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
// In dev mode, call `npm run start` once
|
||||
// the bundle has been generated
|
||||
!production && serve(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload('public'),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser()
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false
|
||||
}
|
||||
};
|
6
web/src/App.svelte
Normal file
6
web/src/App.svelte
Normal file
|
@ -0,0 +1,6 @@
|
|||
<script>
|
||||
import Router from 'svelte-spa-router'
|
||||
import routes from './routes'
|
||||
</script>
|
||||
|
||||
<Router {routes} />
|
78
web/src/components/BaseLayout.svelte
Normal file
78
web/src/components/BaseLayout.svelte
Normal file
|
@ -0,0 +1,78 @@
|
|||
<script>
|
||||
import {location} from "svelte-spa-router"
|
||||
import sidebarOptions from "./sidebarOptions.js"
|
||||
</script>
|
||||
|
||||
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-dark sidebar d-none d-md-block">
|
||||
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
|
||||
<i class="bi-train-front-fill pe-2" style="font-size: 32px;"></i>
|
||||
<span class="fs-4">RailMiles</span>
|
||||
</a>
|
||||
<hr>
|
||||
<ul class="nav nav-pills flex-column mb-auto">
|
||||
{#each sidebarOptions as {name, icon, path}}
|
||||
<li>
|
||||
<a href="#{path}" class={path === $location ? "nav-link active" : "nav-link text-white"}>
|
||||
<i class="bi-{icon}" style="font-size: 16px;"></i>
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="navbar bg-dark text-white d-block d-md-none">
|
||||
<div class="container-fluid">
|
||||
<a href="/" class="navbar-brand d-flex align-items-center text-white text-decoration-none">
|
||||
<i class="bi-train-front-fill pe-2"></i>
|
||||
<span class="fs-4">RailMiles</span>
|
||||
</a>
|
||||
<button class="navbar-toggler text-white" type="button" data-bs-toggle="collapse" data-bs-target="#navMobileDropdown">
|
||||
<i style="font-size: 1.75rem" class="bi-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="navMobileDropdown" data-bs-theme="dark">
|
||||
<ul class="nav nav-pills flex-column mb-auto bg-dark p-2">
|
||||
{#each sidebarOptions as {name, icon, path}}
|
||||
<li>
|
||||
<a href="#{path}" class={path === $location ? "nav-link active" : "nav-link text-white"}>
|
||||
<i class="bi-{icon}" style="font-size: 16px;"></i>
|
||||
{name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="custom-container" style="">
|
||||
<div class="pt-5 d-none d-sm-block"></div>
|
||||
<div class="pt-4 d-block d-md-none"></div>
|
||||
<slot></slot>
|
||||
<div class="pt-5"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
box-shadow: 0 2px 5px 0 rgb(0 0 0 / 5%), 0 2px 10px 0 rgb(0 0 0 / 5%);
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.custom-container {
|
||||
width: 80%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.custom-container {
|
||||
width: 90%;
|
||||
padding-left: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
5
web/src/components/ErrorAlert.svelte
Normal file
5
web/src/components/ErrorAlert.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
export let message;
|
||||
</script>
|
||||
|
||||
<div class="alert alert-danger" role="alert"><i class="bi-exclamation-octagon-fill"></i> {message}</div>
|
64
web/src/components/JourneyMap.svelte
Normal file
64
web/src/components/JourneyMap.svelte
Normal file
|
@ -0,0 +1,64 @@
|
|||
<script>
|
||||
import {onMount} from "svelte";
|
||||
import L from "leaflet";
|
||||
|
||||
export let geoJSON
|
||||
|
||||
let map
|
||||
let geoJSONLayer
|
||||
|
||||
const updateGeoJSON = (obj) => {
|
||||
if (map) {
|
||||
geoJSONLayer.removeFrom(map)
|
||||
}
|
||||
|
||||
geoJSONLayer = L.geoJSON(obj, { onEachFeature: (feature, layer) => {
|
||||
if (feature.properties && feature.properties.name) {
|
||||
layer.bindPopup(feature.properties.name);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (map) {
|
||||
geoJSONLayer.addTo(map)
|
||||
try {
|
||||
map.fitBounds(geoJSONLayer.getBounds()) // sometimes this errors, sometimes it doesn't
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$: updateGeoJSON(geoJSON)
|
||||
|
||||
onMount(() => {
|
||||
map = L.map("journey-map").setView([55.093, -2.894], 5);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
let ormOverlay = L.tileLayer('http://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', {
|
||||
attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a> and OpenStreetMap',
|
||||
minZoom: 2,
|
||||
maxZoom: 19,
|
||||
tileSize: 256,
|
||||
className: "tile-orm",
|
||||
});
|
||||
|
||||
L.control.layers({}, {"OpenRailwayMap": ormOverlay}).addTo(map);
|
||||
|
||||
updateGeoJSON(geoJSON)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div id="journey-map" class="mt-4"></div>
|
||||
|
||||
<style>
|
||||
#journey-map {
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
:global(#journey-map .tile-orm .leaflet-tile) {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
</style>
|
57
web/src/components/JourneyTable.svelte
Normal file
57
web/src/components/JourneyTable.svelte
Normal file
|
@ -0,0 +1,57 @@
|
|||
<script>
|
||||
import {formatDate, roundFloat} from "../util.js";
|
||||
|
||||
export let journeys = [];
|
||||
export let showMore = false;
|
||||
</script>
|
||||
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Route</th>
|
||||
<!-- <th scope="col">To</th>-->
|
||||
<th scope="col"></th>
|
||||
<th scope="col">Distance</th>
|
||||
<th scope="col">Return</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each journeys as journey (journey.id)}
|
||||
<tr>
|
||||
<td>{formatDate(journey.date)}</td>
|
||||
<td>{journey.from.full} to {journey.to.full}</td>
|
||||
<td>
|
||||
{#if journey.via}
|
||||
via
|
||||
{#each journey.via as station, i}
|
||||
{#if i !== 0}, {/if}<abbr title="{station.full}">{station.shortcode}</abbr>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
<td>{roundFloat(journey.distance, 1)} miles</td>
|
||||
<td>
|
||||
{#if journey.return }<i class="bi-check-lg"></i>{/if}
|
||||
</td>
|
||||
<td><a href="#/journeys/{journey.id}"><i class="bi-three-dots"></i></a></td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center bg-warning-subtle text-warning-emphasis">Nothing to display!</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if showMore}
|
||||
<tr>
|
||||
<td colspan="6"><a href="#/journeys">See more...</a></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<style>
|
||||
table {
|
||||
overflow: scroll;
|
||||
}
|
||||
</style>
|
42
web/src/components/Loading.svelte
Normal file
42
web/src/components/Loading.svelte
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
export let transparent = false
|
||||
export let text = undefined;
|
||||
</script>
|
||||
|
||||
<div class="panel" class:transparent>
|
||||
<div class="d-flex gap-4 flex-column justify-content-center align-items-center">
|
||||
<div class="spinner-border" style="width: 4rem; height: 4rem;" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
{#if text}
|
||||
<p>{text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 280px;
|
||||
width: calc(100% - 280px);
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
div.panel {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
div.panel.transparent {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
div.panel > div {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
5
web/src/components/SuccessAlert.svelte
Normal file
5
web/src/components/SuccessAlert.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script>
|
||||
export let message;
|
||||
</script>
|
||||
|
||||
<div class="alert alert-success" role="alert"><i class="bi-check-circle-fill"></i> {message}</div>
|
17
web/src/components/sidebarOptions.js
Normal file
17
web/src/components/sidebarOptions.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
export default [
|
||||
{
|
||||
name: "Dashboard",
|
||||
icon: "speedometer2",
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
name: "Journey listing",
|
||||
icon: "table",
|
||||
path: "/journeys",
|
||||
},
|
||||
{
|
||||
name: "Log new journey",
|
||||
icon: "plus-lg",
|
||||
path: "/new"
|
||||
}
|
||||
]
|
7
web/src/main.js
Normal file
7
web/src/main.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.body,
|
||||
});
|
||||
|
||||
export default app;
|
13
web/src/routes.js
Normal file
13
web/src/routes.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Home from './routes/Home.svelte'
|
||||
import JourneyListing from './routes/JourneyListing.svelte'
|
||||
import NewJourney from "./routes/NewJourney.svelte";
|
||||
import NotFound from './routes/NotFound.svelte'
|
||||
import JourneyDetail from "./routes/JourneyDetail.svelte";
|
||||
|
||||
export default {
|
||||
'/': Home,
|
||||
'/journeys': JourneyListing,
|
||||
'/journeys/:id': JourneyDetail,
|
||||
'/new': NewJourney,
|
||||
'*': NotFound,
|
||||
}
|
108
web/src/routes/Home.svelte
Normal file
108
web/src/routes/Home.svelte
Normal file
|
@ -0,0 +1,108 @@
|
|||
<script>
|
||||
import BaseLayout from "../components/BaseLayout.svelte";
|
||||
import L from "leaflet";
|
||||
import { onMount } from "svelte";
|
||||
import Loading from "../components/Loading.svelte";
|
||||
import {makeURL, roundFloat} from "../util.js";
|
||||
import JourneyTable from "../components/JourneyTable.svelte";
|
||||
import JourneyMap from "../components/JourneyMap.svelte";
|
||||
|
||||
let map;
|
||||
let stats = {
|
||||
lastMonth: {count: 0, miles: 0},
|
||||
ytd: {count: 0, miles: 0},
|
||||
allTime: {count: 0, miles: 0},
|
||||
};
|
||||
let journeys;
|
||||
let journeyGeoData;
|
||||
let ready = false;
|
||||
|
||||
onMount(async () => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(makeURL("/api/dashboard"));
|
||||
} catch (e) {
|
||||
alert(e.toString())
|
||||
return
|
||||
}
|
||||
|
||||
const responseJSON = await response.json();
|
||||
|
||||
stats = responseJSON.stats;
|
||||
journeys = responseJSON.journeys;
|
||||
|
||||
journeyGeoData = responseJSON.geoJSON
|
||||
|
||||
ready = true;
|
||||
})
|
||||
</script>
|
||||
|
||||
<BaseLayout>
|
||||
{#if !ready}
|
||||
<Loading />
|
||||
{/if}
|
||||
|
||||
<h1 class="pb-4"><i class="bi-speedometer2"></i> Dashboard</h1>
|
||||
|
||||
<div class="row gap-2 g-2">
|
||||
<div class="col-sm card text-bg-primary">
|
||||
<div class="card-header">Last Month</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex text-center justify-content-center">
|
||||
<div>
|
||||
<span class="fs-2">{roundFloat(stats.lastMonth.miles, 1)}</span>
|
||||
<span class="fs-5">miles</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fs-2">{stats.lastMonth.count}</span>
|
||||
<span class="fs-5">journeys</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm card text-bg-light">
|
||||
<div class="card-header">Year-to-Date</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex text-center justify-content-center">
|
||||
<div>
|
||||
<span class="fs-2">{roundFloat(stats.ytd.miles, 1)}</span>
|
||||
<span class="fs-5">miles</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fs-2">{stats.ytd.count}</span>
|
||||
<span class="fs-5">journeys</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm card text-bg-light">
|
||||
<div class="card-header">All Time</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex text-center justify-content-center">
|
||||
<div>
|
||||
<span class="fs-2">{roundFloat(stats.allTime.miles, 1)}</span>
|
||||
<span class="fs-5">miles</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fs-2">{stats.allTime.count}</span>
|
||||
<span class="fs-5">journeys</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="pt-4">Recent journeys</h3>
|
||||
|
||||
<JourneyMap geoJSON={journeyGeoData} />
|
||||
|
||||
<div class="pt-4"></div>
|
||||
|
||||
<JourneyTable showMore=true journeys={journeys} />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.d-flex {
|
||||
gap: 1em;
|
||||
}
|
||||
</style>
|
114
web/src/routes/JourneyDetail.svelte
Normal file
114
web/src/routes/JourneyDetail.svelte
Normal file
|
@ -0,0 +1,114 @@
|
|||
<script>
|
||||
import BaseLayout from "../components/BaseLayout.svelte";
|
||||
import {onMount} from "svelte";
|
||||
import {formatDate, makeURL, roundFloat} from "../util.js";
|
||||
import Loading from "../components/Loading.svelte";
|
||||
import JourneyMap from "../components/JourneyMap.svelte";
|
||||
import {push} from "svelte-spa-router";
|
||||
|
||||
let ready = false
|
||||
let transparentLoading = false
|
||||
|
||||
export let params = {
|
||||
id: undefined,
|
||||
}
|
||||
let journey;
|
||||
let geoJSON;
|
||||
|
||||
onMount(async () => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(makeURL("/api/journeys/" + params.id));
|
||||
} catch (e) {
|
||||
alert(e.toString())
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await push("/notfound")
|
||||
}
|
||||
|
||||
const responseJSON = await response.json()
|
||||
journey = responseJSON.data
|
||||
geoJSON = responseJSON.geoJSON
|
||||
ready = true
|
||||
})
|
||||
|
||||
const deleteSelf = async () => {
|
||||
if (!confirm("Are you sure you want to permanently delete this journey?")) {
|
||||
return
|
||||
}
|
||||
|
||||
transparentLoading = true
|
||||
ready = false
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(makeURL("/api/journeys/" + params.id), {method: "DELETE"});
|
||||
} catch (e) {
|
||||
alert(e.toString())
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
alert(response.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
alert("Success!")
|
||||
await push("/journeys")
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseLayout>
|
||||
{#if !ready}
|
||||
<Loading transparent={transparentLoading}/>
|
||||
{/if}
|
||||
|
||||
{#if journey}
|
||||
<h1><i class="bi-ticket-detailed"></i> {journey.from.full} to {journey.to.full}</h1>
|
||||
|
||||
<JourneyMap geoJSON={geoJSON}/>
|
||||
|
||||
<table class="table mt-4 mb-4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">From</th>
|
||||
<td>{journey.from.full} ({journey.from.shortcode})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">To</th>
|
||||
<td>{journey.to.full} ({journey.to.shortcode})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Via</th>
|
||||
<td>
|
||||
{#if journey.via}
|
||||
{#each journey.via as station, i}
|
||||
{#if i !== 0},{/if}{station.full} ({station.shortcode})
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-secondary"><i>n/a</i></span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Date</th>
|
||||
<td>{formatDate(journey.date)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Distance</th>
|
||||
<td>{roundFloat(journey.distance, 2)} miles</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Return</th>
|
||||
<td>{journey.return ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button class="btn btn-danger mb-4" on:click={deleteSelf}>Delete this journey</button>
|
||||
|
||||
<p class="text-secondary">Journey ID: <code>{journey.id}</code></p>
|
||||
{/if}
|
||||
</BaseLayout>
|
62
web/src/routes/JourneyListing.svelte
Normal file
62
web/src/routes/JourneyListing.svelte
Normal file
|
@ -0,0 +1,62 @@
|
|||
<script>
|
||||
import BaseLayout from "../components/BaseLayout.svelte"
|
||||
import JourneyTable from "../components/JourneyTable.svelte"
|
||||
import {onMount} from "svelte"
|
||||
import Loading from "../components/Loading.svelte"
|
||||
import {makeURL} from "../util.js"
|
||||
|
||||
let journeys = []
|
||||
let totalNumPages
|
||||
let currentPage = 0
|
||||
let ready = false
|
||||
let transparentLoading = false
|
||||
|
||||
const getPage = async (n) => {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(makeURL("/api/journeys?page=" + n));
|
||||
} catch (e) {
|
||||
alert(e.toString())
|
||||
return
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const resp = await getPage(currentPage)
|
||||
totalNumPages = resp.numPages
|
||||
journeys = resp.data
|
||||
ready = true
|
||||
transparentLoading = true
|
||||
})
|
||||
|
||||
$: {
|
||||
ready = false
|
||||
getPage(currentPage).then((x) => {
|
||||
journeys = x.data
|
||||
ready = true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseLayout>
|
||||
{#if !ready}
|
||||
<Loading transparent={transparentLoading}/>
|
||||
{/if}
|
||||
|
||||
<h1><i class="bi-table"></i> Journey listing</h1>
|
||||
|
||||
<div class="pt-4"></div>
|
||||
|
||||
<nav class="d-flex justify-content-center">
|
||||
<ul class="pagination">
|
||||
<li class={currentPage === 0 ? "page-item disabled" : "page-item"}><a class="page-link" on:click={() => {currentPage--}}><i class="bi-chevron-left"></i></a></li>
|
||||
{#each {length: totalNumPages} as _, i}
|
||||
<li class={currentPage === i ? "page-item active" : "page-item"}><a class="page-link" on:click={() => {currentPage=i}}>{i+1}</a></li>
|
||||
{/each}
|
||||
<li class={currentPage+1 === totalNumPages ? "page-item disabled" : "page-item"}><a class="page-link" on:click={() => {currentPage++}}><i class="bi-chevron-right"></i></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<JourneyTable journeys={journeys}/>
|
||||
</BaseLayout>
|
140
web/src/routes/NewJourney.svelte
Normal file
140
web/src/routes/NewJourney.svelte
Normal file
|
@ -0,0 +1,140 @@
|
|||
<script>
|
||||
import BaseLayout from "../components/BaseLayout.svelte"
|
||||
import {leftPad, makeURL} from "../util.js";
|
||||
import Loading from "../components/Loading.svelte";
|
||||
import {push} from "svelte-spa-router";
|
||||
import ErrorAlert from "../components/ErrorAlert.svelte";
|
||||
|
||||
let problem
|
||||
let loading
|
||||
|
||||
let inputs = {
|
||||
rawDate: undefined,
|
||||
date: undefined,
|
||||
route: undefined,
|
||||
manualDistance: undefined,
|
||||
isReturn: false,
|
||||
}
|
||||
|
||||
$: inputs.manualDistance = parseFloat(inputs.manualDistance)
|
||||
$: {
|
||||
inputs.date = new Date(Date.parse(inputs.rawDate))
|
||||
console.log(inputs.rawDate)
|
||||
}
|
||||
|
||||
const setDateToday = () => {
|
||||
const today = new Date(Date.now());
|
||||
inputs.rawDate = `${today.getFullYear()}-${leftPad(today.getMonth()+1, "0", 2)}-${leftPad(today.getDate(), "0", 2)}`
|
||||
console.log("set to", inputs.rawDate)
|
||||
}
|
||||
|
||||
const doFormSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!inputs.rawDate) {
|
||||
problem = "Please set the date of travel"
|
||||
return
|
||||
}
|
||||
|
||||
if (!inputs.route) {
|
||||
problem = "Please provide a route"
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(
|
||||
makeURL("/api/journeys"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(inputs),
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
alert(e.toString())
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
const responseJSON = await response.json()
|
||||
|
||||
loading = false
|
||||
|
||||
if (response.status === 400) {
|
||||
problem = responseJSON.message
|
||||
return
|
||||
}
|
||||
|
||||
await push(`/journeys/${responseJSON.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseLayout>
|
||||
{#if loading}
|
||||
<Loading transparent={true}/>
|
||||
{/if}
|
||||
|
||||
<h1><i class="bi-plus-lg"></i> Log new journey</h1>
|
||||
<div class="pt-4"></div>
|
||||
|
||||
{#if problem}
|
||||
<ErrorAlert message={problem} />
|
||||
<div class="pt-4"></div>
|
||||
{/if}
|
||||
|
||||
<form on:submit={doFormSubmit}>
|
||||
<div class="row pb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputTravelDate" class="form-label">Date of travel <a class="link-primary"
|
||||
on:click={setDateToday}>(today)</a></label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="date" id="inputTravelDate" class="form-control" bind:value={inputs.rawDate}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row pb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputRoute" class="form-label">Route</label>
|
||||
<div class="form-text">Locations should be entered with the short code (eg: <code>SLY</code>) and
|
||||
optionally the service UID (eg: <code>SLY, C16977</code>). Seperate locations with a newline.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<textarea type="date" id="inputRoute" rows="7" class="form-control"
|
||||
bind:value={inputs.route}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row pb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputManualDistance" class="form-label">Manual distance</label>
|
||||
<div class="form-text">Leave blank to auto-detect. Enter values in miles.</div>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="number" step="any" id="inputManualDistance" class="form-control" placeholder="Auto-detect"
|
||||
bind:value={inputs.manualDistance}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row pb-2">
|
||||
<div class="col-sm">
|
||||
<label for="inputReturnJourney" class="form-label">Was this a return journey?</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="checkbox" id="inputReturnJourney" class="form-check-input" bind:checked={inputs.isReturn}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
a.link-primary {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
8
web/src/routes/NotFound.svelte
Normal file
8
web/src/routes/NotFound.svelte
Normal file
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import BaseLayout from "../components/BaseLayout.svelte";
|
||||
</script>
|
||||
|
||||
<BaseLayout>
|
||||
<h1>Page not found</h1>
|
||||
<p>Click <a href="#/">here</a> to go back to the dashboard or use the sidebar for more options.</p>
|
||||
</BaseLayout>
|
35
web/src/util.js
Normal file
35
web/src/util.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
export const roundFloat = (x, decimalPlaces) => {
|
||||
const scale = Math.pow(10, decimalPlaces)
|
||||
x *= scale
|
||||
x = Math.round(x)
|
||||
x /= scale
|
||||
return x
|
||||
}
|
||||
|
||||
const baseURL = "1"
|
||||
|
||||
export const makeURL = (path) => {
|
||||
if (baseURL.endsWith("/") && path.startsWith("/")) {
|
||||
return baseURL + path.substring(1)
|
||||
}
|
||||
return baseURL + path
|
||||
}
|
||||
|
||||
export const leftPad = (str, char, len) => {
|
||||
str = str.toString()
|
||||
if (str.length >= len) {
|
||||
return str
|
||||
}
|
||||
|
||||
while (str.length < len) {
|
||||
str = char + str
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
const dateFormat = {year: 'numeric', month: 'short', day: 'numeric'};
|
||||
|
||||
export const formatDate = (date) => {
|
||||
return new Date(Date.parse(date)).toLocaleDateString(undefined, dateFormat)
|
||||
}
|
6
web/web.go
Normal file
6
web/web.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package webAssets
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed public/*
|
||||
var Public embed.FS
|
Loading…
Add table
Add a link
Reference in a new issue