Compare commits

..

34 commits
0.2.1 ... main

Author SHA1 Message Date
146e4d7812
feat: add edit mode to TUI (#57)
Reviewed-on: #57
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-22 20:11:48 -04:00
53ed75133c
release: 0.3.4 (#56)
Reviewed-on: #56
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-15 01:04:45 -04:00
93fb08e310
feat(tui): show IG urls as @username (#55)
Reviewed-on: #55
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-15 01:00:58 -04:00
b9e706e36e
release: 0.3.3 (#54)
Reviewed-on: #54
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 19:56:37 -04:00
aac318e7b8
fix(tui): add address column (#53)
Reviewed-on: #53
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 19:53:19 -04:00
71a96f722e
fix: docker build (#52)
Reviewed-on: #52
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 19:37:46 -04:00
93ceafba91
release: 0.3.2 (#51)
Reviewed-on: #51
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 18:49:14 -04:00
bf30b4f491
fix: use correct regex to display instagram urls (#50)
Reviewed-on: #50
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 18:43:57 -04:00
83e032a590
feat: initial TUI for administration (#49)
Allows listing and deleting places. Edit mode is not implemented yet.

Reviewed-on: #49
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-14 18:25:56 -04:00
78cb376791
chore: use native HTML5 dialog element and refactor backend into hexagonal architecture. (#48)
Reviewed-on: #48
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-06-11 22:46:58 -04:00
f8540e5043
chore: updates (#47)
Reviewed-on: #47
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2025-03-08 13:48:06 -03:00
d368fc932a
chore: update dependencies (#46)
Reviewed-on: #46
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-10-17 21:31:33 -03:00
6ff081fe8c
chore: bump dependencies (#45)
Reviewed-on: #45
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-09-21 00:02:28 -03:00
dc6e642ac6
chore: update dependencies (#44)
Reviewed-on: #44
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-07-23 18:57:27 -04:00
9b529cedb4
doc: improve README (#43)
Reviewed-on: #43
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-07-16 00:25:20 -04:00
67fec9bd88
chore: bump dependencies (#42)
Reviewed-on: #42
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-07-13 21:46:22 -04:00
7e72eda2a3
chore: 0.3.0 (#41)
Reviewed-on: #41
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-03-06 21:41:37 -03:00
1bffd10585
feat!: use msgpack instead of json (#40)
Reviewed-on: #40
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-03-06 21:38:04 -03:00
7f62b1a22d
doc: Improve README.md (#39)
Reviewed-on: #39
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-01-19 18:38:21 -03:00
3b70973210
release: 0.2.3 (#38)
Reviewed-on: #38
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-01-19 18:30:46 -03:00
644cdb92b0
chore(deps): Update to axum 0.7 (#37)
Reviewed-on: #37
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2024-01-19 18:26:24 -03:00
0191a7e2c1
chore: add sqlfluff config (#36)
Reviewed-on: #36
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-11-20 23:47:10 -03:00
9d5680e133
chore: add git hooks (#35)
Reviewed-on: #35
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-11-18 21:39:42 -03:00
c992711ab3
chore(changelog): update for 0.2.2 (#34)
Reviewed-on: #34
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-11-10 15:09:28 -03:00
4ccdb389ac
0.2.2 (#33)
Reviewed-on: #33
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-11-10 15:05:09 -03:00
d593d0a6ab
fix: fix GMaps links and do some dependencies maintainance (#32)
Reviewed-on: #32
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-11-10 00:05:47 -03:00
23f3307599
chore: add CHANGELOG.md and git-cliff config (#31)
Reviewed-on: #31
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-09-05 02:51:55 -03:00
4685e0a6c6
chore(deps): tokio -> 1.32.0, serde -> 1.0.185, serde_json -> 1.0.105, anyhow -> 1.0.75 (#30)
Reviewed-on: #30
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-08-22 01:05:13 -04:00
8e114d9a8f
chore: update rust version in docker to 1.71 (#29)
Reviewed-on: #29
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-08-06 01:20:53 -04:00
5842b6b3e3
chore: update sqlx offline files (#28)
Reviewed-on: #28
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-08-05 01:09:11 -04:00
579ed9fe34
Refactors and TODOs: (#27)
- Use anyhow for setup errors
- Implement comparison agaisnt db in insert test
- Crate updates

Reviewed-on: #27
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Co-committed-by: Felipe Contreras Salinas <felipe@bstr.cl>
2023-08-04 23:46:34 -04:00
7c07ede265
chore(deps): tokio -> 1.29.1, tower-http -> 0.4.1, serde -> 1.0.166, serde_json -> 1.0.99, axum-test-helper -> 0.3.0, sqlx -> 0.7.0 (#26)
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Reviewed-on: #26
2023-07-04 19:44:36 -04:00
a6d753e921
chore: add docker-compose.yml file for testing (#25)
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Reviewed-on: #25
2023-06-08 22:06:05 -04:00
bdb4e41943
fix: docker build (#24)
Co-authored-by: Felipe Contreras Salinas <felipe@bstr.cl>
Reviewed-on: #24
2023-05-22 22:59:58 -04:00
43 changed files with 3951 additions and 1482 deletions

View file

@ -1,2 +1,4 @@
.git
target
db/huellas-test
db/*.wal

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
/target
/db
node_modules
build
db
node_modules
target

2
.hadolint.yaml Normal file
View file

@ -0,0 +1,2 @@
ignored:
- DL3018

12
.sqlfluff Normal file
View file

@ -0,0 +1,12 @@
[sqlfluff]
dialect = sqlite
[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper
[sqlfluff:rules:capitalisation.identifiers]
capitalisation_policy = upper
[sqlfluff:rules:capitalisation.functions]
extended_capitalisation_policy = upper
[sqlfluff:rules:capitalisation.literals]
capitalisation_policy = upper
[sqlfluff:rules:capitalisation.types]
extended_capitalisation_policy = upper

View file

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "INSERT INTO places\n (name, address, open_hours, icon, description, longitude, latitude, url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n RETURNING id",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
}
],
"parameters": {
"Right": 8
},
"nullable": [
false
]
},
"hash": "92ac9ff4e52046e57f006846914912c790d4fa63428c2e5d432358aa55bd2bbc"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE places\n SET (name, address, open_hours, icon, description, longitude, latitude, url)\n = (?, ?, ?, ?, ?, ?, ?, ?)\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "96057f55cf85aa23dd20bd1277f075176c3297d02d6056ce93ef991ce4f6ceed"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE places SET active = FALSE WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "af66ec71413501f84c7f4cb0dd732c8ebfcd3da36a5f1177918c2277a8674c28"
}

View file

@ -0,0 +1,68 @@
{
"db_name": "SQLite",
"query": "SELECT id, name, address, open_hours, icon, description, url,\n longitude as \"longitude: f64\", latitude as \"latitude: f64\"\n FROM places\n WHERE active = TRUE",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "address",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "open_hours",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "icon",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "longitude: f64",
"ordinal": 7,
"type_info": "Float"
},
{
"name": "latitude: f64",
"ordinal": 8,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "dae51f97c52b1391d2fa1109a2dc550dc9e412d60c538db82a841964d631aa0f"
}

90
CHANGELOG.md Normal file
View file

@ -0,0 +1,90 @@
# Changelog
## [0.3.4] - 2025-06-15
### Features
- Show IG urls as @username ([#55](https://oolong.ludwig.dog/pitbuster/huellas/issues/55))
## [0.3.3] - 2025-06-14
### Bug Fixes
- Docker build ([#52](https://oolong.ludwig.dog/pitbuster/huellas/issues/52))
- Add address column ([#53](https://oolong.ludwig.dog/pitbuster/huellas/issues/53))
## [0.3.2] - 2025-06-14
### Bug Fixes
- Use correct regex to display instagram urls ([#50](https://oolong.ludwig.dog/pitbuster/huellas/issues/50))
### Documentation
- Improve README ([#43](https://oolong.ludwig.dog/pitbuster/huellas/issues/43))
### Features
- Initial TUI for administration ([#49](https://oolong.ludwig.dog/pitbuster/huellas/issues/49))
### Miscellaneous Tasks
- Bump dependencies ([#42](https://oolong.ludwig.dog/pitbuster/huellas/issues/42))
- Update dependencies ([#44](https://oolong.ludwig.dog/pitbuster/huellas/issues/44))
- Bump dependencies ([#45](https://oolong.ludwig.dog/pitbuster/huellas/issues/45))
- Update dependencies ([#46](https://oolong.ludwig.dog/pitbuster/huellas/issues/46))
- Updates ([#47](https://oolong.ludwig.dog/pitbuster/huellas/issues/47))
- Use native HTML5 dialog element and refactor backend into hexagonal architecture. ([#48](https://oolong.ludwig.dog/pitbuster/huellas/issues/48))
## [0.3.0] - 2024-03-07
### Documentation
- Improve README.md ([#39](https://oolong.ludwig.dog/pitbuster/huellas/issues/39))
### Features
- [**breaking**] Use msgpack instead of json ([#40](https://oolong.ludwig.dog/pitbuster/huellas/issues/40))
### Miscellaneous Tasks
- 0.3.0 ([#41](https://oolong.ludwig.dog/pitbuster/huellas/issues/41))
## [0.2.3] - 2024-01-19
### Miscellaneous Tasks
- Add git hooks ([#35](https://oolong.ludwig.dog/pitbuster/huellas/issues/35))
- Add sqlfluff config ([#36](https://oolong.ludwig.dog/pitbuster/huellas/issues/36))
## [0.2.2] - 2023-11-10
### Bug Fixes
- Docker build ([#24](https://oolong.ludwig.dog/pitbuster/huellas/issues/24))
- Fix GMaps links and do some dependencies maintainance ([#32](https://oolong.ludwig.dog/pitbuster/huellas/issues/32))
### Miscellaneous Tasks
- Add docker-compose.yml file for testing ([#25](https://oolong.ludwig.dog/pitbuster/huellas/issues/25))
- Update sqlx offline files ([#28](https://oolong.ludwig.dog/pitbuster/huellas/issues/28))
- Update rust version in docker to 1.71 ([#29](https://oolong.ludwig.dog/pitbuster/huellas/issues/29))
- Add CHANGELOG.md and git-cliff config ([#31](https://oolong.ludwig.dog/pitbuster/huellas/issues/31))
## [0.2.1] - 2023-05-23
### Miscellaneous Tasks
- Add tests ([#19](https://oolong.ludwig.dog/pitbuster/huellas/issues/19))
- Update sqlx to 0.7.0-alpha3 ([#21](https://oolong.ludwig.dog/pitbuster/huellas/issues/21))
- Axum -> 0.6.18, tracing-subscriber -> 0.3.17, tokio -> 1.28.1, serde ->1.0.163,serde_json -> 1.0.96 ([#22](https://oolong.ludwig.dog/pitbuster/huellas/issues/22))
## [0.1.1] - 2022-11-23
### Features
- Add and Edit places through Leaflet.contextmenu ([#4](https://oolong.ludwig.dog/pitbuster/huellas/issues/4))
## [0.1.0] - 2022-10-22

2286
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,58 @@
[package]
name = "huellas"
version = "0.2.1"
edition = "2021"
version = "0.3.4"
edition = "2024"
license = "AGPL-3.0"
links = "sqlite"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.6.18"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
tokio = { version = "1.28.1", features = ["macros", "signal", "rt-multi-thread"] }
anyhow = "1.0.98"
axum = { version = "0.8.4", default-features = false, features = [
"tracing",
"tokio",
"http1",
"http2",
] }
axum-msgpack = "0.5.0"
clap = { version = "4.5.40", features = ["derive"] }
# This must be the same version that ratatui depends on :(
crossterm = { version = "0.28.1", default-features = false, features = [
"bracketed-paste",
"event-stream",
"serde",
] }
dotenvy = "0.15.7"
tower-http = { version = "0.4.0", features = ["fs"] }
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
futures = "0.3.28"
axum-test-helper = "0.2.0"
sqlx ={ version = "0.7.0-alpha.3", default-features = false, features = ["runtime-tokio", "tls-rustls", "macros", "migrate", "sqlite"] }
itertools = "0.14.0"
futures = { version = "0.3.31", default-features = false }
ratatui = { version = "0.29.0", default-features = false, features = [
"crossterm",
] }
serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.6", default-features = false, features = [
"macros",
"migrate",
"runtime-tokio",
"sqlite",
"tls-rustls",
] }
thiserror = "2.0.12"
tokio = { version = "1.45.1", default-features = false, features = [
"macros",
"rt-multi-thread",
"signal",
] }
tokio-util = "0.7.15"
tower-http = { version = "0.6.6", default-features = false, features = ["fs"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", default-features = false, features = [
"env-filter",
"fmt",
"tracing",
"tracing-log",
] }
tui-textarea = { version = "0.7.0", default-features = false, features = [
"ratatui",
"crossterm",
] }
[dev-dependencies]
axum-test = { version = "17.3.0", features = ["msgpack"] }

View file

@ -1,10 +1,8 @@
##### Builder ####
FROM rust:1.64-alpine as builder
FROM rust:1.87-alpine3.20 AS builder
RUN apk add --no-cache sqlite npm musl-dev fd minify
# Install Typescript
RUN npm install -g typescript
# Install dependencies
RUN apk add --no-cache sqlite npm musl-dev fd minify && npm install -g typescript
WORKDIR /usr/src
@ -24,13 +22,10 @@ RUN cargo build --release
COPY src /usr/src/huellas/src/
COPY migrations /usr/src/huellas/migrations/
COPY db /usr/src/huellas/db/
COPY .env sqlx-data.json Rocket.toml /usr/src/huellas/
COPY .env /usr/src/huellas/
## Touch main.rs to prevent cached release build
RUN touch /usr/src/huellas/src/main.rs
# This is the actual application build.
RUN cargo build --release
## Touch main.rs to prevent cached release build and then build
RUN touch /usr/src/huellas/src/main.rs && cargo build --release
# Now TS client
COPY ts-client /usr/src/huellas/ts-client/
@ -39,30 +34,26 @@ COPY ts-client /usr/src/huellas/ts-client/
WORKDIR /usr/src/huellas/ts-client/
# Install dependencies
RUN npm install
RUN npm ci
# Transpile
RUN tsc
# Delete the first line of jvascript ts-client
RUN sed -i '1d' build/client.js
# Transpile and delete the first line of javascript ts-client
RUN tsc && sed -i '1,2d' build/client.js
# Minify static files
COPY static /usr/src/huellas/static/
RUN fd -e html . '/usr/src/huellas/static/' -x minify -r -o {} {}
RUN fd -e js . '/usr/src/huellas/ts-client/build/' -x minify -r -o {} {}
RUN fd -e html . '/usr/src/huellas/static/' -x minify -r -o {} {} \
&& fd -e js . '/usr/src/huellas/ts-client/build/' -x minify -r -o {} {}
################
##### Runtime
FROM alpine:3.16 AS Runtime
FROM alpine:3.20 AS runtime
RUN apk add --no-cache sqlite
# Copy application binary from builder image
COPY --from=builder /usr/src/huellas/target/release/huellas /usr/local/bin
# Copy Rocket.toml
COPY Rocket.toml /usr/local/bin
# Copy .env
COPY .env /usr/local/bin
# Copy static files
COPY --from=builder /usr/src/huellas/static /usr/local/bin/static/
# Copy javascript client
@ -70,4 +61,4 @@ COPY --from=builder /usr/src/huellas/ts-client/build/client.js /usr/local/bin/st
# Run the application
WORKDIR /usr/local/bin
CMD ["/usr/local/bin/huellas"]
CMD ["/usr/local/bin/huellas", "server"]

View file

@ -1,2 +1,32 @@
# huellas
This service is backed by an Axum server and uses a raw Typescript front-end using
Leaflet.js.
## Development
To run the application locally, just do
```shell
cargo run
```
To compile the front-end code, go to the `ts-client` folder, install the dependencies
with
```shell
npm install
```
and then run
```
make
```
### Install git hooks
Run the following from the project root:
```shell
hooks/install.sh
```
### Migrations
We use the `sqlx` CLI to manage migrations. To create a new one run
```shell
cargo sqlx migrate add
```
## Cross-architecture building
Images are built for arm64 on a juicier machine using
```shell
docker buildx build --platform=linux/arm64 . -t oolong.ludwig.dog/pitbuster/huellas:X.Y.Z
```

View file

@ -2,4 +2,4 @@
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}
}

79
cliff.toml Normal file
View file

@ -0,0 +1,79 @@
[changelog]
# changelog header
header = """
# Changelog\n
"""
# template for the changelog body
# https://tera.netlify.app/docs
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
"""
# postprocessors
postprocessors = [
{ pattern = '<REPO>', replace = "https://oolong.ludwig.dog/pitbuster/huellas" },
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))" }, # replace issue numbers
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(changelog\\):", skip = true },
{ message = "^chore\\(release\\):", skip = true },
{ message = "^release:", skip = true },
{ message = "^chore\\(deps\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|ci", group = "Miscellaneous Tasks" },
{ body = ".*security", group = "Security" },
{ message = "^revert", group = "Revert" },
]
# extract external references
link_parsers = [
{ pattern = "#(\\d+)", href = "https://oolong.ludwig.dog/pitbuster/huellas/issues/$1" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
# limit_commits = 42

9
docker-compose.yml Normal file
View file

@ -0,0 +1,9 @@
version: '3'
services:
huellas:
restart: no
image: oolong.ludwig.dog/pitbuster/huellas:0.2.1
volumes:
- ./db:/usr/local/bin/db
ports:
- "8059:3000"

11
hooks/install.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/bash
set -eu
if GIT_ROOT="$(git rev-parse --show-toplevel)"; then
ln -s $GIT_ROOT/hooks/pre-commit.sh $GIT_ROOT/.git/hooks/pre-commit
ln -s $GIT_ROOT/hooks/pre-push.sh $GIT_ROOT/.git/hooks/pre-push
else
echo "Failed to get git root, aborting"
exit 1
fi

11
hooks/pre-commit.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/bash
set -eu
if ! cargo fmt -- --check
then
echo "There are some code style issues."
echo "Run cargo fmt first."
exit 1
fi
exit 0

14
hooks/pre-push.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash
set -eu
if ! cargo clippy --all-targets -- -D warnings; then
echo "There are some clippy issues."
exit 1
fi
if ! cargo nextest run; then
echo "There are some test issues."
exit 1
fi
exit 0

View file

@ -1,101 +0,0 @@
{
"db": "SQLite",
"3fae7e613d23f9713643829d36bab2851a9c406aa32a1f8afe1bab34d53f13e7": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 7
}
},
"query": "UPDATE places SET (name, address, open_hours, icon, description, longitude, latitude) = (?, ?, ?, ?, ?, ?, ?)"
},
"af66ec71413501f84c7f4cb0dd732c8ebfcd3da36a5f1177918c2277a8674c28": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"query": "UPDATE places SET active = FALSE WHERE id = ?"
},
"e10f7e8f125a3f60338f6c35b195517d4304304599c75e4f26f071e2a09609dc": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
}
],
"nullable": [
false
],
"parameters": {
"Right": 7
}
},
"query": "INSERT INTO places (name, address, open_hours, icon, description, longitude, latitude)VALUES (?, ?, ?, ?, ?, ?, ?)RETURNING id"
},
"fdc2eb1d98b93f2b61c756687f1a30edf2e4a74622e23b6b72a9509a9303385d": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "address",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "open_hours",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "icon",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "longitude: f64",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "latitude: f64",
"ordinal": 7,
"type_info": "Float"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "SELECT id, name, address, open_hours, icon, description,longitude as \"longitude: f64\", latitude as \"latitude: f64\" FROM places WHERE active = TRUE"
}
}

20
src/cli.rs Normal file
View file

@ -0,0 +1,20 @@
//! Cli Parameters
use clap::{Parser, Subcommand};
/// Server for saving places in a map
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct CliArgs {
/// Application mode
#[command(subcommand)]
pub mode: Mode,
}
#[derive(Subcommand)]
pub enum Mode {
/// Spins up the server
Server,
/// Fires up a TUI
Tui,
}

26
src/db.rs Normal file
View file

@ -0,0 +1,26 @@
//! Database handling.
use anyhow::{Context, Result};
use sqlx::SqlitePool;
/// Creates a Database Pool
///
/// # Errors
/// This function may return an error if the `DATABASE_URL` environment is not defined or if the
/// database that URL points to is not reachable for some reason.
pub async fn pool() -> Result<SqlitePool> {
let db_url = std::env::var("DATABASE_URL").context("DATABASE_URL not defined")?;
let pool = SqlitePool::connect(&db_url)
.await
.context("Couldn't connect to database")?;
Ok(pool)
}
/// Run migrations on the database `pool` is connected to.
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::migrate!()
.run(pool)
.await
.context("Couldn't run migrations")?;
Ok(())
}

20
src/logging.rs Normal file
View file

@ -0,0 +1,20 @@
//! Service logging.
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Setups logging.
///
/// # Errors
/// This function can return an error if called repeatedly or if logging/tracing was already setup
/// by another means.
pub fn setup() -> Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "huellas=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.try_init()?;
Ok(())
}

View file

@ -1,51 +1,43 @@
use axum::Router;
use sqlx::sqlite::SqlitePool;
use std::net::SocketAddr;
use tower_http::services::ServeDir;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use anyhow::Result;
use clap::Parser;
mod place;
mod routes;
mod cli;
mod db;
mod logging;
mod places;
mod server;
mod tui;
#[tokio::main]
async fn main() {
async fn main() -> Result<()> {
dotenvy::dotenv().unwrap_or_default();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "huellas=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let db_url = dotenvy::var("DATABASE_URL").expect("DATABASE_URL not defined");
let pool = SqlitePool::connect(&db_url)
.await
.expect("can't connect to database");
sqlx::migrate!()
.run(&pool)
.await
.expect("couldn't run migrations");
let app = Router::new()
.nest("/places", routes::places_routes(pool))
.nest_service("/", ServeDir::new("static"));
let port = dotenvy::var("PORT").unwrap_or_default();
let port = str::parse(&port).unwrap_or(3000);
let address = SocketAddr::from(([0, 0, 0, 0], port));
tracing::debug!("listening on {}", address);
axum::Server::bind(&address)
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
logging::setup()?;
let args = cli::CliArgs::parse();
match args.mode {
cli::Mode::Server => server_mode().await?,
cli::Mode::Tui => tui_mode().await?,
}
Ok(())
}
async fn server_mode() -> Result<()> {
let pool = db::pool().await?;
db::run_migrations(&pool).await?;
let places_repository = places::db_repository::DbPlacesRepository::new(pool);
let places_routes = places::routes::places_routes(places_repository);
server::serve(places_routes).await?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Ctrl-C shutdown signal");
println!("Received shutdown signal");
async fn tui_mode() -> Result<()> {
let pool = db::pool().await?;
let places_repository = places::db_repository::DbPlacesRepository::new(pool);
tui::tui(places_repository).await?;
Ok(())
}

View file

@ -1,14 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct Place {
pub id: Option<i64>,
pub name: String,
pub address: String,
pub open_hours: String,
pub icon: String,
pub description: String,
pub longitude: f64,
pub latitude: f64,
pub url: Option<String>,
}

320
src/places/db_repository.rs Normal file
View file

@ -0,0 +1,320 @@
//! `PlacesRepository` that is backed by a DB.
use futures::TryStreamExt;
use sqlx::SqlitePool;
use crate::places::models::PlaceInsert;
use super::models::Place;
use super::repository::{PlacesError, PlacesRepository};
#[derive(Clone)]
pub struct DbPlacesRepository {
db_pool: SqlitePool,
}
impl DbPlacesRepository {
pub fn new(db_pool: SqlitePool) -> Self {
Self { db_pool }
}
}
impl PlacesRepository for DbPlacesRepository {
async fn get_places(&self) -> Result<Vec<super::models::Place>, PlacesError> {
sqlx::query!(
r#"SELECT id, name, address, open_hours, icon, description, url,
longitude as "longitude: f64", latitude as "latitude: f64"
FROM places
WHERE active = TRUE"#
)
.fetch(&self.db_pool)
.map_ok(|p| Place {
id: p.id,
name: p.name,
address: p.address,
open_hours: p.open_hours,
icon: p.icon,
description: p.description,
latitude: p.latitude,
longitude: p.longitude,
url: p.url,
})
.try_collect::<Vec<_>>()
.await
.map_err(|err| PlacesError::FailToGet(err.to_string()))
}
async fn get_places_paginated(
&self,
offset: u32,
limit: u8,
) -> Result<Vec<super::models::Place>, PlacesError> {
sqlx::query!(
r#"SELECT id, name, address, open_hours, icon, description, url,
longitude as "longitude: f64", latitude as "latitude: f64"
FROM places
WHERE active = TRUE
ORDER BY id
LIMIT ?
OFFSET ?"#,
limit,
offset,
)
.fetch(&self.db_pool)
.map_ok(|p| Place {
id: p.id,
name: p.name,
address: p.address,
open_hours: p.open_hours,
icon: p.icon,
description: p.description,
latitude: p.latitude,
longitude: p.longitude,
url: p.url,
})
.try_collect::<Vec<_>>()
.await
.map_err(|err| PlacesError::FailToGet(err.to_string()))
}
async fn insert_place(&self, place: PlaceInsert) -> Result<Place, PlacesError> {
let id = sqlx::query_scalar!(
r#"INSERT INTO places
(name, address, open_hours, icon, description, longitude, latitude, url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id"#,
place.name,
place.address,
place.open_hours,
place.icon,
place.description,
place.longitude,
place.latitude,
place.url
)
.fetch_one(&self.db_pool)
.await
.map_err(|err| PlacesError::FailToUpsert(err.to_string()))?;
Ok((place, id).into())
}
async fn update_place(&self, place: Place) -> Result<Place, PlacesError> {
let result = sqlx::query!(
r#"UPDATE places
SET (name, address, open_hours, icon, description, longitude, latitude, url)
= (?, ?, ?, ?, ?, ?, ?, ?)
WHERE id = ?"#,
place.name,
place.address,
place.open_hours,
place.icon,
place.description,
place.longitude,
place.latitude,
place.url,
place.id,
)
.execute(&self.db_pool)
.await
.map_err(|err| PlacesError::FailToUpsert(err.to_string()))?;
if result.rows_affected() == 1 {
Ok(place)
} else {
Err(PlacesError::NotFound(place.id))
}
}
async fn delete_place(&self, id: i64) -> Result<(), PlacesError> {
let result = ::sqlx::query!("UPDATE places SET active = FALSE WHERE id = ?", id)
.execute(&self.db_pool)
.await
.map_err(|err| PlacesError::FailToDelete(err.to_string()))?;
if result.rows_affected() == 1 {
Ok(())
} else {
Err(PlacesError::NotFound(id))
}
}
}
mod tests {
#![cfg(test)]
use super::DbPlacesRepository;
use crate::places::models::PlaceInsert;
use crate::places::repository::{PlacesError, PlacesRepository};
use anyhow::Result;
use futures::future::try_join_all;
use sqlx::sqlite::SqlitePool;
#[sqlx::test]
async fn test_add_place(pool: SqlitePool) -> Result<()> {
let repository = DbPlacesRepository::new(pool);
let place = PlaceInsert {
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
};
// Insert the place
let res_place = repository.insert_place(place.clone()).await?;
let (res_place, _) = res_place.into();
// And now they should be equal
assert_eq!(place, res_place);
Ok(())
}
#[sqlx::test]
async fn test_get_places(pool: SqlitePool) -> Result<()> {
let repository = DbPlacesRepository::new(pool);
let places = vec![
PlaceInsert {
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
PlaceInsert {
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert the places
for place in &places {
let _res_place = repository.insert_place(place.clone()).await?;
}
// and fetch them
let mut res_places = repository.get_places().await?;
// and they should be equal
res_places.sort_by(|a, b| a.id.cmp(&b.id));
let res_places = res_places
.into_iter()
.map(|p| {
let (p, _id): (PlaceInsert, i64) = p.into();
p
})
.collect::<Vec<_>>();
assert_eq!(places, res_places);
Ok(())
}
#[sqlx::test]
async fn test_delete(pool: SqlitePool) -> Result<()> {
let repository = DbPlacesRepository::new(pool);
let places = vec![
PlaceInsert {
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
PlaceInsert {
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert the places
let ids = try_join_all(places.iter().map(|place| async {
let res_place = repository.insert_place(place.clone()).await?;
Ok::<_, PlacesError>(res_place.id)
}))
.await?;
// delete the first one
repository.delete_place(ids[0]).await?;
// fetch the remaining places
let res_places = repository.get_places().await?;
let res_places = res_places
.into_iter()
.map(|p| {
let (p, _id) = p.into();
p
})
.collect::<Vec<_>>();
// we should only get the second place
assert_eq!(&places[1..], res_places.as_slice());
Ok(())
}
#[sqlx::test]
async fn test_delete_not_existing(pool: SqlitePool) -> Result<()> {
let repository = DbPlacesRepository::new(pool);
// Try to delete a non-existing place
let res = repository.delete_place(33).await;
assert!(res.is_err_and(|err| err == PlacesError::NotFound(33)));
Ok(())
}
#[sqlx::test]
async fn test_update(pool: SqlitePool) -> Result<()> {
let repository = DbPlacesRepository::new(pool);
let places = vec![
PlaceInsert {
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
PlaceInsert {
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert original place
let res = repository.insert_place(places[0].clone()).await?;
// Add the returned ID to the new place so we can do the update
let place = (places[1].clone(), res.id).into();
// update the place
let _res = repository.update_place(place).await?;
// fetch the places
let res_places = repository.get_places().await?;
let res_places = res_places
.into_iter()
.map(|p| {
let (p, _id) = p.into();
p
})
.collect::<Vec<_>>();
// we should get the updated place
assert_eq!(&places[1..], res_places.as_slice());
Ok(())
}
}

4
src/places/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod db_repository;
pub mod models;
pub mod repository;
pub mod routes;

119
src/places/models.rs Normal file
View file

@ -0,0 +1,119 @@
/// Models
use serde::{Deserialize, Serialize};
/// Place can be any place of interest we want to mark in a map
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Place {
pub id: i64,
/// Name
pub name: String,
/// Address
pub address: String,
/// Opening Hours
pub open_hours: String,
/// Icon name
pub icon: String,
/// Description
pub description: String,
/// Longitude of the place
pub longitude: f64,
/// latitude of the place
pub latitude: f64,
/// URL for the place website
pub url: Option<String>,
}
/// Insert Place payload
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PlaceInsert {
/// Name
pub name: String,
/// Address
pub address: String,
/// Opening Hours
pub open_hours: String,
/// Icon name
pub icon: String,
/// Description
pub description: String,
/// Longitude of the place
pub longitude: f64,
/// latitude of the place
pub latitude: f64,
/// URL for the place website
pub url: Option<String>,
}
/// UpsertPlace payload
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PlaceUpsert {
pub id: Option<i64>,
/// Name
pub name: String,
/// Address
pub address: String,
/// Opening Hours
pub open_hours: String,
/// Icon name
pub icon: String,
/// Description
pub description: String,
/// Longitude of the place
pub longitude: f64,
/// latitude of the place
pub latitude: f64,
/// URL for the place website
pub url: Option<String>,
}
impl From<(PlaceInsert, i64)> for Place {
fn from((place, id): (PlaceInsert, i64)) -> Self {
Self {
id,
name: place.name,
address: place.address,
open_hours: place.open_hours,
icon: place.icon,
description: place.description,
longitude: place.longitude,
latitude: place.latitude,
url: place.url,
}
}
}
impl From<PlaceUpsert> for (PlaceInsert, Option<i64>) {
fn from(place: PlaceUpsert) -> Self {
(
PlaceInsert {
name: place.name,
address: place.address,
open_hours: place.open_hours,
icon: place.icon,
description: place.description,
longitude: place.longitude,
latitude: place.latitude,
url: place.url,
},
place.id,
)
}
}
impl From<Place> for (PlaceInsert, i64) {
fn from(place: Place) -> Self {
(
PlaceInsert {
name: place.name,
address: place.address,
open_hours: place.open_hours,
icon: place.icon,
description: place.description,
longitude: place.longitude,
latitude: place.latitude,
url: place.url,
},
place.id,
)
}
}

129
src/places/repository.rs Normal file
View file

@ -0,0 +1,129 @@
//! Places Repository
#[cfg(test)]
use std::sync::Arc;
use thiserror::Error;
#[cfg(test)]
use tokio::sync::RwLock;
use super::models::{Place, PlaceInsert};
/// Trait to handle Places.
pub trait PlacesRepository: Clone + Send + Sync + 'static {
/// Get all of the Places
fn get_places(&self) -> impl Future<Output = Result<Vec<Place>, PlacesError>> + Send;
/// Get all of the Places
fn get_places_paginated(
&self,
offset: u32,
limit: u8,
) -> impl Future<Output = Result<Vec<Place>, PlacesError>> + Send;
/// Inserts a Place.
fn insert_place(
&self,
place: PlaceInsert,
) -> impl Future<Output = Result<Place, PlacesError>> + Send;
/// Updates a Place.
fn update_place(&self, place: Place)
-> impl Future<Output = Result<Place, PlacesError>> + Send;
/// Deletes the place for the given `id`.
fn delete_place(&self, id: i64) -> impl Future<Output = Result<(), PlacesError>> + Send;
}
#[derive(Debug, Error, PartialEq)]
pub enum PlacesError {
#[error("Couldn't retrieve places: {0}")]
FailToGet(String),
#[error("Couldn't upsert place: {0}")]
FailToUpsert(String),
#[error("Couldn't delete place: {0}")]
FailToDelete(String),
#[error("Place with id {0} not found")]
NotFound(i64),
}
#[cfg(test)]
#[derive(Clone)]
pub struct MockPlacesRepository {
get_places_count: Arc<RwLock<usize>>,
get_places_paginated_count: Arc<RwLock<usize>>,
insert_place_count: Arc<RwLock<usize>>,
update_place_count: Arc<RwLock<usize>>,
delete_place_count: Arc<RwLock<usize>>,
}
#[cfg(test)]
impl MockPlacesRepository {
pub fn new() -> Self {
Self {
get_places_count: Arc::new(RwLock::new(0)),
get_places_paginated_count: Arc::new(RwLock::new(0)),
insert_place_count: Arc::new(RwLock::new(0)),
update_place_count: Arc::new(RwLock::new(0)),
delete_place_count: Arc::new(RwLock::new(0)),
}
}
pub async fn get_places_count(&self) -> usize {
*self.get_places_count.read().await
}
#[expect(dead_code)]
pub async fn get_places_paginated_count(&self) -> usize {
*self.get_places_paginated_count.read().await
}
pub async fn insert_place_count(&self) -> usize {
*self.insert_place_count.read().await
}
pub async fn update_place_count(&self) -> usize {
*self.update_place_count.read().await
}
pub async fn delete_place_count(&self) -> usize {
*self.delete_place_count.read().await
}
}
#[cfg(test)]
impl PlacesRepository for MockPlacesRepository {
async fn get_places(&self) -> Result<Vec<Place>, PlacesError> {
let mut get_places_count = self.get_places_count.write().await;
*get_places_count += 1;
Ok(Vec::new())
}
async fn get_places_paginated(
&self,
_offset: u32,
_limit: u8,
) -> Result<Vec<Place>, PlacesError> {
let mut get_places_paginated_count = self.get_places_paginated_count.write().await;
*get_places_paginated_count += 1;
Ok(Vec::new())
}
async fn insert_place(&self, place: super::models::PlaceInsert) -> Result<Place, PlacesError> {
let mut insert_place_count = self.insert_place_count.write().await;
*insert_place_count += 1;
let place: Place = (place, 0).into();
Ok(place)
}
async fn update_place(&self, place: Place) -> Result<Place, PlacesError> {
let mut update_place_count = self.update_place_count.write().await;
*update_place_count += 1;
Ok(place)
}
async fn delete_place(&self, _id: i64) -> Result<(), PlacesError> {
let mut delete_place_count = self.delete_place_count.write().await;
*delete_place_count += 1;
Ok(())
}
}

145
src/places/routes.rs Normal file
View file

@ -0,0 +1,145 @@
use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, put};
use axum_msgpack::MsgPack;
use super::models::{Place, PlaceUpsert};
use super::repository::{PlacesError, PlacesRepository};
type Result<T, E = (StatusCode, String)> = std::result::Result<T, E>;
fn internal_error(err: PlacesError) -> (StatusCode, String) {
match err {
PlacesError::FailToGet(_) | PlacesError::FailToUpsert(_) | PlacesError::FailToDelete(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
PlacesError::NotFound(_) => (StatusCode::NOT_FOUND, err.to_string()),
}
}
async fn get_places<PR: PlacesRepository>(
State(repository): State<PR>,
) -> Result<MsgPack<Vec<Place>>> {
let places = repository.get_places().await.map_err(internal_error)?;
Ok(MsgPack(places))
}
async fn upsert_place<PR: PlacesRepository>(
State(repository): State<PR>,
MsgPack(place): MsgPack<PlaceUpsert>,
) -> Result<MsgPack<Place>> {
let place = match place.into() {
(place, Some(id)) => repository.update_place((place, id).into()).await,
(place, None) => repository.insert_place(place).await,
}
.map_err(internal_error)?;
Ok(MsgPack(place))
}
async fn delete_place<PR: PlacesRepository>(
State(repository): State<PR>,
Path(id): Path<i64>,
) -> Result<()> {
repository.delete_place(id).await.map_err(internal_error)?;
Ok(())
}
pub fn places_routes<PR: PlacesRepository>(repository: PR) -> Router {
Router::new()
.route("/", get(get_places::<PR>))
.route("/", put(upsert_place::<PR>))
.route("/{id}", delete(delete_place::<PR>))
.with_state(repository)
}
mod tests {
#![cfg(test)]
use super::places_routes;
use crate::places::models::{Place, PlaceUpsert};
use crate::places::repository::MockPlacesRepository;
use anyhow::Result;
use axum::Router;
use axum::http::StatusCode;
use axum_test::TestServer;
fn setup_server() -> Result<(TestServer, MockPlacesRepository)> {
let places_repository = MockPlacesRepository::new();
let router = Router::new().nest("/places", places_routes(places_repository.clone()));
Ok((TestServer::new(router)?, places_repository))
}
#[tokio::test]
async fn test_add_place() -> Result<()> {
let (server, mock_repository) = setup_server()?;
let place = PlaceUpsert {
id: None,
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
};
// Insert the place
let res = server.put("/places").msgpack(&place).await;
// We should get a success on the request
assert_eq!(res.status_code(), StatusCode::OK);
let _res_place: Place = res.msgpack();
// The correct function should be called
assert_eq!(mock_repository.insert_place_count().await, 1);
Ok(())
}
#[tokio::test]
async fn test_get_places() -> Result<()> {
let (server, mock_repository) = setup_server()?;
// Get the places
let res = server.get("/places").await;
// We should get a success on the request
assert_eq!(res.status_code(), StatusCode::OK);
let _res_places: Vec<Place> = res.msgpack();
// and the correct function should be called
assert_eq!(mock_repository.get_places_count().await, 1);
Ok(())
}
#[tokio::test]
async fn test_delete() -> Result<()> {
let (server, mock_repository) = setup_server()?;
// Call delete
let res = server.delete("/places/0").await;
// We should get a success on the request
assert_eq!(res.status_code(), StatusCode::OK);
// The correct function should be called
assert_eq!(mock_repository.delete_place_count().await, 1);
Ok(())
}
#[tokio::test]
async fn test_update() -> Result<()> {
let (server, mock_repository) = setup_server()?;
let places = PlaceUpsert {
id: Some(1),
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
};
// upsert the place
let res = server.put("/places").msgpack(&places).await;
// We should get a success on the request
assert_eq!(res.status_code(), StatusCode::OK);
let _res_place: Place = res.msgpack();
// The correct function should be called
assert_eq!(mock_repository.update_place_count().await, 1);
Ok(())
}
}

View file

@ -1,321 +0,0 @@
use axum::extract::{Json, Path, State};
use axum::http::StatusCode;
use axum::routing::{delete, get};
use axum::Router;
use futures::TryStreamExt;
use sqlx::sqlite::SqlitePool;
use crate::place::Place;
type Result<T, E = (StatusCode, String)> = std::result::Result<T, E>;
fn internal_error<E>(err: E) -> (StatusCode, String)
where
E: std::error::Error,
{
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
async fn get_places(State(pool): State<SqlitePool>) -> Result<Json<Vec<Place>>> {
let places = ::sqlx::query!(
"SELECT id, name, address, open_hours, icon, description, url," +
r#"longitude as "longitude: f64", latitude as "latitude: f64" FROM places WHERE active = TRUE"#
)
.fetch(&pool)
.map_ok(|p| Place {
id: Some(p.id),
name: p.name,
address: p.address,
open_hours: p.open_hours,
icon: p.icon,
description: p.description,
latitude: p.latitude,
longitude: p.longitude,
url: p.url,
})
.try_collect::<Vec<_>>()
.await.map_err(internal_error)?;
Ok(Json(places))
}
async fn upsert_place(
State(pool): State<SqlitePool>,
Json(place): Json<Place>,
) -> Result<Json<Place>> {
if place.id.is_some() {
update_place(pool, place).await
} else {
insert_place(pool, place).await
}
}
struct Id {
id: i64,
}
async fn insert_place(pool: SqlitePool, mut place: Place) -> Result<Json<Place>> {
let i = ::sqlx::query_as!(
Id,
"INSERT INTO places (name, address, open_hours, icon, description, longitude, latitude, url)\
VALUES (?, ?, ?, ?, ?, ?, ?, ?)\
RETURNING id",
place.name,
place.address,
place.open_hours,
place.icon,
place.description,
place.longitude,
place.latitude,
place.url
)
.fetch_one(&pool)
.await
.map_err(internal_error)?;
place.id = Some(i.id);
Ok(Json(place))
}
async fn update_place(pool: SqlitePool, place: Place) -> Result<Json<Place>> {
let result = ::sqlx::query!(
"UPDATE places SET (name, address, open_hours, icon, description, longitude, latitude, url) = (?, ?, ?, ?, ?, ?, ?, ?) WHERE id = ?",
place.name,
place.address,
place.open_hours,
place.icon,
place.description,
place.longitude,
place.latitude,
place.url,
place.id,
)
.execute(&pool)
.await
.map_err(internal_error)?;
if result.rows_affected() == 1 {
Ok(Json(place))
} else {
Err((StatusCode::NOT_FOUND, "".to_owned()))
}
}
async fn delete_place(State(pool): State<SqlitePool>, Path(id): Path<i64>) -> Result<()> {
let result = ::sqlx::query!("UPDATE places SET active = FALSE WHERE id = ?", id)
.execute(&pool)
.await
.map_err(internal_error)?;
if result.rows_affected() == 1 {
Ok(())
} else {
Err((StatusCode::NOT_FOUND, "".to_owned()))
}
}
pub fn places_routes(pool: SqlitePool) -> Router {
Router::new()
.route("/", get(get_places).put(upsert_place))
.route("/:id", delete(delete_place))
.with_state(pool)
}
mod tests {
#![cfg(test)]
// ctor crate generates non upper case globals
#![allow(non_upper_case_globals)]
use crate::place::Place;
use crate::routes;
use axum::http::StatusCode;
use axum::Router;
use axum_test_helper::TestClient;
use sqlx::sqlite::SqlitePool;
fn client(pool: SqlitePool) -> TestClient {
let router = Router::new().nest("/places", routes::places_routes(pool.clone()));
TestClient::new(router)
}
#[sqlx::test]
async fn test_add_place(pool: SqlitePool) {
let client = client(pool);
let mut place = Place {
id: None,
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
};
// Insert hte place
let res = client.put("/places").json(&place).send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_place: Place = serde_json::from_value(res.json().await).unwrap();
// The inserted place should have an ID
assert!(res_place.id.is_some());
// Add the returned ID to the original place
place.id = res_place.id;
// And now they should be equal
assert_eq!(place, res_place)
// TODO: actually query the DB to check the place was inserted
}
#[sqlx::test]
async fn test_get_places(pool: SqlitePool) {
let client = client(pool);
let mut places = vec![
Place {
id: None,
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
Place {
id: None,
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert the places
for p in &mut places {
let res = client.put("/places").json(&p).send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_place: Place = serde_json::from_value(res.json().await).unwrap();
// The inserted place should have an ID
assert!(res_place.id.is_some());
// Add the returned ID to the original place
p.id = res_place.id;
}
// and fetch them
let res = client.get("/places").send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let mut res_places: Vec<Place> = serde_json::from_value(res.json().await).unwrap();
// and they should be equal
places.sort_by(|a, b| a.id.cmp(&b.id));
res_places.sort_by(|a, b| a.id.cmp(&b.id));
assert_eq!(places, res_places);
}
#[sqlx::test]
async fn test_delete(pool: SqlitePool) {
let client = client(pool);
let mut places = vec![
Place {
id: None,
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
Place {
id: None,
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert the places
for p in &mut places {
let res = client.put("/places").json(&p).send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_place: Place = serde_json::from_value(res.json().await).unwrap();
// The inserted place should have an ID
assert!(res_place.id.is_some());
// Add the returned ID to the original place
p.id = res_place.id;
}
// delete the first one
let res = client
.delete(&format!("/places/{}", places[0].id.unwrap()))
.send()
.await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
// fetch the remaining places
let res = client.get("/places").send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_places: Vec<Place> = serde_json::from_value(res.json().await).unwrap();
// we should only get the second place
assert_eq!(&places[1..], res_places.as_slice());
}
#[sqlx::test]
async fn test_update(pool: SqlitePool) {
let client = client(pool);
let mut places = vec![
Place {
id: None,
name: "Sherlock Holmes".to_owned(),
address: "221 B Baker Street, London".to_owned(),
description: "Museum and Gift Shop".to_owned(),
icon: "museum".to_owned(),
latitude: 51.5237669,
longitude: -0.1627829,
open_hours: "Tu-Su 09:30-18:00".to_owned(),
url: Some("https://www.sherlock-holmes.co.uk/".to_owned()),
},
Place {
id: None,
name: "Museo Nacional de Historia Natural".to_owned(),
address: "Parque Quinta Normal S/N, Santiago".to_owned(),
description: "Museo".to_owned(),
icon: "museum".to_owned(),
latitude: -70.681838888889,
longitude: -33.4421694444449,
open_hours: "Tu-Su 10:00-18:00".to_owned(),
url: Some("https://www.mnhn.gob.cl/".to_owned()),
},
];
// insert original place
let res = client.put("/places").json(&places[0]).send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_place: Place = serde_json::from_value(res.json().await).unwrap();
// The inserted place should have an ID
assert!(res_place.id.is_some());
// Add the returned ID to the new place so we can do the update
places[1].id = res_place.id;
// update the place
let res = client.put("/places").json(&places[1]).send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
// fetch the places
let res = client.get("/places").send().await;
// We should get a success on the request
assert_eq!(res.status(), StatusCode::OK);
let res_places: Vec<Place> = serde_json::from_value(res.json().await).unwrap();
// we should get the updated place
assert_eq!(&places[1..], res_places.as_slice());
}
}

37
src/server.rs Normal file
View file

@ -0,0 +1,37 @@
//! HTTP Server
use anyhow::Result;
use axum::Router;
use axum::serve::ListenerExt;
use std::net::SocketAddr;
use tower_http::services::ServeDir;
pub async fn serve(place_routes: Router) -> Result<()> {
let port = std::env::var("PORT").unwrap_or_default();
let port = str::parse(&port).unwrap_or(3000);
let address = SocketAddr::from(([0, 0, 0, 0], port));
let routes = Router::new()
.nest("/places", place_routes)
.fallback_service(ServeDir::new("static"));
tracing::debug!("listening on {}", address);
let listener = tokio::net::TcpListener::bind(address)
.await?
.tap_io(|tcp_stream| {
if let Err(err) = tcp_stream.set_nodelay(true) {
tracing::trace!("failed to set TCP_NODELAY on incoming connection: {err:#}");
}
});
axum::serve(listener, routes.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("failed to listen for ctrl-c");
tracing::debug!("Received shutdown signal");
}

41
src/tui/keys.rs Normal file
View file

@ -0,0 +1,41 @@
//! Keyboard handling
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::state::{Mode, State};
/// Event handling
pub async fn handle_key(state: &mut State, key_event: KeyEvent) {
if state.confirmation.is_some() {
match key_event.code {
KeyCode::Char('y') => state.proceed_confirmation().await,
KeyCode::Char('n') | KeyCode::Esc => state.cancel_confirmation(),
_ => {}
};
return;
}
match state.mode {
Mode::List => match key_event.code {
KeyCode::Char('d') => state.confirm_deletion(),
KeyCode::Char('e') => {
state.set_edit_mode();
}
KeyCode::Home => state.selected_place.select_first(),
KeyCode::End => state.selected_place.select_last(),
KeyCode::PageUp => state.prev_page(),
KeyCode::PageDown => state.next_page(),
KeyCode::Up | KeyCode::Char('k') => state.selected_place.select_previous(),
KeyCode::Down | KeyCode::Char('j') => state.selected_place.select_next(),
KeyCode::Esc | KeyCode::Char('q') => state.quit = true,
_ => {}
},
Mode::Edit => match (key_event.modifiers, key_event.code) {
(_, KeyCode::Esc) => state.set_list_mode(),
(_, KeyCode::Tab) => state.edit_next(),
(_, KeyCode::BackTab) => state.edit_prev(),
(KeyModifiers::CONTROL, KeyCode::Char('s')) => state.start_save(),
_ => state.edit_input(key_event),
},
}
}

46
src/tui/mod.rs Normal file
View file

@ -0,0 +1,46 @@
//! TUI
pub mod keys;
pub mod state;
pub mod terminal;
pub mod ui;
use anyhow::Result;
use state::State;
use terminal::Event;
use ui::UI;
use crate::places::db_repository::DbPlacesRepository;
/// Fires up the UI
pub async fn tui(places_repository: DbPlacesRepository) -> Result<()> {
let (_, terminal_height) = crossterm::terminal::size()?;
let mut state = State::new(places_repository, terminal_height);
let mut ui = UI::new(&state);
let mut tui = terminal::Tui::new()?;
let result = loop {
match tui.next().await? {
Event::Key(key_event) => keys::handle_key(&mut state, key_event).await,
Event::Render => {
let messages = state.ui_messages.drain(0..).collect::<Vec<_>>();
ui.handle_messages(&mut state, messages);
tui.draw(|frame| ui.draw(&mut state, frame))?;
}
Event::Tick => {
state.fetch_places().await;
}
Event::Resize(_, h) => state.height = h,
Event::Quit => state.quit = true,
_ => {}
}
if state.quit {
break Ok(());
}
};
tui.stop()?;
result
}

184
src/tui/state.rs Normal file
View file

@ -0,0 +1,184 @@
//! TUI state
use ratatui::crossterm::event::KeyEvent;
use ratatui::widgets::TableState;
use crate::places::db_repository::DbPlacesRepository;
use crate::places::models::Place;
use crate::places::repository::PlacesRepository;
use super::ui::Message;
pub struct State {
pub height: u16,
pub page: u32,
pub mode: Mode,
places_repository: DbPlacesRepository,
pub places: Vec<Place>,
places_status: DataStatus,
pub confirmation: Option<ConfirmationStatus>,
pub selected_place: TableState,
pub ui_messages: Vec<Message>,
pub quit: bool,
}
#[derive(Copy, Clone)]
pub enum Mode {
List,
Edit,
}
enum DataStatus {
Fresh,
Old,
}
pub enum ConfirmationStatus {
Deletion(i64),
Save(Place),
}
impl State {
pub fn new(places_repository: DbPlacesRepository, height: u16) -> Self {
Self {
height,
page: 0,
mode: Mode::List,
places_repository,
places_status: DataStatus::Old,
places: vec![],
selected_place: TableState::default(),
confirmation: None,
ui_messages: Vec::new(),
quit: false,
}
}
pub fn set_list_mode(&mut self) {
self.mode = Mode::List;
self.push_mode_change();
}
pub fn set_edit_mode(&mut self) {
self.mode = Mode::Edit;
self.push_mode_change();
let Some(selection) = self.selected_place.selected() else {
return;
};
let Some(place) = self.places.get(selection) else {
return;
};
self.ui_messages.push(Message::EditPlace(place.clone()))
}
fn push_mode_change(&mut self) {
self.ui_messages.push(Message::UpdateAppMode(self.mode));
}
pub fn next_page(&mut self) {
self.page += 1;
self.places_status = DataStatus::Old;
self.push_page_change();
}
pub fn prev_page(&mut self) {
self.page = self.page.saturating_sub(1);
self.places_status = DataStatus::Old;
self.push_page_change();
}
fn push_page_change(&mut self) {
self.ui_messages.push(Message::UpdatePage(self.page));
}
pub fn edit_next(&mut self) {
self.ui_messages.push(Message::EditNext);
}
pub fn edit_prev(&mut self) {
self.ui_messages.push(Message::EditPrev);
}
pub fn edit_input(&mut self, key_event: KeyEvent) {
self.ui_messages.push(Message::Input(key_event));
}
pub async fn fetch_places(&mut self) {
if let DataStatus::Fresh = self.places_status {
return;
}
let limit = (self.height as u8).saturating_sub(3);
let offset = (limit as u32) * self.page;
match self
.places_repository
.get_places_paginated(offset, limit)
.await
{
Ok(places) => {
self.places = places;
if !self.places.is_empty() {
self.selected_place.select(Some(0));
}
}
Err(err) => {
tracing::error!("{err}");
}
}
self.places_status = DataStatus::Fresh;
self.ui_messages
.push(Message::UpdatePlaces(self.places.clone()))
}
pub fn confirm_deletion(&mut self) {
if let Some(Some(id)) = self
.selected_place
.selected()
.map(|index| self.places.get(index).map(|p| p.id))
{
self.confirmation = Some(ConfirmationStatus::Deletion(id))
}
}
pub fn start_save(&mut self) {
if let Some(Some(id)) = self
.selected_place
.selected()
.map(|index| self.places.get(index).map(|p| p.id))
{
self.ui_messages.push(Message::SavePlace(id));
}
}
pub fn confirm_save(&mut self, place: Place) {
self.confirmation = Some(ConfirmationStatus::Save(place));
}
pub fn cancel_confirmation(&mut self) {
self.confirmation = None;
}
pub async fn proceed_confirmation(&mut self) {
let Some(confirmation) = &self.confirmation else {
return;
};
match confirmation {
ConfirmationStatus::Deletion(id) => {
if let Err(err) = self.places_repository.delete_place(*id).await {
tracing::error!("{err}");
}
}
ConfirmationStatus::Save(place) => {
if let Err(err) = self.places_repository.update_place(place.clone()).await {
tracing::error!("{err}");
}
self.mode = Mode::List;
self.push_mode_change();
}
}
self.confirmation = None;
self.places_status = DataStatus::Old;
}
}

189
src/tui/terminal.rs Normal file
View file

@ -0,0 +1,189 @@
use std::ops::{Deref, DerefMut};
use anyhow::{Result, anyhow};
use crossterm::event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use futures::{FutureExt, StreamExt};
use ratatui::Terminal;
use ratatui::prelude::CrosstermBackend;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
/// Terminal events.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
/// Triggers background actions
Tick,
/// UI Render
Render,
FocusGained,
FocusLost,
Paste(String),
/// Key Press
Key(KeyEvent),
Mouse(MouseEvent),
/// Terminal Resize
Resize(u16, u16),
}
/// Terminal event handler.
pub struct Tui {
/// Terminal backend
pub terminal: ratatui::Terminal<CrosstermBackend<std::io::Stderr>>,
/// Event handler task.
pub task: JoinHandle<Result<()>>,
/// Cancelation token
pub cancellation_token: CancellationToken,
/// Event receiver channel.
receiver: UnboundedReceiver<Event>,
}
impl Tui {
pub fn new() -> Result<Self> {
let tick_rate = 4.0;
let frame_rate = 20.0;
let terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
let cancellation_token = CancellationToken::new();
// setup the event handling task
let (sender, receiver) = unbounded_channel();
let task = {
let cancellation_token = cancellation_token.clone();
let sender = sender.clone();
let tick_delay = std::time::Duration::from_secs_f64(1.0 / tick_rate);
let render_delay = std::time::Duration::from_secs_f64(1.0 / frame_rate);
tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut tick_interval = tokio::time::interval(tick_delay);
let mut render_interval = tokio::time::interval(render_delay);
sender.send(Event::Init)?;
loop {
let tick_delay = tick_interval.tick();
let render_delay = render_interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = cancellation_token.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(ev)) => {
match ev {
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
sender.send(Event::Key(key))?
}
},
CrosstermEvent::Mouse(mouse) => {
sender.send(Event::Mouse(mouse))?
},
CrosstermEvent::Resize(x, y)=> {
sender.send(Event::Resize(x,y))?
},
CrosstermEvent::FocusLost => sender.send(Event::FocusLost)?,
CrosstermEvent::FocusGained => sender.send(Event::FocusGained)?,
CrosstermEvent::Paste(s) => sender.send(Event::Paste(s))?,
}
},
Some(Err(_)) => sender.send(Event::Error)?,
None => {}
}
},
_ = tick_delay => {
sender.send(Event::Tick)?;
},
_ = render_delay => {
sender.send(Event::Render)?;
}
}
}
Ok(())
})
};
// Setup the terminal
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(
std::io::stderr(),
EnterAlternateScreen,
crossterm::cursor::Hide
)?;
Ok(Self {
terminal,
cancellation_token,
task,
receiver,
})
}
fn cancel(&self) {
self.cancellation_token.cancel();
}
pub fn stop(&self) -> Result<()> {
self.cancel();
let mut counter = 0;
while !self.task.is_finished() {
std::thread::sleep(std::time::Duration::from_millis(10));
counter += 1;
if counter > 5 {
self.task.abort();
}
if counter > 10 {
return Err(anyhow!(
"Failed to abort task in 100 milliseconds for unknown reason"
));
}
}
Ok(())
}
fn exit(&mut self) -> Result<()> {
self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
crossterm::execute!(
std::io::stderr(),
LeaveAlternateScreen,
crossterm::cursor::Show
)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
/// Receive the next event from the handler task.
pub async fn next(&mut self) -> Result<Event> {
self.receiver
.recv()
.await
.ok_or_else(|| anyhow!("Events channel was closed"))
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<CrosstermBackend<std::io::Stderr>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.exit().unwrap();
}
}

487
src/tui/ui.rs Normal file
View file

@ -0,0 +1,487 @@
//! UI definition and drawing
use itertools::Itertools;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text, ToSpan};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Row, Table};
use tui_textarea::TextArea;
use crate::places::models::Place;
use super::state::{ConfirmationStatus, Mode, State};
pub enum Message {
UpdateAppMode(Mode),
UpdatePage(u32),
UpdatePlaces(Vec<Place>),
EditPlace(Place),
EditNext,
EditPrev,
SavePlace(i64),
Input(KeyEvent),
}
pub struct UI {
header: Header,
main: Main,
footer: Footer,
}
impl UI {
pub fn new(state: &State) -> Self {
Self {
header: Header::new(state),
main: Main::new(state),
footer: Footer::new(state),
}
}
pub fn handle_messages<M: IntoIterator<Item = Message>>(
&mut self,
state: &mut State,
messages: M,
) {
for m in messages {
match m {
Message::UpdateAppMode(mode) => {
self.header.update_app_mode(mode);
self.footer.update_keybindings(mode);
}
Message::UpdatePage(page) => self.header.update_page(page),
Message::UpdatePlaces(places) => self.main.update_places_table(places),
Message::EditPlace(place) => self.main.set_edit_textareas(place),
Message::EditNext => self.main.next_textarea(),
Message::EditPrev => self.main.prev_textarea(),
Message::SavePlace(id) => self.main.save_place(state, id),
Message::Input(key_event) => self.main.pass_input(key_event),
}
}
}
/// UI drawing
pub fn draw(&mut self, state: &mut State, f: &mut Frame<'_>) {
let main_split = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.split(f.area());
self.header.draw(f, main_split[0]);
self.main.draw(state, f, main_split[1]);
self.footer.draw(f, main_split[2]);
}
}
struct Header {
app_name: Span<'static>,
app_mode: Span<'static>,
page: Line<'static>,
}
impl Header {
fn new(state: &State) -> Self {
let app_name = Span::styled(
" huellas ",
Style::new().bg(Color::Gray).fg(Color::Black).bold(),
);
let app_mode = Self::get_app_mode(state.mode);
let page = Self::get_page(state.page);
Self {
app_name,
app_mode,
page,
}
}
fn update_app_mode(&mut self, new_mode: Mode) {
self.app_mode = Self::get_app_mode(new_mode)
}
fn get_app_mode(mode: Mode) -> Span<'static> {
match mode {
Mode::List => " LIST ".to_span().black().on_light_green().bold(),
Mode::Edit => " EDIT ".to_span().black().on_light_blue().bold(),
}
}
fn update_page(&mut self, new_page: u32) {
self.page = Self::get_page(new_page)
}
fn get_page(page: u32) -> Line<'static> {
Line::from_iter([
"Page: ".to_span(),
Span::raw(page.to_string()),
" ".to_span(),
])
.right_aligned()
}
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(9),
Constraint::Fill(1),
Constraint::Length(6),
])
.split(area);
f.render_widget(&self.app_name, split[0]);
f.render_widget(&self.page, split[1]);
f.render_widget(&self.app_mode, split[2]);
}
}
struct Main {
places_table: Table<'static>,
edit_textareas: Vec<TextArea<'static>>,
selected_textarea: usize,
}
impl Main {
fn new(state: &State) -> Self {
let places_table = Self::get_places_table(state.places.clone());
let edit_textareas = Vec::new();
let selected_textarea = 0;
Self {
places_table,
edit_textareas,
selected_textarea,
}
}
fn update_places_table(&mut self, new_places: Vec<Place>) {
self.places_table = Self::get_places_table(new_places);
}
fn get_places_table(places: Vec<Place>) -> Table<'static> {
let places = places.into_iter().map(|p| {
Row::new([
p.id.to_string(),
p.name,
p.latitude.to_string(),
p.longitude.to_string(),
p.icon,
p.address,
p.open_hours,
p.description,
url(p.url),
])
});
let widths = [
Constraint::Length(3),
Constraint::Fill(1),
Constraint::Length(7),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
];
Table::new(places, widths)
.header(
Row::new([
"Id",
"Name",
"Lat",
"Long",
"Icon",
"Address",
"Open Hours",
"Description",
"URL",
])
.style(Style::new().white().on_dark_gray().bold()),
)
.row_highlight_style(Style::new().reversed())
}
fn set_edit_textareas(&mut self, place: Place) {
let mut name = TextArea::new(vec![place.name]);
name.set_block(Block::default().title("Name"));
let mut latitude = TextArea::new(vec![place.latitude.to_string()]);
latitude.set_block(Block::default().title("Latitude"));
let mut longitude = TextArea::new(vec![place.longitude.to_string()]);
longitude.set_block(Block::default().title("Longitude"));
let mut icon = TextArea::new(vec![place.icon]);
icon.set_block(Block::default().title("Icon"));
let mut address = TextArea::new(vec![place.address]);
address.set_block(Block::default().title("Address"));
let mut url = TextArea::new(vec![place.url.unwrap_or_default()]);
url.set_block(Block::default().title("URL"));
let mut open_hours = TextArea::new(vec![place.open_hours]);
open_hours.set_block(Block::default().title("Open Hours"));
let mut description = TextArea::new(vec![place.description]);
description.set_block(Block::default().title("Description"));
self.edit_textareas = vec![
name,
latitude,
longitude,
icon,
address,
url,
open_hours,
description,
];
for textarea in &mut self.edit_textareas {
inactive_textarea(textarea);
}
active_textarea(&mut self.edit_textareas[0]);
self.selected_textarea = 0;
}
fn next_textarea(&mut self) {
let n = self.edit_textareas.len();
if n != 0 {
if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
inactive_textarea(prev_textarea);
}
self.selected_textarea = (self.selected_textarea + 1) % n;
if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
active_textarea(next_textarea);
}
}
}
fn prev_textarea(&mut self) {
let n = self.edit_textareas.len();
if n != 0 {
if let Some(prev_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
inactive_textarea(prev_textarea);
}
self.selected_textarea = (self.selected_textarea + n - 1) % n;
if let Some(next_textarea) = self.edit_textareas.get_mut(self.selected_textarea) {
active_textarea(next_textarea);
}
}
}
fn save_place(&self, state: &mut State, id: i64) {
let name = self.edit_textareas[0].lines().concat();
let Ok(latitude) = self.edit_textareas[1].lines().concat().parse::<f64>() else {
return;
};
let Ok(longitude) = self.edit_textareas[2].lines().concat().parse::<f64>() else {
return;
};
let icon = self.edit_textareas[3].lines().concat();
let address = self.edit_textareas[4].lines().concat();
let url = self.edit_textareas[5].lines().concat();
let url = if url.is_empty() { None } else { Some(url) };
let open_hours = self.edit_textareas[6].lines().concat();
let description = self.edit_textareas[7].lines().concat();
let place = Place {
id,
name,
address,
open_hours,
icon,
description,
longitude,
latitude,
url,
};
state.confirm_save(place);
}
fn pass_input(&mut self, key_event: KeyEvent) {
let Some(active_textarea) = self.edit_textareas.get_mut(self.selected_textarea) else {
return;
};
match key_event.code {
KeyCode::Enter => {
// Only allow line breaking on open hours and description fields
if self.selected_textarea == 6 || self.selected_textarea == 7 {
active_textarea.input(key_event);
}
}
_ => {
active_textarea.input(key_event);
}
}
}
fn draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) {
match state.mode {
Mode::List => self.list_draw(state, f, area),
Mode::Edit => self.edit_draw(state, f, area),
}
confirmation_dialog_draw(state, f, area);
}
fn list_draw(&self, state: &mut State, f: &mut Frame<'_>, area: Rect) {
f.render_stateful_widget(&self.places_table, area, &mut state.selected_place);
}
fn edit_draw(&self, _state: &mut State, f: &mut Frame<'_>, area: Rect) {
let areas: [_; 8] = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Min(5),
])
.areas(area);
for (textarea, area) in self.edit_textareas.iter().zip(areas.into_iter()) {
f.render_widget(textarea, area);
}
}
}
fn confirmation_dialog_draw(state: &mut State, f: &mut Frame<'_>, area: Rect) {
let Some(confirmation) = &state.confirmation else {
return;
};
let dialog_area = center(area, Constraint::Percentage(80), Constraint::Percentage(60));
let (action, id) = match confirmation {
ConfirmationStatus::Deletion(id) => ("delete", id),
ConfirmationStatus::Save(place) => ("save", &place.id),
};
let confirmation_dialog = Paragraph::new(Text::from_iter([
Line::from_iter([
"Do you want to ".to_span(),
action.to_span(),
" place with id: ".to_span(),
id.to_span(),
]),
Line::from(""),
Line::from("Y/N".to_span().bold()),
]))
.centered()
.block(Block::bordered().padding(Padding::uniform(1)));
f.render_widget(Clear, dialog_area);
f.render_widget(confirmation_dialog, dialog_area);
}
struct Footer {
keybindings: Paragraph<'static>,
}
impl Footer {
fn new(state: &State) -> Self {
let keybindings = Self::get_keybindings(state.mode);
Self { keybindings }
}
fn update_keybindings(&mut self, new_mode: Mode) {
self.keybindings = Self::get_keybindings(new_mode)
}
#[expect(unstable_name_collisions)]
fn get_keybindings(mode: Mode) -> Paragraph<'static> {
let separator = Span::styled(" ", Style::new().black().on_black());
match mode {
Mode::List => {
let keybindings = [
("j/k", "Next/Previous"),
("Home/End", "First/Last"),
("PgUp", "Prev Page"),
("PgDown", "Next Page"),
("e", "Edit"),
("d", "Delete"),
]
.map(|(key, action)| keybinding(key, action).to_vec())
.into_iter()
.intersperse(vec![separator])
.flatten();
Paragraph::new(Line::from_iter(keybindings))
}
Mode::Edit => {
let keybindings = [
("Esc", "Close w/o saving"),
("Tab/S-Tab", "Next/prev field"),
("C-s", "Save"),
]
.map(|(key, action)| keybinding(key, action).to_vec())
.into_iter()
.intersperse(vec![separator])
.flatten();
Paragraph::new(Line::from_iter(keybindings))
}
}
}
fn draw(&self, f: &mut Frame<'_>, area: Rect) {
f.render_widget(&self.keybindings, area);
}
}
fn url(url: Option<String>) -> String {
match url {
Some(url) => {
if url.starts_with("https://instagram.com/") {
format!(
"@{}",
url.trim_start_matches("https://instagram.com/")
.trim_end_matches("/")
)
} else {
url
}
}
None => String::new(),
}
}
fn keybinding(key: &'static str, action: &'static str) -> [Span<'static>; 5] {
let black_bold = Style::new().black().on_gray().bold();
let red_bold = Style::new().red().on_gray().bold();
let black = Style::new().black().on_gray();
[
Span::styled(" <", black_bold),
Span::styled(key, red_bold),
Span::styled("> ", black_bold),
Span::styled(action, black),
Span::styled(" ", black),
]
}
fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect {
let [area] = Layout::horizontal([horizontal])
.flex(Flex::Center)
.areas(area);
let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area);
area
}
fn inactive_textarea(textarea: &mut TextArea<'_>) {
textarea.set_cursor_line_style(Style::default());
textarea.set_cursor_style(Style::default());
if let Some(block) = textarea.block().map(|block| {
block
.clone()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Gray))
}) {
textarea.set_block(block);
};
}
fn active_textarea(textarea: &mut TextArea<'_>) {
textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
if let Some(block) = textarea
.block()
.map(|block| block.clone().style(Style::default().bold()))
{
textarea.set_block(block);
};
}

View file

@ -17,18 +17,19 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet-contextmenu/1.4.0/leaflet.contextmenu.min.js"
integrity="sha512-8sfQf8cr0KjCeN32YPfjvLU2cMvyY1lhCXTMfpTZ16CvwIzeVQtwtKlxeSqFs/TpXjKhp1Dcv77LQmn1VFaOZg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script crossorigin src="https://unpkg.com/@msgpack/msgpack@3.1.2/dist.umd/msgpack.min.js"
integrity="sha512-B9xeVWeBMLLUlFALrj2/h3IY/N7MJSkzBrwIltslJSlfWdPsQsQinFJ3X9PuAsz695c5qy5U0194ZqZTg8H3yg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style type="text/css" media="screen">
body {
padding: 0;
margin: 0;
}
html, body, #map {
height: 100vh;
width: 100vw;
}
.leaflet-popup-content h3 {
margin-top: 1em;
margin-bottom: 0.5em;
@ -38,20 +39,8 @@
padding: 0;
margin: 0;
}
#modal {
display: none;
position: fixed;
z-index: 400;
left: 0;
top: 0;
height: 100vh;
width: 100vw;
overflow: auto;
background-color: rgb(0,0,0);
background-color: rgba(0,0,0,0.4);
dialog {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
}
#modal-form {
margin: 15vh auto;
color: #333;
background-color: white;
@ -60,6 +49,9 @@
width: 80vw;
border-radius: 12px;
}
dialog::backdrop {
background-color: rgba(0,0,0,0.4);
}
#close {
color: #333;
float: right;
@ -91,66 +83,64 @@
</head>
<body>
<div id="map"></div>
<div id="modal">
<div id="modal-form">
<span id="close">&times;</span>
<h1>Título</h1>
<form>
<p>
<label for="id"> Id:</label>
<input type="text" id="id" name="id" size="30" readonly>
</p>
<p>
<label for="name"> Longitud:</label>
<input type="text" id="long" name="long" size="30" readonly>
</p>
<p>
<label for="name"> Latitud:</label>
<input type="text" id="lat" name="lat" size="30" readonly>
</p>
<p>
<label for="name"> Nombre:</label>
<input type="text" id="name" name="name" size="30">
<p>
<label for="address"> Dirección:</label>
<input type="text" id="address" name="address" size="30">
</p>
<p>
<label for="open_hours"> Horario:</label>
<textarea id="open_hours" name="open_hours"
cols="30"></textarea>
</p>
<p>
<label for="icon"> Ícono:</label>
<select id="icon" name="icon">
<option value="bar">Bar</option>
<option value="coffee">Café</option>
<option value="cinema">Cine</option>
<option value="food">Comida</option>
<option value="jazz">Jazz</option>
<option value="library">Librería</option>
<option value="marker" selected>Marcador</option>
<option value="museum">Museo</option>
<option value="dining">Restaurant</option>
<option value="mask">Teatro</option>
<option value="shop">Tienda</option>
</select>
</p>
<p>
<label for="url"> URL:</label>
<input type="text" id="url" name="url" size="30">
</p>
<p>
<label for="description"> Descripción:</label>
<textarea id="description" name="description"
cols="30" rows="5"></textarea>
</p>
<p>
<button type="button" id="button">Enviar </button>
</p>
</form>
</div>
</div>
<dialog id="dialog">
<span id="close">&times;</span>
<h1>Título</h1>
<form>
<p>
<label for="id"> Id:</label>
<input type="text" id="id" name="id" size="30" readonly>
</p>
<p>
<label for="name"> Longitud:</label>
<input type="text" id="long" name="long" size="30" readonly>
</p>
<p>
<label for="name"> Latitud:</label>
<input type="text" id="lat" name="lat" size="30" readonly>
</p>
<p>
<label for="name"> Nombre:</label>
<input type="text" id="name" name="name" size="30">
<p>
<label for="address"> Dirección:</label>
<input type="text" id="address" name="address" size="30">
</p>
<p>
<label for="open_hours"> Horario:</label>
<textarea id="open_hours" name="open_hours"
cols="30"></textarea>
</p>
<p>
<label for="icon"> Ícono:</label>
<select id="icon" name="icon">
<option value="bar">Bar</option>
<option value="coffee">Café</option>
<option value="cinema">Cine</option>
<option value="food">Comida</option>
<option value="jazz">Jazz</option>
<option value="library">Librería</option>
<option value="marker" selected>Marcador</option>
<option value="museum">Museo</option>
<option value="dining">Restaurant</option>
<option value="mask">Teatro</option>
<option value="shop">Tienda</option>
</select>
</p>
<p>
<label for="url"> URL:</label>
<input type="text" id="url" name="url" size="30">
</p>
<p>
<label for="description"> Descripción:</label>
<textarea id="description" name="description"
cols="30" rows="5"></textarea>
</p>
<p>
<button type="button" id="button">Enviar </button>
</p>
</form>
</dialog>
<script src="client.js" onload="setupMap()"></script>
</body>
</html>

View file

@ -1,3 +1,3 @@
all: client.ts
build/client.js: client.ts
tsc
sed -i '1d' build/client.js
sed -i '1,2d' build/client.js

View file

@ -1,5 +1,6 @@
import * as L from 'leaflet-contextmenu';
import { Feature, FeatureCollection, Point } from 'geojson';
import * as MessagePack from "@msgpack/msgpack";
interface PlaceModel {
id: number | null;
@ -14,7 +15,8 @@ interface PlaceModel {
}
async function loadPlaces(): Promise<Array<PlaceModel>> {
return await fetch('places').then(response => response.json());
let bytes = await fetch('places').then(response => response.body);
return (await MessagePack.decodeAsync(bytes)) as Array<PlaceModel>;
}
function toFeature(place: PlaceModel): Feature {
@ -111,7 +113,7 @@ function clearForm(): void {
document.getElementById("button").onclick = null;
/* Now you see it, now you don't*/
document.getElementById("modal").style.display = "none";
(document.getElementById("dialog") as HTMLDialogElement).close();
}
async function createPlace(): Promise<void> {
@ -120,11 +122,12 @@ async function createPlace(): Promise<void> {
await fetch('places', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/msgpack',
},
body: JSON.stringify(newPlace),
body: MessagePack.encode(newPlace),
})
.then((response) => response.json())
.then((response) => response.body)
.then((bytes) => MessagePack.decodeAsync(bytes))
.then((place: PlaceModel) => {
places.set(
toStr({ lat: place.latitude, lng: place.longitude }),
@ -143,11 +146,12 @@ async function editPlace(): Promise<void> {
await fetch('places', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Content-Type': 'application/msgpack',
},
body: JSON.stringify(newPlace),
body: MessagePack.encode(newPlace),
})
.then((response) => response.json())
.then((response) => response.body)
.then((bytes) => MessagePack.decodeAsync(bytes))
.then((place: PlaceModel) => {
places.set(
toStr({ lat: place.latitude, lng: place.longitude }),
@ -177,30 +181,25 @@ async function getAddressReverse(lat: number, long: number): Promise<string> {
function toLink(url: string): string {
let content = url;
const m = url.match("https://instagram.com/(.*)");
const m = url.match(/https:\/\/instagram\.com\/((?:\w|\.)+)\/?/);
if (m) {
content = `@${m[1]}`;
}
return `<a href="${url}" target="_blank">${content}</a>`
}
async function setupMap(): Promise<void> {
/* Create/Edit form */
const modal = document.getElementById("modal");
const dialog = document.getElementById("dialog") as HTMLDialogElement;
const closeButton = document.getElementById("close");
closeButton.onclick = function() {
modal.style.display = "none";
}
window.onclick = function(e: Event) {
if (e.target == modal) {
modal.style.display = "none";
}
dialog.close();
}
async function openForm(op: Operation, lat: number, long: number): Promise<void> {
/* Fill the form for us */
const h1 = modal.getElementsByTagName("h1")[0];
const h1 = dialog.getElementsByTagName("h1")[0];
if (op == Operation.Create) {
clearForm()
h1.innerText = "Añadir lugar nuevo";
@ -250,7 +249,7 @@ async function setupMap(): Promise<void> {
}
/* Make it appear */
modal.style.display = "block";
dialog.showModal();
}
function openCreateForm(e: MapEvent) {
@ -317,7 +316,7 @@ async function setupMap(): Promise<void> {
const lnglat = (feature.geometry as Point).coordinates;
const lng = lnglat[0];
const lat = lnglat[0];
const lat = lnglat[1];
popupStr += `<a href="https://www.google.com/maps/dir//` +
`${lat},${lng}/@${lat},${lng},15z" target="_blank">GMaps</a>`
popupStr += "</ul>";

View file

@ -1,82 +1,96 @@
{
"name": "ts-client",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"geojson": "^0.5.0",
"leaflet": "^1.8.0",
"leaflet-contextmenu": "^1.4.0"
},
"devDependencies": {
"@types/leaflet": "^1.7.11"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.11.tgz",
"integrity": "sha512-VwAYom2pfIAf/pLj1VR5aLltd4tOtHyvfaJlNYCoejzP2nu52PrMi1ehsLRMUS+bgafmIIKBV1cMfKeS+uJ0Vg==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/geojson": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz",
"integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/leaflet": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.8.0.tgz",
"integrity": "sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA=="
},
"node_modules/leaflet-contextmenu": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/leaflet-contextmenu/-/leaflet-contextmenu-1.4.0.tgz",
"integrity": "sha512-BXASCmJ5bLkuJGDCpWmvGqhZi5AzeOY0IbQalfkgBcMAMfAOFSvD4y0gIQxF/XzEyLkjXaRiUpibVj4+Cf3tUA=="
}
},
"dependencies": {
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"@types/leaflet": {
"version": "1.7.11",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.7.11.tgz",
"integrity": "sha512-VwAYom2pfIAf/pLj1VR5aLltd4tOtHyvfaJlNYCoejzP2nu52PrMi1ehsLRMUS+bgafmIIKBV1cMfKeS+uJ0Vg==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"geojson": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz",
"integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ=="
},
"leaflet": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.8.0.tgz",
"integrity": "sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA=="
},
"leaflet-contextmenu": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/leaflet-contextmenu/-/leaflet-contextmenu-1.4.0.tgz",
"integrity": "sha512-BXASCmJ5bLkuJGDCpWmvGqhZi5AzeOY0IbQalfkgBcMAMfAOFSvD4y0gIQxF/XzEyLkjXaRiUpibVj4+Cf3tUA=="
}
}
"name": "ts-client",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"geojson": "^0.5.0",
"leaflet": "^1.9.4",
"leaflet-contextmenu": "^1.4.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.8"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.0.0-beta2",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz",
"integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.9.8",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz",
"integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/geojson": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz",
"integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/leaflet-contextmenu": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/leaflet-contextmenu/-/leaflet-contextmenu-1.4.0.tgz",
"integrity": "sha512-BXASCmJ5bLkuJGDCpWmvGqhZi5AzeOY0IbQalfkgBcMAMfAOFSvD4y0gIQxF/XzEyLkjXaRiUpibVj4+Cf3tUA=="
}
},
"dependencies": {
"@msgpack/msgpack": {
"version": "3.0.0-beta2",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz",
"integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw=="
},
"@types/geojson": {
"version": "7946.0.10",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
"integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==",
"dev": true
},
"@types/leaflet": {
"version": "1.9.8",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.8.tgz",
"integrity": "sha512-EXdsL4EhoUtGm2GC2ZYtXn+Fzc6pluVgagvo2VC1RHWToLGlTRwVYoDpqS/7QXa01rmDyBjJk3Catpf60VMkwg==",
"dev": true,
"requires": {
"@types/geojson": "*"
}
},
"geojson": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/geojson/-/geojson-0.5.0.tgz",
"integrity": "sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ=="
},
"leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"leaflet-contextmenu": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/leaflet-contextmenu/-/leaflet-contextmenu-1.4.0.tgz",
"integrity": "sha512-BXASCmJ5bLkuJGDCpWmvGqhZi5AzeOY0IbQalfkgBcMAMfAOFSvD4y0gIQxF/XzEyLkjXaRiUpibVj4+Cf3tUA=="
}
}
}

View file

@ -1,10 +1,11 @@
{
"devDependencies": {
"@types/leaflet": "^1.7.11"
},
"dependencies": {
"geojson": "^0.5.0",
"leaflet": "^1.8.0",
"leaflet-contextmenu": "^1.4.0"
}
"devDependencies": {
"@types/leaflet": "^1.9.8"
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"geojson": "^0.5.0",
"leaflet": "^1.9.4",
"leaflet-contextmenu": "^1.4.0"
}
}

View file

@ -11,7 +11,7 @@
],
"types": [
"leaflet",
"geojson",
// "geojson",
],
"moduleResolution": "node"
},