Compare commits
34 commits
Author | SHA1 | Date | |
---|---|---|---|
f1ed826eee | |||
f1e5388db1 | |||
ba4086bac9 | |||
067dae1356 | |||
0c8d70d6e1 | |||
e8bb51d388 | |||
b38eabb27b | |||
1e7718865c | |||
44fc06cd4b | |||
f700b484be | |||
0cec19e682 | |||
3be7f2795f | |||
97a8e9a2ba | |||
63738518a3 | |||
3dfdc4c44e | |||
a30cb5087b | |||
d76e7a49ed | |||
e53a8ba92b | |||
4421dec657 | |||
36b80bbbd9 | |||
4cb4a34f39 | |||
c94915e351 | |||
12ee90c793 | |||
7758385b51 | |||
ad184190cd | |||
5c38036ef1 | |||
1765d390bd | |||
381d535540 | |||
77bcbebacc | |||
166e6c1738 | |||
67db47f053 | |||
fc939c7f9b | |||
863fcabf0f | |||
f993c3a4af |
65 changed files with 2562 additions and 2391 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
||||||
/target
|
/target
|
||||||
|
*.snap.new
|
||||||
|
*.pending-snap
|
||||||
|
|
|
@ -11,3 +11,12 @@ repos:
|
||||||
args: ["--all"]
|
args: ["--all"]
|
||||||
- id: cargo-clippy
|
- id: cargo-clippy
|
||||||
args: ["--all", "--all-features", "--", "-D", "warnings"]
|
args: ["--all", "--all-features", "--", "-D", "warnings"]
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ui-menu
|
||||||
|
name: ui/menu lint+fmt
|
||||||
|
language: system
|
||||||
|
files: ^ui/menu/
|
||||||
|
pass_filenames: false
|
||||||
|
entry: npm run --prefix ui/menu pc
|
||||||
|
|
|
@ -4,8 +4,9 @@ pipeline:
|
||||||
environment:
|
environment:
|
||||||
- VERSION=${CI_COMMIT_TAG}
|
- VERSION=${CI_COMMIT_TAG}
|
||||||
commands:
|
commands:
|
||||||
- npm ci --prefix ui/talon-client
|
- npm install --prefix ui/talon-client
|
||||||
- npm ci --prefix ui/menu
|
- npm install --prefix ui/menu
|
||||||
|
- npm run ci --prefix ui/menu
|
||||||
- npm run build --prefix ui/menu
|
- npm run build --prefix ui/menu
|
||||||
|
|
||||||
test-server:
|
test-server:
|
||||||
|
@ -48,6 +49,16 @@ pipeline:
|
||||||
event: tag
|
event: tag
|
||||||
tag: v*
|
tag: v*
|
||||||
|
|
||||||
|
compress-release:
|
||||||
|
image: debian:latest
|
||||||
|
commands:
|
||||||
|
- mkdir target/upload
|
||||||
|
- gzip -c target/x86_64-unknown-linux-musl/release/talon > target/upload/talon-amd64.gz
|
||||||
|
- gzip -c target/aarch64-unknown-linux-musl/release/talon > target/upload/talon-aarch64.gz
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
tag: v*
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
group: publish
|
group: publish
|
||||||
image: woodpeckerci/plugin-gitea-release:latest
|
image: woodpeckerci/plugin-gitea-release:latest
|
||||||
|
@ -57,6 +68,9 @@ pipeline:
|
||||||
from_secret: GITEA_KEY
|
from_secret: GITEA_KEY
|
||||||
title: "Talon ${CI_COMMIT_TAG}"
|
title: "Talon ${CI_COMMIT_TAG}"
|
||||||
note: "./target/CHANGES.md"
|
note: "./target/CHANGES.md"
|
||||||
|
files:
|
||||||
|
- "target/upload/talon-amd64.gz"
|
||||||
|
- "target/upload/talon-aarch64.gz"
|
||||||
when:
|
when:
|
||||||
event: tag
|
event: tag
|
||||||
tag: v*
|
tag: v*
|
||||||
|
|
72
CHANGELOG.md
72
CHANGELOG.md
|
@ -2,6 +2,78 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.4.2] - 2023-07-22
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Container entrypoint
|
||||||
|
- Website version ordering
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Fix npm pre-commit hook
|
||||||
|
|
||||||
|
## [0.4.1] - 2023-04-05
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Stop propagation of key events on menu search
|
||||||
|
- Use sh
|
||||||
|
- Remove version prefix from "latest" tag
|
||||||
|
- Detect CI commit SHA
|
||||||
|
- Use better abbreviations for page names
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add upload script
|
||||||
|
|
||||||
|
## [0.4.0] - 2023-04-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- [**breaking**] Switch database format to CBOR (not compatible with previous format)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Hide version-bump messages
|
||||||
|
|
||||||
|
## [0.3.0] - 2023-04-02
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Move menu bar down
|
||||||
|
- Set icon size to 48px
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add last-modified date to all GET responses
|
||||||
|
- Add last-version tag to PageInfoModal
|
||||||
|
- Purge file storage daily
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Update cargo dependencies
|
||||||
|
- Bump version -> 0.3.0
|
||||||
|
|
||||||
|
## [0.2.0] - 2023-04-01
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Revert svelte-preprocess update
|
||||||
|
- Delete versions from db when upload failed
|
||||||
|
- Change version info icon
|
||||||
|
- Smaller menu button on mobile
|
||||||
|
- Release script
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add instance info dialog
|
||||||
|
- Add website icons
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Publish release on gitea
|
||||||
|
|
||||||
## [0.1.0] - 2023-03-31
|
## [0.1.0] - 2023-03-31
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
1478
Cargo.lock
generated
1478
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "talon"
|
name = "talon"
|
||||||
version = "0.1.0"
|
version = "0.4.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -10,13 +10,13 @@ build = "build.rs"
|
||||||
default-run = "talon"
|
default-run = "talon"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
poem = "1.3.55"
|
poem = { version = "1.3.55", features = ["static-files"] }
|
||||||
poem-openapi = { version = "2.0.26", features = ["time", "swagger-ui"] }
|
poem-openapi = { version = "2.0.26", features = ["time", "swagger-ui"] }
|
||||||
tokio = { version = "1.25.0", features = ["rt-multi-thread", "fs", "signal"] }
|
tokio = { version = "1.25.0", features = ["rt-multi-thread", "fs", "signal"] }
|
||||||
sled = "0.34.7"
|
sled = "0.34.7"
|
||||||
serde = "1.0.152"
|
serde = "1.0.152"
|
||||||
serde_json = "1.0.93"
|
serde_json = "1.0.93"
|
||||||
rmp-serde = "1.1.1"
|
serde_cbor = "0.11.2"
|
||||||
toml = "0.7.2"
|
toml = "0.7.2"
|
||||||
thiserror = "1.0.38"
|
thiserror = "1.0.38"
|
||||||
time = { version = "0.3.15", features = [
|
time = { version = "0.3.15", features = [
|
||||||
|
@ -27,7 +27,7 @@ time = { version = "0.3.15", features = [
|
||||||
httpdate = "1.0.2"
|
httpdate = "1.0.2"
|
||||||
sha2 = "0.10.6"
|
sha2 = "0.10.6"
|
||||||
path_macro = "1.0.0"
|
path_macro = "1.0.0"
|
||||||
hex-literal = "0.3.4"
|
hex-literal = "0.4.0"
|
||||||
hex = { version = "0.4.3", features = ["serde"] }
|
hex = { version = "0.4.3", features = ["serde"] }
|
||||||
temp-dir = "0.1.11"
|
temp-dir = "0.1.11"
|
||||||
zip = { version = "0.6.4", default-features = false, features = [
|
zip = { version = "0.6.4", default-features = false, features = [
|
||||||
|
@ -53,6 +53,8 @@ clap = { version = "4.1.8", features = ["derive"] }
|
||||||
shadow-rs = "0.21.0"
|
shadow-rs = "0.21.0"
|
||||||
walkdir = "2.3.2"
|
walkdir = "2.3.2"
|
||||||
rust-embed = { version = "6.6.1", features = ["poem-ex"] }
|
rust-embed = { version = "6.6.1", features = ["poem-ex"] }
|
||||||
|
image = "0.24.6"
|
||||||
|
clokwerk = { version = "0.4.0", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.17.0"
|
rstest = "0.17.0"
|
||||||
|
|
9
Justfile
9
Justfile
|
@ -18,12 +18,13 @@ oai-doc:
|
||||||
cargo run --bin openapi
|
cargo run --bin openapi
|
||||||
|
|
||||||
# Generate the JS API client
|
# Generate the JS API client
|
||||||
oai-client:
|
oai-client: && npm-setup
|
||||||
|
rm -rf ui/menu/node_modules/talon-client
|
||||||
openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ui/talon-client -p "npmName=talon-client"
|
openapi-generator-cli generate -i openapi.json -g typescript-fetch -o ui/talon-client -p "npmName=talon-client"
|
||||||
|
|
||||||
npm-setup:
|
npm-setup:
|
||||||
npm ci --prefix ui/talon-client
|
npm install --prefix ui/talon-client
|
||||||
npm ci --prefix ui/menu
|
npm install --prefix ui/menu
|
||||||
|
|
||||||
# Start the dev server for the sidebar menu
|
# Start the dev server for the sidebar menu
|
||||||
menu-dev:
|
menu-dev:
|
||||||
|
@ -37,7 +38,7 @@ release:
|
||||||
if [ -n "$(git status --porcelain)" ]; then echo "Workdir must be clean"; exit 1; fi
|
if [ -n "$(git status --porcelain)" ]; then echo "Workdir must be clean"; exit 1; fi
|
||||||
if git rev-parse "v{{version}}" >/dev/null 2>&1; then echo "Version tag v{{version}} already exists"; exit 1; fi
|
if git rev-parse "v{{version}}" >/dev/null 2>&1; then echo "Version tag v{{version}} already exists"; exit 1; fi
|
||||||
|
|
||||||
if `file CHANGELOG.md`; then git-cliff --tag "v{{version}}" --unreleased --prepend CHANGELOG.md; else git-cliff --tag "v{{version}}" --unreleased --output CHANGELOG.md; fi
|
if [ -f "CHANGELOG.md" ]; then git-cliff --tag "v{{version}}" --unreleased --prepend CHANGELOG.md; else git-cliff --tag "v{{version}}" --unreleased --output CHANGELOG.md; fi
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md
|
||||||
git commit -m "chore(release): prepare for v{{version}}"
|
git commit -m "chore(release): prepare for v{{version}}"
|
||||||
|
|
||||||
|
|
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7 KiB |
|
@ -48,7 +48,7 @@ commit_parsers = [
|
||||||
{ message = "^refactor", group = "Refactor"},
|
{ message = "^refactor", group = "Refactor"},
|
||||||
{ message = "^style", group = "Styling"},
|
{ message = "^style", group = "Styling"},
|
||||||
{ message = "^test", group = "Testing"},
|
{ message = "^test", group = "Testing"},
|
||||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
{ message = "^chore\\(release\\):", skip = true},
|
||||||
{ message = "(^chore)|(^ci)", group = "Miscellaneous Tasks"},
|
{ message = "(^chore)|(^ci)", group = "Miscellaneous Tasks"},
|
||||||
{ body = ".*security", group = "Security"},
|
{ body = ".*security", group = "Security"},
|
||||||
]
|
]
|
||||||
|
|
81
openapi.json
81
openapi.json
|
@ -135,6 +135,70 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/website/{subdomain}/icon": {
|
||||||
|
"put": {
|
||||||
|
"summary": "Upload a website icon",
|
||||||
|
"description": "Supported image formats: png, jpeg, gif, bmp, ico, tiff,\nwebp, pnm, dds, tga, openexr, farbfeld\n\nMaximum upload size: 5MB, 4000 pixels\n\nIcons are resized to 32x32 pixels.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subdomain",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"deprecated": false,
|
||||||
|
"explode": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuthorization": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"summary": "Delete a website icon",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "subdomain",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"deprecated": false,
|
||||||
|
"explode": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuthorization": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/websites": {
|
"/websites": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Get all publicly listed websites",
|
"summary": "Get all publicly listed websites",
|
||||||
|
@ -522,7 +586,7 @@
|
||||||
"required": [
|
"required": [
|
||||||
"stats",
|
"stats",
|
||||||
"version",
|
"version",
|
||||||
"uptime"
|
"start_time"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"stats": {
|
"stats": {
|
||||||
|
@ -545,10 +609,10 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"uptime": {
|
"start_time": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"format": "uint64",
|
"format": "date-time",
|
||||||
"description": "Current uptime of the server in seconds"
|
"description": "Start time of the server"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -693,7 +757,8 @@
|
||||||
"subdomain",
|
"subdomain",
|
||||||
"name",
|
"name",
|
||||||
"created_at",
|
"created_at",
|
||||||
"visibility"
|
"visibility",
|
||||||
|
"has_icon"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"subdomain": {
|
"subdomain": {
|
||||||
|
@ -741,6 +806,10 @@
|
||||||
"description": "Icon for the source link"
|
"description": "Icon for the source link"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"has_icon": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Does the website have an icon?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -41,7 +41,8 @@ for arch in "${ARCHITECTURES[@]}"; do
|
||||||
|
|
||||||
# Finalize container
|
# Finalize container
|
||||||
buildah umount "$container"
|
buildah umount "$container"
|
||||||
buildah config --entrypoint "/talon" --cmd "run -d /data" --arch "$arch" --port 3000 --author "ThetaDev" "$container"
|
# entrypoint syntax: see issue https://github.com/containers/buildah/issues/1768
|
||||||
|
buildah config --entrypoint '["/talon"]' --cmd "run -d /data" --arch "$arch" --port 3000 --author "ThetaDev" "$container"
|
||||||
buildah commit "$container" "$IMAGE:$arch-$TAG"
|
buildah commit "$container" "$IMAGE:$arch-$TAG"
|
||||||
|
|
||||||
buildah manifest add "$REGISTRY/$IMAGE:$TAG" "$IMAGE:$arch-$TAG"
|
buildah manifest add "$REGISTRY/$IMAGE:$TAG" "$IMAGE:$arch-$TAG"
|
||||||
|
|
75
scripts/upload.sh
Executable file
75
scripts/upload.sh
Executable file
|
@ -0,0 +1,75 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check for dependencies
|
||||||
|
which curl > /dev/null
|
||||||
|
which jq > /dev/null
|
||||||
|
|
||||||
|
# Assert required variables
|
||||||
|
if [ -z "$TALON_KEY" ]; then echo "TALON_KEY unset"; exit 1; fi
|
||||||
|
if [ -z "$TALON_URL" ]; then echo "TALON_URL unset"; exit 1; fi
|
||||||
|
if [ -z "$SUBDOMAIN" ]; then echo "SUBDOMAIN unset"; exit 1; fi
|
||||||
|
|
||||||
|
API_URL="$TALON_URL/api"
|
||||||
|
API_KEY_H="x-api-key: $TALON_KEY"
|
||||||
|
|
||||||
|
# Check if the website already exists
|
||||||
|
WEBSITE_STATUS=$(curl --head -o /dev/null -s -w "%{http_code}" "$API_URL/website/$SUBDOMAIN")
|
||||||
|
if [ "$WEBSITE_STATUS" = "200" ]; then
|
||||||
|
echo "Website '$SUBDOMAIN' found"
|
||||||
|
else
|
||||||
|
# Create the website if it does not exist
|
||||||
|
if [ -z "$WEBSITE_NAME" ]; then echo "WEBSITE_NAME unset"; exit 1; fi
|
||||||
|
|
||||||
|
CREATE_BODY=$(jq -c --null-input --arg name "$WEBSITE_NAME" --arg color "$WEBSITE_COLOR" \
|
||||||
|
--arg visibility "$WEBSITE_VISIBILITY" --arg source_url "$WEBSITE_SOURCE_URL" \
|
||||||
|
--arg source_icon "$WEBSITE_SOURCE_ICON" \
|
||||||
|
'{"name": $name, "color": $color, "visibility": $visibility, "source_url": $source_url, "source_icon": $source_icon} | delpaths([path(.[]| select(.==""))])')
|
||||||
|
echo "Creating website '$SUBDOMAIN': $CREATE_BODY"
|
||||||
|
|
||||||
|
curl -Ss --fail -X "PUT" -H "$API_KEY_H" -H "content-type: application/json" --data "$CREATE_BODY" "$API_URL/website/$SUBDOMAIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check the upload directory
|
||||||
|
if [ ! -d "$1" ]; then echo "Upload directory does not exist"; exit 1; fi
|
||||||
|
if [ ! -f "$1/index.html" ]; then echo "Upload directory does not contain index.html"; exit 1; fi
|
||||||
|
|
||||||
|
# Validate fallback page param
|
||||||
|
if [ "$FALLBACK" ] && [ ! -f "$1/$FALLBACK" ]; then echo "fallback page $FALLBACK does not exist"; exit 1; fi
|
||||||
|
|
||||||
|
# Automatically detect fallback pages
|
||||||
|
if [ -z "$SPA" ] && [ -z "$FALLBACK" ]; then
|
||||||
|
if [ -f "$1/404.html" ]; then FALLBACK="404.html"; fi
|
||||||
|
if [ -f "$1/200.html" ]; then SPA=true; FALLBACK="200.html"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
push_arg() {
|
||||||
|
if [ "$UPLOAD_ARGS" ]; then UPLOAD_ARGS="$UPLOAD_ARGS&"; fi
|
||||||
|
UPLOAD_ARGS="$UPLOAD_ARGS$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$FALLBACK" ]; then push_arg "fallback=$FALLBACK"; fi
|
||||||
|
if [ "$SPA" = "true" ]; then push_arg "spa=true"; fi
|
||||||
|
|
||||||
|
if [ "$UPLOAD_ARGS" ]; then UPLOAD_ARGS="?$UPLOAD_ARGS"; fi
|
||||||
|
|
||||||
|
if [ "$CI_COMMIT_SHA" ]; then
|
||||||
|
echo "Git commit: $CI_COMMIT_SHA"
|
||||||
|
push_arg "commit=$CI_COMMIT_SHA"
|
||||||
|
elif GIT_COMMIT=$(git rev-parse HEAD 2> /dev/null); then
|
||||||
|
echo "Git commit: $GIT_COMMIT"
|
||||||
|
push_arg "commit=$GIT_COMMIT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress website
|
||||||
|
ARCHIVE=$(mktemp)
|
||||||
|
tar -cz --directory "$1" --file "$ARCHIVE" .
|
||||||
|
|
||||||
|
# Upload website
|
||||||
|
echo "Version params: $UPLOAD_ARGS"
|
||||||
|
echo "Uploading..."
|
||||||
|
curl --fail -X "POST" -H "$API_KEY_H" -H "content-type: application/octet-stream" --data-binary "@$ARCHIVE" "$API_URL/website/$SUBDOMAIN/upload$UPLOAD_ARGS"
|
||||||
|
|
||||||
|
rm "$ARCHIVE"
|
||||||
|
|
||||||
|
echo "Website uploaded ;-)"
|
201
src/api.rs
201
src/api.rs
|
@ -63,6 +63,8 @@ pub enum ApiError {
|
||||||
InvalidArchiveType,
|
InvalidArchiveType,
|
||||||
#[error("invalid color")]
|
#[error("invalid color")]
|
||||||
InvalidColor,
|
InvalidColor,
|
||||||
|
#[error("join error: {0}")]
|
||||||
|
TokioJoin(#[from] tokio::task::JoinError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseError for ApiError {
|
impl ResponseError for ApiError {
|
||||||
|
@ -72,7 +74,8 @@ impl ResponseError for ApiError {
|
||||||
| ApiError::InvalidFallback(_)
|
| ApiError::InvalidFallback(_)
|
||||||
| ApiError::InvalidArchiveType
|
| ApiError::InvalidArchiveType
|
||||||
| ApiError::InvalidColor => StatusCode::BAD_REQUEST,
|
| ApiError::InvalidColor => StatusCode::BAD_REQUEST,
|
||||||
ApiError::NoAccess => StatusCode::UNAUTHORIZED,
|
ApiError::NoAccess => StatusCode::FORBIDDEN,
|
||||||
|
ApiError::TokioJoin(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -99,11 +102,17 @@ impl TalonApi {
|
||||||
&self,
|
&self,
|
||||||
talon: Data<&Talon>,
|
talon: Data<&Talon>,
|
||||||
subdomain: Path<String>,
|
subdomain: Path<String>,
|
||||||
) -> Result<Json<Website>> {
|
) -> Result<Response<Json<Website>>> {
|
||||||
talon
|
talon
|
||||||
.db
|
.db
|
||||||
.get_website(&subdomain)
|
.get_website(&subdomain)
|
||||||
.map(|w| Json(Website::from((subdomain.0, w))))
|
.map(|website| {
|
||||||
|
let modified = website.updated_at;
|
||||||
|
Response::new(Json(Website::from((subdomain.0, website)))).header(
|
||||||
|
header::LAST_MODIFIED,
|
||||||
|
httpdate::fmt_http_date(modified.into()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,6 +153,64 @@ impl TalonApi {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a website icon
|
||||||
|
///
|
||||||
|
/// Supported image formats: png, jpeg, gif, bmp, ico, tiff,
|
||||||
|
/// webp, pnm, dds, tga, openexr, farbfeld
|
||||||
|
///
|
||||||
|
/// Maximum upload size: 5MB, 4000 pixels
|
||||||
|
///
|
||||||
|
/// Icons are resized to 32x32 pixels.
|
||||||
|
#[oai(path = "/website/:subdomain/icon", method = "put")]
|
||||||
|
async fn website_add_icon(
|
||||||
|
&self,
|
||||||
|
auth: ApiKeyAuthorization,
|
||||||
|
talon: Data<&Talon>,
|
||||||
|
subdomain: Path<String>,
|
||||||
|
/// Icon data.
|
||||||
|
data: Binary<Vec<u8>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
||||||
|
|
||||||
|
let t2 = talon.clone();
|
||||||
|
let sd = subdomain.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
t2.icons.insert_icon(Cursor::new(data.as_slice()), &sd)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)??;
|
||||||
|
|
||||||
|
talon.db.update_website(
|
||||||
|
&subdomain,
|
||||||
|
db::model::WebsiteUpdate {
|
||||||
|
has_icon: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a website icon
|
||||||
|
#[oai(path = "/website/:subdomain/icon", method = "delete")]
|
||||||
|
async fn website_delete_icon(
|
||||||
|
&self,
|
||||||
|
auth: ApiKeyAuthorization,
|
||||||
|
talon: Data<&Talon>,
|
||||||
|
subdomain: Path<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
auth.check_subdomain(&subdomain, Access::Modify)?;
|
||||||
|
|
||||||
|
talon.icons.delete_icon(&subdomain)?;
|
||||||
|
talon.db.update_website(
|
||||||
|
&subdomain,
|
||||||
|
db::model::WebsiteUpdate {
|
||||||
|
has_icon: Some(false),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete website
|
/// Delete website
|
||||||
#[oai(path = "/website/:subdomain", method = "delete")]
|
#[oai(path = "/website/:subdomain", method = "delete")]
|
||||||
async fn website_delete(
|
async fn website_delete(
|
||||||
|
@ -199,7 +266,8 @@ impl TalonApi {
|
||||||
/// Mimimum visibility of the websites
|
/// Mimimum visibility of the websites
|
||||||
#[oai(default)]
|
#[oai(default)]
|
||||||
visibility: Query<Visibility>,
|
visibility: Query<Visibility>,
|
||||||
) -> Result<Json<Vec<Website>>> {
|
) -> Result<Response<Json<Vec<Website>>>> {
|
||||||
|
let modified = talon.db.websites_last_update()?;
|
||||||
talon
|
talon
|
||||||
.db
|
.db
|
||||||
.get_websites()
|
.get_websites()
|
||||||
|
@ -217,7 +285,10 @@ impl TalonApi {
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map(Json)
|
.map(|data| {
|
||||||
|
Response::new(Json(data))
|
||||||
|
.header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
|
||||||
|
})
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,15 +298,19 @@ impl TalonApi {
|
||||||
&self,
|
&self,
|
||||||
talon: Data<&Talon>,
|
talon: Data<&Talon>,
|
||||||
subdomain: Path<String>,
|
subdomain: Path<String>,
|
||||||
) -> Result<Json<Vec<Version>>> {
|
) -> Result<Response<Json<Vec<Version>>>> {
|
||||||
talon.db.website_exists(&subdomain)?;
|
let website = talon.db.get_website(&subdomain)?;
|
||||||
talon
|
let mut versions = talon
|
||||||
.db
|
.db
|
||||||
.get_website_versions(&subdomain)
|
.get_website_versions(&subdomain)
|
||||||
.map(|r| r.map(Version::from))
|
.map(|r| r.map(Version::from))
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
.map(Json)
|
versions.sort_by_key(|v| v.id);
|
||||||
.map_err(Error::from)
|
|
||||||
|
Ok(Response::new(Json(versions)).header(
|
||||||
|
header::LAST_MODIFIED,
|
||||||
|
httpdate::fmt_http_date(website.updated_at.into()),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get version
|
/// Get version
|
||||||
|
@ -245,11 +320,17 @@ impl TalonApi {
|
||||||
talon: Data<&Talon>,
|
talon: Data<&Talon>,
|
||||||
subdomain: Path<String>,
|
subdomain: Path<String>,
|
||||||
version: Path<u32>,
|
version: Path<u32>,
|
||||||
) -> Result<Json<Version>> {
|
) -> Result<Response<Json<Version>>> {
|
||||||
talon
|
talon
|
||||||
.db
|
.db
|
||||||
.get_version(&subdomain, *version)
|
.get_version(&subdomain, *version)
|
||||||
.map(|v| Json(Version::from((*version, v))))
|
.map(|v| {
|
||||||
|
let create_date = v.created_at;
|
||||||
|
Response::new(Json(Version::from((*version, v)))).header(
|
||||||
|
header::LAST_MODIFIED,
|
||||||
|
httpdate::fmt_http_date(create_date.into()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,14 +341,19 @@ impl TalonApi {
|
||||||
talon: Data<&Talon>,
|
talon: Data<&Talon>,
|
||||||
subdomain: Path<String>,
|
subdomain: Path<String>,
|
||||||
version: Path<u32>,
|
version: Path<u32>,
|
||||||
) -> Result<Json<Vec<VersionFile>>> {
|
) -> Result<Response<Json<Vec<VersionFile>>>> {
|
||||||
talon.db.version_exists(&subdomain, *version)?;
|
let v = talon.db.get_version(&subdomain, *version)?;
|
||||||
talon
|
talon
|
||||||
.db
|
.db
|
||||||
.get_version_files(&subdomain, *version)
|
.get_version_files(&subdomain, *version)
|
||||||
.map(|r| r.map(VersionFile::from))
|
.map(|r| r.map(VersionFile::from))
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
.map(Json)
|
.map(|r| {
|
||||||
|
Response::new(Json(r)).header(
|
||||||
|
header::LAST_MODIFIED,
|
||||||
|
httpdate::fmt_http_date(v.created_at.into()),
|
||||||
|
)
|
||||||
|
})
|
||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,39 +411,66 @@ impl TalonApi {
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if data.starts_with(&hex!("1f8b")) {
|
// Try to store the uploaded website
|
||||||
talon
|
// If this fails, the new version needs to be deleted
|
||||||
.storage
|
fn try_insert(
|
||||||
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
|
talon: &Talon,
|
||||||
} else if data.starts_with(&hex!("504b0304")) {
|
data: Binary<Vec<u8>>,
|
||||||
talon
|
subdomain: &str,
|
||||||
.storage
|
version: u32,
|
||||||
.insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?;
|
fallback: Option<String>,
|
||||||
} else {
|
) -> Result<()> {
|
||||||
return Err(ApiError::InvalidArchiveType.into());
|
if data.starts_with(&hex!("1f8b")) {
|
||||||
}
|
|
||||||
|
|
||||||
// Validata fallback path
|
|
||||||
if let Some(fallback) = &fallback.0 {
|
|
||||||
if let Err(e) =
|
|
||||||
talon
|
talon
|
||||||
.storage
|
.storage
|
||||||
.get_file(&subdomain, version, fallback, &Default::default())
|
.insert_tgz_archive(data.as_slice(), subdomain, version)?;
|
||||||
{
|
} else if data.starts_with(&hex!("504b0304")) {
|
||||||
// Remove the bad version
|
talon.storage.insert_zip_archive(
|
||||||
let _ = talon.db.delete_version(&subdomain, version, false);
|
Cursor::new(data.as_slice()),
|
||||||
return Err(ApiError::InvalidFallback(e.to_string()).into());
|
subdomain,
|
||||||
|
version,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
return Err(Error::from(ApiError::InvalidArchiveType));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validata fallback path
|
||||||
|
if let Some(fallback) = &fallback {
|
||||||
|
if let Err(e) =
|
||||||
|
talon
|
||||||
|
.storage
|
||||||
|
.get_file(subdomain, version, fallback, &Default::default())
|
||||||
|
{
|
||||||
|
return Err(Error::from(ApiError::InvalidFallback(e.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
talon.db.update_website(
|
let t2 = talon.clone();
|
||||||
&subdomain,
|
let sd = subdomain.clone();
|
||||||
db::model::WebsiteUpdate {
|
|
||||||
latest_version: Some(Some(version)),
|
match tokio::task::spawn_blocking(move || try_insert(&t2, data, &sd, version, fallback.0))
|
||||||
..Default::default()
|
.await
|
||||||
},
|
.map_err(|e| Error::from(ApiError::from(e)))
|
||||||
)?;
|
{
|
||||||
Ok(())
|
Ok(Ok(())) => {
|
||||||
|
talon.db.update_website(
|
||||||
|
&subdomain,
|
||||||
|
db::model::WebsiteUpdate {
|
||||||
|
latest_version: Some(Some(version)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) | Ok(Err(e)) => {
|
||||||
|
// Remove the bad version and decrement the id counter
|
||||||
|
let _ = talon.db.delete_version(&subdomain, version, false);
|
||||||
|
let _ = talon.db.decrement_vid(&subdomain, version);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a file
|
/// Retrieve a file
|
||||||
|
|
|
@ -80,10 +80,26 @@ impl Config {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ServerCfg {
|
pub struct ServerCfg {
|
||||||
|
/// Address to bind the server to
|
||||||
|
///
|
||||||
|
/// Default: `0.0.0.0:3000`
|
||||||
pub address: String,
|
pub address: String,
|
||||||
|
/// Root domain (and port if non-standard) under which Talon is available
|
||||||
|
///
|
||||||
|
/// Default: `localhost:3000`
|
||||||
pub root_domain: String,
|
pub root_domain: String,
|
||||||
|
/// Subdomain used for Talon internals (API, assets)
|
||||||
|
///
|
||||||
|
/// Default: `talon`
|
||||||
pub internal_subdomain: String,
|
pub internal_subdomain: String,
|
||||||
|
/// URL under which the internals are available
|
||||||
|
///
|
||||||
|
/// Default: `http://talon.localhost:3000`
|
||||||
pub internal_url: String,
|
pub internal_url: String,
|
||||||
|
/// Interval in minutes between file storage purges
|
||||||
|
///
|
||||||
|
/// Default: 1440 (24h)
|
||||||
|
pub purge_interval: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerCfg {
|
impl Default for ServerCfg {
|
||||||
|
@ -93,6 +109,7 @@ impl Default for ServerCfg {
|
||||||
root_domain: "localhost:3000".to_owned(),
|
root_domain: "localhost:3000".to_owned(),
|
||||||
internal_subdomain: "talon".to_owned(),
|
internal_subdomain: "talon".to_owned(),
|
||||||
internal_url: "http://talon.localhost:3000".to_owned(),
|
internal_url: "http://talon.localhost:3000".to_owned(),
|
||||||
|
purge_interval: 1440,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,10 +30,8 @@ struct DbInner {
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
#[error("sled db error: {0}")]
|
#[error("sled db error: {0}")]
|
||||||
Sled(#[from] sled::Error),
|
Sled(#[from] sled::Error),
|
||||||
#[error("msgpack serialization error: {0}")]
|
#[error("cbor serialization error: {0}")]
|
||||||
Serialize(#[from] rmp_serde::encode::Error),
|
Serialize(#[from] serde_cbor::Error),
|
||||||
#[error("msgpack deserialization error: {0}")]
|
|
||||||
Deserialize(#[from] rmp_serde::decode::Error),
|
|
||||||
#[error("json serialization error: {0}")]
|
#[error("json serialization error: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error("{0} with id `{1}` already exists")]
|
#[error("{0} with id `{1}` already exists")]
|
||||||
|
@ -100,7 +98,7 @@ impl Db {
|
||||||
for item in self.i.websites.iter() {
|
for item in self.i.websites.iter() {
|
||||||
let (k, v) = item?;
|
let (k, v) = item?;
|
||||||
let key = Self::key_to_string(k.to_vec())?;
|
let key = Self::key_to_string(k.to_vec())?;
|
||||||
let value = rmp_serde::from_slice::<Website>(&v)?;
|
let value = serde_cbor::from_slice::<Website>(&v)?;
|
||||||
|
|
||||||
let dataset = ExportDataset::Website { key, value };
|
let dataset = ExportDataset::Website { key, value };
|
||||||
|
|
||||||
|
@ -111,7 +109,7 @@ impl Db {
|
||||||
for item in self.i.versions.iter() {
|
for item in self.i.versions.iter() {
|
||||||
let (k, v) = item?;
|
let (k, v) = item?;
|
||||||
let key = Self::key_to_string(k.to_vec())?;
|
let key = Self::key_to_string(k.to_vec())?;
|
||||||
let value = rmp_serde::from_slice::<Version>(&v)?;
|
let value = serde_cbor::from_slice::<Version>(&v)?;
|
||||||
|
|
||||||
let dataset = ExportDataset::Version { key, value };
|
let dataset = ExportDataset::Version { key, value };
|
||||||
serde_json::to_writer(&mut writer, &dataset)?;
|
serde_json::to_writer(&mut writer, &dataset)?;
|
||||||
|
@ -152,11 +150,11 @@ impl Db {
|
||||||
fn import_dataset(&self, ds: ExportDataset) -> Result<()> {
|
fn import_dataset(&self, ds: ExportDataset) -> Result<()> {
|
||||||
match ds {
|
match ds {
|
||||||
ExportDataset::Website { key, value } => {
|
ExportDataset::Website { key, value } => {
|
||||||
let data = rmp_serde::to_vec(&value)?;
|
let data = serde_cbor::to_vec(&value)?;
|
||||||
self.i.websites.insert(key, data)?;
|
self.i.websites.insert(key, data)?;
|
||||||
}
|
}
|
||||||
ExportDataset::Version { key, value } => {
|
ExportDataset::Version { key, value } => {
|
||||||
let data = rmp_serde::to_vec(&value)?;
|
let data = serde_cbor::to_vec(&value)?;
|
||||||
self.i.versions.insert(key, data)?;
|
self.i.versions.insert(key, data)?;
|
||||||
}
|
}
|
||||||
ExportDataset::File { key, value } => {
|
ExportDataset::File { key, value } => {
|
||||||
|
@ -178,13 +176,13 @@ impl Db {
|
||||||
/// Get a website from the database
|
/// Get a website from the database
|
||||||
pub fn get_website(&self, subdomain: &str) -> Result<Website> {
|
pub fn get_website(&self, subdomain: &str) -> Result<Website> {
|
||||||
let data = self.i.websites.get(subdomain)?;
|
let data = self.i.websites.get(subdomain)?;
|
||||||
data.and_then(|data| rmp_serde::from_slice::<Website>(data.as_ref()).ok())
|
data.and_then(|data| serde_cbor::from_slice::<Website>(data.as_ref()).ok())
|
||||||
.ok_or_else(|| DbError::NotExists("website", subdomain.to_owned()))
|
.ok_or_else(|| DbError::NotExists("website", subdomain.to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a new website into the database
|
/// Insert a new website into the database
|
||||||
pub fn insert_website(&self, subdomain: &str, website: &Website) -> Result<()> {
|
pub fn insert_website(&self, subdomain: &str, website: &Website) -> Result<()> {
|
||||||
let data = rmp_serde::to_vec(website)?;
|
let data = serde_cbor::to_vec(website)?;
|
||||||
self.i
|
self.i
|
||||||
.websites
|
.websites
|
||||||
.compare_and_swap(subdomain, None::<&[u8]>, Some(data))?
|
.compare_and_swap(subdomain, None::<&[u8]>, Some(data))?
|
||||||
|
@ -198,7 +196,7 @@ impl Db {
|
||||||
.i
|
.i
|
||||||
.websites
|
.websites
|
||||||
.update_and_fetch(subdomain, |data| match data {
|
.update_and_fetch(subdomain, |data| match data {
|
||||||
Some(data) => match rmp_serde::from_slice::<Website>(data) {
|
Some(data) => match serde_cbor::from_slice::<Website>(data) {
|
||||||
Ok(mut w) => {
|
Ok(mut w) => {
|
||||||
let website = website.clone();
|
let website = website.clone();
|
||||||
w.name = website.name.unwrap_or(w.name);
|
w.name = website.name.unwrap_or(w.name);
|
||||||
|
@ -207,8 +205,10 @@ impl Db {
|
||||||
w.visibility = website.visibility.unwrap_or(w.visibility);
|
w.visibility = website.visibility.unwrap_or(w.visibility);
|
||||||
w.source_url = website.source_url.unwrap_or(w.source_url);
|
w.source_url = website.source_url.unwrap_or(w.source_url);
|
||||||
w.source_icon = website.source_icon.unwrap_or(w.source_icon);
|
w.source_icon = website.source_icon.unwrap_or(w.source_icon);
|
||||||
|
w.has_icon = website.has_icon.unwrap_or(w.has_icon);
|
||||||
|
w.updated_at = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
rmp_serde::to_vec(&w).ok()
|
serde_cbor::to_vec(&w).ok()
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
},
|
},
|
||||||
|
@ -250,7 +250,7 @@ impl Db {
|
||||||
self.i.websites.iter().map(|r| {
|
self.i.websites.iter().map(|r| {
|
||||||
r.map_err(DbError::from).and_then(|(k, v)| {
|
r.map_err(DbError::from).and_then(|(k, v)| {
|
||||||
let subdomain = Self::key_to_string(k.to_vec())?;
|
let subdomain = Self::key_to_string(k.to_vec())?;
|
||||||
let website = rmp_serde::from_slice::<Website>(&v)?;
|
let website = serde_cbor::from_slice::<Website>(&v)?;
|
||||||
Ok((subdomain, website))
|
Ok((subdomain, website))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -299,7 +299,7 @@ impl Db {
|
||||||
let key = Self::version_key(subdomain, id);
|
let key = Self::version_key(subdomain, id);
|
||||||
|
|
||||||
let data = self.i.versions.get(&key)?;
|
let data = self.i.versions.get(&key)?;
|
||||||
data.and_then(|data| rmp_serde::from_slice::<Version>(data.as_ref()).ok())
|
data.and_then(|data| serde_cbor::from_slice::<Version>(data.as_ref()).ok())
|
||||||
.ok_or_else(|| DbError::NotExists("version", key))
|
.ok_or_else(|| DbError::NotExists("version", key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,16 +311,16 @@ impl Db {
|
||||||
.i
|
.i
|
||||||
.websites
|
.websites
|
||||||
.update_and_fetch(subdomain, |data| match data {
|
.update_and_fetch(subdomain, |data| match data {
|
||||||
Some(data) => match rmp_serde::from_slice::<Website>(data) {
|
Some(data) => match serde_cbor::from_slice::<Website>(data) {
|
||||||
Ok(mut w) => {
|
Ok(mut w) => {
|
||||||
w.vid_count += 1;
|
w.vid_count += 1;
|
||||||
rmp_serde::to_vec(&w).ok()
|
serde_cbor::to_vec(&w).ok()
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
},
|
},
|
||||||
None => None,
|
None => None,
|
||||||
})?
|
})?
|
||||||
.and_then(|data| rmp_serde::from_slice::<Website>(&data).ok());
|
.and_then(|data| serde_cbor::from_slice::<Website>(&data).ok());
|
||||||
|
|
||||||
let id = match ws {
|
let id = match ws {
|
||||||
Some(ws) => ws.vid_count,
|
Some(ws) => ws.vid_count,
|
||||||
|
@ -328,7 +328,7 @@ impl Db {
|
||||||
};
|
};
|
||||||
|
|
||||||
let key = Self::version_key(subdomain, id);
|
let key = Self::version_key(subdomain, id);
|
||||||
let data = rmp_serde::to_vec(version)?;
|
let data = serde_cbor::to_vec(version)?;
|
||||||
self.i
|
self.i
|
||||||
.versions
|
.versions
|
||||||
.compare_and_swap(&key, None::<&[u8]>, Some(data))?
|
.compare_and_swap(&key, None::<&[u8]>, Some(data))?
|
||||||
|
@ -336,6 +336,27 @@ impl Db {
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrement the version id counter after a failed insertion.
|
||||||
|
///
|
||||||
|
/// Does not decrement the counter if it does not equal the given value.
|
||||||
|
pub fn decrement_vid(&self, subdomain: &str, version: u32) -> Result<()> {
|
||||||
|
self.i
|
||||||
|
.websites
|
||||||
|
.update_and_fetch(subdomain, |data| match data {
|
||||||
|
Some(data) => match serde_cbor::from_slice::<Website>(data) {
|
||||||
|
Ok(mut w) => {
|
||||||
|
if w.vid_count == version {
|
||||||
|
w.vid_count -= 1;
|
||||||
|
}
|
||||||
|
serde_cbor::to_vec(&w).ok()
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// internal method for deleting a version from the database
|
/// internal method for deleting a version from the database
|
||||||
///
|
///
|
||||||
/// this method does not lock the db or update the associated website
|
/// this method does not lock the db or update the associated website
|
||||||
|
@ -395,7 +416,7 @@ impl Db {
|
||||||
self.i.versions.scan_prefix(key).map(|r| {
|
self.i.versions.scan_prefix(key).map(|r| {
|
||||||
r.map_err(DbError::from).and_then(|(k, v)| {
|
r.map_err(DbError::from).and_then(|(k, v)| {
|
||||||
let (_, id) = Self::split_version_key(k.to_vec())?;
|
let (_, id) = Self::split_version_key(k.to_vec())?;
|
||||||
let version = rmp_serde::from_slice::<Version>(&v)?;
|
let version = serde_cbor::from_slice::<Version>(&v)?;
|
||||||
Ok((id, version))
|
Ok((id, version))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -12,6 +12,8 @@ pub struct Website {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Website creation date
|
/// Website creation date
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
|
/// Website update date
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
/// Latest version ID
|
/// Latest version ID
|
||||||
pub latest_version: Option<u32>,
|
pub latest_version: Option<u32>,
|
||||||
/// Color of the page icon
|
/// Color of the page icon
|
||||||
|
@ -26,19 +28,25 @@ pub struct Website {
|
||||||
///
|
///
|
||||||
/// value + 1 will be the next version ID
|
/// value + 1 will be the next version ID
|
||||||
pub vid_count: u32,
|
pub vid_count: u32,
|
||||||
|
/// Does the website have an icon?
|
||||||
|
pub has_icon: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Website {
|
impl Default for Website {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let created_at = OffsetDateTime::now_utc();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
name: Default::default(),
|
name: Default::default(),
|
||||||
created_at: OffsetDateTime::now_utc(),
|
created_at,
|
||||||
|
updated_at: created_at,
|
||||||
latest_version: Default::default(),
|
latest_version: Default::default(),
|
||||||
color: Default::default(),
|
color: Default::default(),
|
||||||
visibility: Default::default(),
|
visibility: Default::default(),
|
||||||
source_url: Default::default(),
|
source_url: Default::default(),
|
||||||
source_icon: Default::default(),
|
source_icon: Default::default(),
|
||||||
vid_count: Default::default(),
|
vid_count: Default::default(),
|
||||||
|
has_icon: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,6 +68,8 @@ pub struct WebsiteUpdate {
|
||||||
pub source_url: Option<Option<String>>,
|
pub source_url: Option<Option<String>>,
|
||||||
/// Icon for the source link
|
/// Icon for the source link
|
||||||
pub source_icon: Option<Option<SourceIcon>>,
|
pub source_icon: Option<Option<SourceIcon>>,
|
||||||
|
/// Does the website have an icon?
|
||||||
|
pub has_icon: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Website version stored in the database
|
/// Website version stored in the database
|
||||||
|
|
110
src/icons.rs
Normal file
110
src/icons.rs
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
use std::{
|
||||||
|
io::{BufReader, Read, Seek},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
|
use image::io::Reader as ImageReader;
|
||||||
|
use poem::{
|
||||||
|
error::ResponseError,
|
||||||
|
handler,
|
||||||
|
http::StatusCode,
|
||||||
|
web::{Data, Path, StaticFileRequest, StaticFileResponse},
|
||||||
|
FromRequest, IntoResponse, Request, Response,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::Talon;
|
||||||
|
|
||||||
|
pub struct Icons {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum ImagesError {
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("image error: {0}")]
|
||||||
|
Image(#[from] image::ImageError),
|
||||||
|
#[error("image for `{0}` not found")]
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for ImagesError {
|
||||||
|
fn status(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
ImagesError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||||
|
ImagesError::Io(_) | ImagesError::Image(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_SIZE: u32 = 48;
|
||||||
|
const MAX_IMAGE_SIZE: u32 = 4000;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, ImagesError>;
|
||||||
|
|
||||||
|
impl Icons {
|
||||||
|
/// Create a new image storage
|
||||||
|
pub fn new<P: Into<PathBuf>>(path: P) -> Self {
|
||||||
|
Self { path: path.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon_path(&self, subdomain: &str) -> PathBuf {
|
||||||
|
self.path.join(format!("{subdomain}.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_icon(&self, reader: impl Read + Seek, subdomain: &str) -> Result<()> {
|
||||||
|
let mut img_reader = ImageReader::new(BufReader::new(reader));
|
||||||
|
let mut limits = image::io::Limits::default();
|
||||||
|
limits.max_image_height = Some(MAX_IMAGE_SIZE);
|
||||||
|
limits.max_image_width = Some(MAX_IMAGE_SIZE);
|
||||||
|
img_reader.limits(limits);
|
||||||
|
|
||||||
|
let img = img_reader.with_guessed_format()?.decode()?;
|
||||||
|
let img = img.resize(
|
||||||
|
IMAGE_SIZE,
|
||||||
|
IMAGE_SIZE,
|
||||||
|
image::imageops::FilterType::Lanczos3,
|
||||||
|
);
|
||||||
|
img.save(self.icon_path(subdomain))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_icon(&self, subdomain: &str) -> Result<()> {
|
||||||
|
let path = self.get_icon(subdomain)?;
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_icon(&self, subdomain: &str) -> Result<PathBuf> {
|
||||||
|
let path = self.icon_path(subdomain);
|
||||||
|
if path.is_file() {
|
||||||
|
Ok(path)
|
||||||
|
} else {
|
||||||
|
Err(ImagesError::NotFound(subdomain.to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_icon_response(
|
||||||
|
&self,
|
||||||
|
subdomain: &str,
|
||||||
|
request: &Request,
|
||||||
|
) -> poem::Result<StaticFileResponse> {
|
||||||
|
let path = self.icon_path(subdomain);
|
||||||
|
Ok(StaticFileRequest::from_request_without_body(request)
|
||||||
|
.await?
|
||||||
|
.create_response(path, false)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
pub async fn icon(
|
||||||
|
request: &Request,
|
||||||
|
talon: Data<&Talon>,
|
||||||
|
subdomain: Path<String>,
|
||||||
|
) -> poem::Result<Response> {
|
||||||
|
talon
|
||||||
|
.icons
|
||||||
|
.get_icon_response(&subdomain, request)
|
||||||
|
.await
|
||||||
|
.map(|r| r.into_response())
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ pub mod api;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod icons;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
11
src/model.rs
11
src/model.rs
|
@ -29,6 +29,8 @@ pub struct Website {
|
||||||
pub source_url: Option<String>,
|
pub source_url: Option<String>,
|
||||||
/// Icon for the source link
|
/// Icon for the source link
|
||||||
pub source_icon: Option<SourceIcon>,
|
pub source_icon: Option<SourceIcon>,
|
||||||
|
/// Does the website have an icon?
|
||||||
|
pub has_icon: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new website
|
/// Create a new website
|
||||||
|
@ -98,8 +100,9 @@ pub struct Info {
|
||||||
pub stats: Stats,
|
pub stats: Stats,
|
||||||
/// Version information
|
/// Version information
|
||||||
pub version: VersionInfo,
|
pub version: VersionInfo,
|
||||||
/// Current uptime of the server in seconds
|
/// Start time of the server
|
||||||
pub uptime: u64,
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub start_time: OffsetDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a Talon version
|
/// Information about a Talon version
|
||||||
|
@ -134,7 +137,7 @@ pub struct Stats {
|
||||||
/// Embedded site config
|
/// Embedded site config
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TalonConfig<'a> {
|
pub struct TalonConfig<'a> {
|
||||||
pub api: &'a str,
|
pub internal: &'a str,
|
||||||
pub version: &'a str,
|
pub version: &'a str,
|
||||||
pub root_domain: &'a str,
|
pub root_domain: &'a str,
|
||||||
}
|
}
|
||||||
|
@ -175,6 +178,7 @@ impl From<(String, db::model::Website)> for Website {
|
||||||
visibility: w.visibility,
|
visibility: w.visibility,
|
||||||
source_url: w.source_url,
|
source_url: w.source_url,
|
||||||
source_icon: w.source_icon,
|
source_icon: w.source_icon,
|
||||||
|
has_icon: w.has_icon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -214,6 +218,7 @@ impl TryFrom<WebsiteUpdate> for db::model::WebsiteUpdate {
|
||||||
visibility: value.visibility,
|
visibility: value.visibility,
|
||||||
source_url: value.source_url,
|
source_url: value.source_url,
|
||||||
source_icon: value.source_icon,
|
source_icon: value.source_icon,
|
||||||
|
has_icon: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
use std::{ops::Deref, path::Path, sync::Arc};
|
use std::{ops::Deref, path::Path, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
assets,
|
assets,
|
||||||
config::Config,
|
config::Config,
|
||||||
db::Db,
|
db::Db,
|
||||||
|
icons::{icon, Icons},
|
||||||
model::{Info, VersionInfo},
|
model::{Info, VersionInfo},
|
||||||
page::page,
|
page::page,
|
||||||
storage::Storage,
|
storage::Storage,
|
||||||
util,
|
util,
|
||||||
};
|
};
|
||||||
|
use clokwerk::{Interval, Scheduler};
|
||||||
use path_macro::path;
|
use path_macro::path;
|
||||||
use poem::{
|
use poem::{
|
||||||
http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain,
|
http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain,
|
||||||
|
@ -26,6 +28,7 @@ pub struct TalonInner {
|
||||||
pub cfg: Config,
|
pub cfg: Config,
|
||||||
pub db: Db,
|
pub db: Db,
|
||||||
pub storage: Storage,
|
pub storage: Storage,
|
||||||
|
pub icons: Icons,
|
||||||
pub start_time: OffsetDateTime,
|
pub start_time: OffsetDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,23 +58,44 @@ impl Talon {
|
||||||
pub fn new<P: AsRef<Path>>(workdir: P) -> Result<Self> {
|
pub fn new<P: AsRef<Path>>(workdir: P) -> Result<Self> {
|
||||||
let db_dir = path!(workdir / "db");
|
let db_dir = path!(workdir / "db");
|
||||||
let storage_dir = path!(workdir / "storage");
|
let storage_dir = path!(workdir / "storage");
|
||||||
|
let icons_dir = path!(workdir / "icons");
|
||||||
util::create_dir_ne(&workdir)?;
|
util::create_dir_ne(&workdir)?;
|
||||||
util::create_dir_ne(&db_dir)?;
|
util::create_dir_ne(&db_dir)?;
|
||||||
util::create_dir_ne(&storage_dir)?;
|
util::create_dir_ne(&storage_dir)?;
|
||||||
|
util::create_dir_ne(&icons_dir)?;
|
||||||
|
|
||||||
let cfg = Config::from_file(path!(workdir / "config.toml"))?;
|
let cfg = Config::from_file(path!(workdir / "config.toml"))?;
|
||||||
let db = Db::new(db_dir)?;
|
let db = Db::new(db_dir)?;
|
||||||
let storage = Storage::new(storage_dir, db.clone(), cfg.clone());
|
let storage = Storage::new(storage_dir, db.clone(), cfg.clone());
|
||||||
|
let icons = Icons::new(icons_dir);
|
||||||
|
|
||||||
Ok(Self {
|
let talon = Self {
|
||||||
i: TalonInner {
|
i: TalonInner {
|
||||||
cfg,
|
cfg,
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
|
icons,
|
||||||
start_time: OffsetDateTime::now_utc(),
|
start_time: OffsetDateTime::now_utc(),
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
Ok(talon)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheduler(&self) -> Scheduler {
|
||||||
|
let mut scheduler = Scheduler::new();
|
||||||
|
let talon = self.clone();
|
||||||
|
scheduler
|
||||||
|
.every(Interval::Minutes(self.cfg.server.purge_interval))
|
||||||
|
.run(move || {
|
||||||
|
log::info!("Starting purge");
|
||||||
|
match talon.storage.purge() {
|
||||||
|
Ok((files, freed)) => log::info!("{files} files purged, {freed} bytes freed"),
|
||||||
|
Err(e) => log::error!("purge error: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn endpoint(&self) -> impl Endpoint {
|
pub fn endpoint(&self) -> impl Endpoint {
|
||||||
|
@ -96,7 +120,8 @@ impl Talon {
|
||||||
"/api/spec",
|
"/api/spec",
|
||||||
poem::endpoint::make_sync(move |_| spec.clone()),
|
poem::endpoint::make_sync(move |_| spec.clone()),
|
||||||
)
|
)
|
||||||
.at("/assets/menu/*path", assets::menu_assets);
|
.at("/assets/menu/*path", assets::menu_assets)
|
||||||
|
.at("/icons/:subdomain", icon);
|
||||||
|
|
||||||
let internal_domain = format!(
|
let internal_domain = format!(
|
||||||
"{}.{}",
|
"{}.{}",
|
||||||
|
@ -122,6 +147,9 @@ impl Talon {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch(&self) -> Result<()> {
|
pub async fn launch(&self) -> Result<()> {
|
||||||
|
let scheduler = self.scheduler();
|
||||||
|
let _scheduler_handle = scheduler.watch_thread(Duration::from_secs(1));
|
||||||
|
|
||||||
Server::new(TcpListener::bind(&self.i.cfg.server.address))
|
Server::new(TcpListener::bind(&self.i.cfg.server.address))
|
||||||
.run_with_graceful_shutdown(self.endpoint(), Self::shutdown_signal(), None)
|
.run_with_graceful_shutdown(self.endpoint(), Self::shutdown_signal(), None)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -132,10 +160,7 @@ impl Talon {
|
||||||
Ok(Info {
|
Ok(Info {
|
||||||
stats: self.storage.stats()?,
|
stats: self.storage.stats()?,
|
||||||
version: VersionInfo::get(),
|
version: VersionInfo::get(),
|
||||||
uptime: (OffsetDateTime::now_utc() - self.start_time)
|
start_time: self.start_time,
|
||||||
.whole_seconds()
|
|
||||||
.try_into()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,7 +120,7 @@ impl Storage {
|
||||||
pub fn new<P: Into<PathBuf>>(path: P, db: Db, cfg: Config) -> Self {
|
pub fn new<P: Into<PathBuf>>(path: P, db: Db, cfg: Config) -> Self {
|
||||||
// Build the string to inject into html pages
|
// Build the string to inject into html pages
|
||||||
let talon_cfg = TalonConfig {
|
let talon_cfg = TalonConfig {
|
||||||
api: &format!("{}/api", cfg.server.internal_url),
|
internal: &cfg.server.internal_url,
|
||||||
version: crate::build::PKG_VERSION,
|
version: crate::build::PKG_VERSION,
|
||||||
root_domain: &cfg.server.root_domain,
|
root_domain: &cfg.server.root_domain,
|
||||||
};
|
};
|
||||||
|
|
5
tests/fixtures/mod.rs
vendored
5
tests/fixtures/mod.rs
vendored
|
@ -46,6 +46,7 @@ pub const HASH_SPA_FALLBACK: [u8; 32] =
|
||||||
pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47";
|
pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47";
|
||||||
pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b";
|
pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b";
|
||||||
// pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4";
|
// pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4";
|
||||||
|
pub const API_KEY_RO: &str = "48691ad9f42bb12e61e259b5e90dc941a293cfae11af18c9e6557f92557f0086";
|
||||||
|
|
||||||
pub struct DbTest {
|
pub struct DbTest {
|
||||||
db: Db,
|
db: Db,
|
||||||
|
@ -74,6 +75,7 @@ fn insert_websites(db: &Db) {
|
||||||
&Website {
|
&Website {
|
||||||
name: "ThetaDev".to_owned(),
|
name: "ThetaDev".to_owned(),
|
||||||
created_at: datetime!(2023-02-18 16:30 +0),
|
created_at: datetime!(2023-02-18 16:30 +0),
|
||||||
|
updated_at: datetime!(2023-02-18 16:30 +0),
|
||||||
latest_version: Some(2),
|
latest_version: Some(2),
|
||||||
color: Some(2068974),
|
color: Some(2068974),
|
||||||
visibility: talon::model::Visibility::Featured,
|
visibility: talon::model::Visibility::Featured,
|
||||||
|
@ -86,6 +88,7 @@ fn insert_websites(db: &Db) {
|
||||||
&Website {
|
&Website {
|
||||||
name: "Spotify-Gender-Ex".to_owned(),
|
name: "Spotify-Gender-Ex".to_owned(),
|
||||||
created_at: datetime!(2023-02-18 16:30 +0),
|
created_at: datetime!(2023-02-18 16:30 +0),
|
||||||
|
updated_at: datetime!(2023-02-18 16:30 +0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(1947988),
|
color: Some(1947988),
|
||||||
visibility: talon::model::Visibility::Featured,
|
visibility: talon::model::Visibility::Featured,
|
||||||
|
@ -100,6 +103,7 @@ fn insert_websites(db: &Db) {
|
||||||
&Website {
|
&Website {
|
||||||
name: "RustyPipe".to_owned(),
|
name: "RustyPipe".to_owned(),
|
||||||
created_at: datetime!(2023-02-20 18:30 +0),
|
created_at: datetime!(2023-02-20 18:30 +0),
|
||||||
|
updated_at: datetime!(2023-02-20 18:30 +0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(7943647),
|
color: Some(7943647),
|
||||||
visibility: talon::model::Visibility::Featured,
|
visibility: talon::model::Visibility::Featured,
|
||||||
|
@ -114,6 +118,7 @@ fn insert_websites(db: &Db) {
|
||||||
&Website {
|
&Website {
|
||||||
name: "SvelteKit SPA".to_owned(),
|
name: "SvelteKit SPA".to_owned(),
|
||||||
created_at: datetime!(2023-03-03 22:00 +0),
|
created_at: datetime!(2023-03-03 22:00 +0),
|
||||||
|
updated_at: datetime!(2023-03-03 22:00 +0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(16727552),
|
color: Some(16727552),
|
||||||
visibility: talon::model::Visibility::Hidden,
|
visibility: talon::model::Visibility::Hidden,
|
||||||
|
|
|
@ -12,6 +12,7 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
subdomain: "rustypipe",
|
subdomain: "rustypipe",
|
||||||
|
@ -22,6 +23,7 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||||
source_icon: Some(gitea),
|
source_icon: Some(gitea),
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
subdomain: "spotify-gender-ex",
|
subdomain: "spotify-gender-ex",
|
||||||
|
@ -32,5 +34,6 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||||
source_icon: Some(github),
|
source_icon: Some(github),
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -12,6 +12,7 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
subdomain: "rustypipe",
|
subdomain: "rustypipe",
|
||||||
|
@ -22,6 +23,7 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||||
source_icon: Some(gitea),
|
source_icon: Some(gitea),
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
subdomain: "spa",
|
subdomain: "spa",
|
||||||
|
@ -32,6 +34,7 @@ expression: websites
|
||||||
visibility: hidden,
|
visibility: hidden,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
subdomain: "spotify-gender-ex",
|
subdomain: "spotify-gender-ex",
|
||||||
|
@ -42,5 +45,6 @@ expression: websites
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||||
source_icon: Some(github),
|
source_icon: Some(github),
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,6 +8,7 @@ ConfigInner(
|
||||||
root_domain: "example.com",
|
root_domain: "example.com",
|
||||||
internal_subdomain: "talon-i",
|
internal_subdomain: "talon-i",
|
||||||
internal_url: "http://talon-i.example.com",
|
internal_url: "http://talon-i.example.com",
|
||||||
|
purge_interval: 60,
|
||||||
),
|
),
|
||||||
compression: CompressionCfg(
|
compression: CompressionCfg(
|
||||||
gzip_en: true,
|
gzip_en: true,
|
||||||
|
|
|
@ -8,6 +8,7 @@ ConfigInner(
|
||||||
root_domain: "localhost:3000",
|
root_domain: "localhost:3000",
|
||||||
internal_subdomain: "talon",
|
internal_subdomain: "talon",
|
||||||
internal_url: "http://talon.localhost:3000",
|
internal_url: "http://talon.localhost:3000",
|
||||||
|
purge_interval: 1440,
|
||||||
),
|
),
|
||||||
compression: CompressionCfg(
|
compression: CompressionCfg(
|
||||||
gzip_en: true,
|
gzip_en: true,
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
source: tests/tests.rs
|
source: tests/tests.rs
|
||||||
expression: data
|
expression: data
|
||||||
---
|
---
|
||||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1}}
|
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"updated_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
|
||||||
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1}}
|
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"updated_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
|
||||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1}}
|
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
|
||||||
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
||||||
{"type":"version","key":"spa:1","value":{"created_at":[2023,62,22,0,0,0,0,0,0],"data":{},"fallback":"200.html","spa":true}}
|
{"type":"version","key":"spa:1","value":{"created_at":[2023,62,22,0,0,0,0,0,0],"data":{},"fallback":"200.html","spa":true}}
|
||||||
{"type":"version","key":"spotify-gender-ex:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
{"type":"version","key":"spotify-gender-ex:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
source: tests/tests.rs
|
source: tests/tests.rs
|
||||||
expression: data
|
expression: data
|
||||||
---
|
---
|
||||||
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null,"vid_count":2}}
|
{"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null,"vid_count":2,"has_icon":false}}
|
||||||
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1}}
|
{"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"updated_at":[2023,51,18,30,0,0,0,0,0],"latest_version":1,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea","vid_count":1,"has_icon":false}}
|
||||||
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1}}
|
{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"updated_at":[2023,62,22,0,0,0,0,0,0],"latest_version":1,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null,"vid_count":1,"has_icon":false}}
|
||||||
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1}}
|
{"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"updated_at":[2023,49,16,30,0,0,0,0,0],"latest_version":1,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github","vid_count":1,"has_icon":false}}
|
||||||
{"type":"version","key":"-:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1352014628","Version":"v0.1.0"},"fallback":null,"spa":false}}
|
{"type":"version","key":"-:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1352014628","Version":"v0.1.0"},"fallback":null,"spa":false}}
|
||||||
{"type":"version","key":"-:2","value":{"created_at":[2023,49,16,52,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1354755231","Version":"v0.1.1"},"fallback":null,"spa":false}}
|
{"type":"version","key":"-:2","value":{"created_at":[2023,49,16,52,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1354755231","Version":"v0.1.1"},"fallback":null,"spa":false}}
|
||||||
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
{"type":"version","key":"rustypipe:1","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}}
|
||||||
|
|
|
@ -6,31 +6,37 @@ expression: "vec![ws1, ws2, ws3]"
|
||||||
Website(
|
Website(
|
||||||
name: "ThetaDev",
|
name: "ThetaDev",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(2),
|
latest_version: Some(2),
|
||||||
color: Some(2068974),
|
color: Some(2068974),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
vid_count: 2,
|
vid_count: 2,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
name: "Spotify-Gender-Ex",
|
name: "Spotify-Gender-Ex",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(1947988),
|
color: Some(1947988),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||||
source_icon: Some(github),
|
source_icon: Some(github),
|
||||||
vid_count: 1,
|
vid_count: 1,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
Website(
|
Website(
|
||||||
name: "RustyPipe",
|
name: "RustyPipe",
|
||||||
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(7943647),
|
color: Some(7943647),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||||
source_icon: Some(gitea),
|
source_icon: Some(gitea),
|
||||||
vid_count: 1,
|
vid_count: 1,
|
||||||
|
has_icon: false,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,41 +6,49 @@ expression: websites
|
||||||
("-", Website(
|
("-", Website(
|
||||||
name: "ThetaDev",
|
name: "ThetaDev",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(2),
|
latest_version: Some(2),
|
||||||
color: Some(2068974),
|
color: Some(2068974),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
vid_count: 2,
|
vid_count: 2,
|
||||||
|
has_icon: false,
|
||||||
)),
|
)),
|
||||||
("rustypipe", Website(
|
("rustypipe", Website(
|
||||||
name: "RustyPipe",
|
name: "RustyPipe",
|
||||||
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 51, 18, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(7943647),
|
color: Some(7943647),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
|
||||||
source_icon: Some(gitea),
|
source_icon: Some(gitea),
|
||||||
vid_count: 1,
|
vid_count: 1,
|
||||||
|
has_icon: false,
|
||||||
)),
|
)),
|
||||||
("spa", Website(
|
("spa", Website(
|
||||||
name: "SvelteKit SPA",
|
name: "SvelteKit SPA",
|
||||||
created_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0),
|
created_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(16727552),
|
color: Some(16727552),
|
||||||
visibility: hidden,
|
visibility: hidden,
|
||||||
source_url: None,
|
source_url: None,
|
||||||
source_icon: None,
|
source_icon: None,
|
||||||
vid_count: 1,
|
vid_count: 1,
|
||||||
|
has_icon: false,
|
||||||
)),
|
)),
|
||||||
("spotify-gender-ex", Website(
|
("spotify-gender-ex", Website(
|
||||||
name: "Spotify-Gender-Ex",
|
name: "Spotify-Gender-Ex",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
latest_version: Some(1),
|
latest_version: Some(1),
|
||||||
color: Some(1947988),
|
color: Some(1947988),
|
||||||
visibility: featured,
|
visibility: featured,
|
||||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
|
||||||
source_icon: Some(github),
|
source_icon: Some(github),
|
||||||
vid_count: 1,
|
vid_count: 1,
|
||||||
|
has_icon: false,
|
||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,10 +5,12 @@ expression: website
|
||||||
Website(
|
Website(
|
||||||
name: "ThetaDev2",
|
name: "ThetaDev2",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: "[date]",
|
||||||
latest_version: Some(2),
|
latest_version: Some(2),
|
||||||
color: Some(1000),
|
color: Some(1000),
|
||||||
visibility: hidden,
|
visibility: hidden,
|
||||||
source_url: Some("https://example.com"),
|
source_url: Some("https://example.com"),
|
||||||
source_icon: Some(link),
|
source_icon: Some(link),
|
||||||
vid_count: 2,
|
vid_count: 2,
|
||||||
|
has_icon: false,
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,6 +3,7 @@ address = "127.0.0.1:3000"
|
||||||
root_domain = "example.com"
|
root_domain = "example.com"
|
||||||
internal_subdomain = "talon-i"
|
internal_subdomain = "talon-i"
|
||||||
internal_url = "http://talon-i.example.com"
|
internal_url = "http://talon-i.example.com"
|
||||||
|
purge_interval = 60
|
||||||
|
|
||||||
# Talon compresses files when they are uploaded
|
# Talon compresses files when they are uploaded
|
||||||
# Here you can configure compression algorithms and levels
|
# Here you can configure compression algorithms and levels
|
||||||
|
|
206
tests/tests.rs
206
tests/tests.rs
|
@ -8,6 +8,8 @@ use rstest::rstest;
|
||||||
use fixtures::*;
|
use fixtures::*;
|
||||||
use talon::db::{Db, DbError};
|
use talon::db::{Db, DbError};
|
||||||
|
|
||||||
|
const ICON_SIZE: u32 = 48;
|
||||||
|
|
||||||
mod database {
|
mod database {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
@ -83,7 +85,7 @@ mod database {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let website = db.get_website(SUBDOMAIN_1).unwrap();
|
let website = db.get_website(SUBDOMAIN_1).unwrap();
|
||||||
insta::assert_ron_snapshot!(website);
|
insta::assert_ron_snapshot!(website, {".updated_at" => "[date]"});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
@ -592,6 +594,50 @@ mod storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod icons {
|
||||||
|
use talon::icons::Icons;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use image::io::Reader as ImageReader;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_icon() {
|
||||||
|
let temp = temp_testdir::TempDir::default();
|
||||||
|
let icons = Icons::new(temp.to_path_buf());
|
||||||
|
|
||||||
|
let icon = File::open(path!("assets" / "icon.png")).unwrap();
|
||||||
|
icons.insert_icon(icon, "talon").unwrap();
|
||||||
|
|
||||||
|
let stored_path = path!(temp / "talon.png");
|
||||||
|
let got_path = icons.get_icon("talon").unwrap();
|
||||||
|
assert_eq!(stored_path, got_path);
|
||||||
|
assert!(stored_path.is_file());
|
||||||
|
|
||||||
|
let stored_img = ImageReader::open(&stored_path).unwrap().decode().unwrap();
|
||||||
|
assert_eq!(stored_img.height(), ICON_SIZE);
|
||||||
|
assert_eq!(stored_img.width(), ICON_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_icon() {
|
||||||
|
let temp = temp_testdir::TempDir::default();
|
||||||
|
let icons = Icons::new(temp.to_path_buf());
|
||||||
|
|
||||||
|
let icon = File::open(path!("assets" / "icon.png")).unwrap();
|
||||||
|
icons.insert_icon(icon, "talon").unwrap();
|
||||||
|
|
||||||
|
icons.delete_icon("talon").unwrap();
|
||||||
|
|
||||||
|
let stored_path = path!(temp / "talon.png");
|
||||||
|
assert!(!stored_path.exists());
|
||||||
|
assert!(matches!(
|
||||||
|
icons.get_icon("talon"),
|
||||||
|
Err(talon::icons::ImagesError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod config {
|
mod config {
|
||||||
use talon::config::Config;
|
use talon::config::Config;
|
||||||
|
|
||||||
|
@ -743,8 +789,11 @@ mod page {
|
||||||
}
|
}
|
||||||
|
|
||||||
mod api {
|
mod api {
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
|
||||||
use hex::ToHex;
|
use hex::ToHex;
|
||||||
use hex_literal::hex;
|
use hex_literal::hex;
|
||||||
|
use image::io::Reader as ImageReader;
|
||||||
use poem::{
|
use poem::{
|
||||||
http::{header, Method, StatusCode},
|
http::{header, Method, StatusCode},
|
||||||
test::TestClient,
|
test::TestClient,
|
||||||
|
@ -773,6 +822,7 @@ mod api {
|
||||||
visibility: Visibility::Featured,
|
visibility: Visibility::Featured,
|
||||||
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()),
|
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()),
|
||||||
source_icon: Some(SourceIcon::Github),
|
source_icon: Some(SourceIcon::Github),
|
||||||
|
has_icon: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -808,16 +858,18 @@ mod api {
|
||||||
resp.assert_status_is_ok();
|
resp.assert_status_is_ok();
|
||||||
|
|
||||||
let ws = tln.db.get_website("test").unwrap();
|
let ws = tln.db.get_website("test").unwrap();
|
||||||
insta::assert_ron_snapshot!(ws, {".created_at" => "[date]"}, @r###"
|
insta::assert_ron_snapshot!(ws, {".created_at" => "[date]", ".updated_at" => "[date]"}, @r###"
|
||||||
Website(
|
Website(
|
||||||
name: "Test",
|
name: "Test",
|
||||||
created_at: "[date]",
|
created_at: "[date]",
|
||||||
|
updated_at: "[date]",
|
||||||
latest_version: None,
|
latest_version: None,
|
||||||
color: Some(1000),
|
color: Some(1000),
|
||||||
visibility: searchable,
|
visibility: searchable,
|
||||||
source_url: Some("example.com"),
|
source_url: Some("example.com"),
|
||||||
source_icon: Some(git),
|
source_icon: Some(git),
|
||||||
vid_count: 0,
|
vid_count: 0,
|
||||||
|
has_icon: false,
|
||||||
)
|
)
|
||||||
"###);
|
"###);
|
||||||
}
|
}
|
||||||
|
@ -862,16 +914,18 @@ mod api {
|
||||||
resp.assert_status_is_ok();
|
resp.assert_status_is_ok();
|
||||||
|
|
||||||
let ws = tln.db.get_website("-").unwrap();
|
let ws = tln.db.get_website("-").unwrap();
|
||||||
insta::assert_ron_snapshot!(ws, @r###"
|
insta::assert_ron_snapshot!(ws, {".updated_at" => "[date]"}, @r###"
|
||||||
Website(
|
Website(
|
||||||
name: "Test",
|
name: "Test",
|
||||||
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
|
||||||
|
updated_at: "[date]",
|
||||||
latest_version: Some(2),
|
latest_version: Some(2),
|
||||||
color: Some(1000),
|
color: Some(1000),
|
||||||
visibility: searchable,
|
visibility: searchable,
|
||||||
source_url: Some("example.com"),
|
source_url: Some("example.com"),
|
||||||
source_icon: Some(git),
|
source_icon: Some(git),
|
||||||
vid_count: 2,
|
vid_count: 2,
|
||||||
|
has_icon: false,
|
||||||
)
|
)
|
||||||
"###);
|
"###);
|
||||||
}
|
}
|
||||||
|
@ -896,6 +950,111 @@ mod api {
|
||||||
resp.assert_status(StatusCode::NOT_FOUND);
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn website_get_icon(tln: TalonTest) {
|
||||||
|
let icon = File::open(path!("assets" / "icon.png")).unwrap();
|
||||||
|
tln.icons.insert_icon(icon, "-").unwrap();
|
||||||
|
|
||||||
|
let mut resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.get("http://talon.localhost:3000/icons/-")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
resp.assert_header(header::CONTENT_TYPE, "image/png");
|
||||||
|
|
||||||
|
let icon_bts = tokio_test::block_on(resp.0.take_body().into_bytes()).unwrap();
|
||||||
|
let got_icon = ImageReader::new(BufReader::new(Cursor::new(icon_bts)))
|
||||||
|
.with_guessed_format()
|
||||||
|
.unwrap()
|
||||||
|
.decode()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got_icon.height(), ICON_SIZE);
|
||||||
|
assert_eq!(got_icon.width(), ICON_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn website_get_icon_404(tln: TalonTest) {
|
||||||
|
let resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.get("http://talon.localhost:3000/icons/-")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn website_upload_icon(tln: TalonTest) {
|
||||||
|
let mut icon = File::open(path!("assets" / "icon.png")).unwrap();
|
||||||
|
let mut icon_bts = Vec::new();
|
||||||
|
icon.read_to_end(&mut icon_bts).unwrap();
|
||||||
|
|
||||||
|
let resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.put("http://talon.localhost:3000/api/website/-/icon")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
|
.header("x-api-key", API_KEY_ROOT)
|
||||||
|
.data(tln.clone())
|
||||||
|
.body(icon_bts)
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
|
||||||
|
let resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.get("http://talon.localhost:3000/api/website/-")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
tokio_test::block_on(resp.assert_json(Website {
|
||||||
|
subdomain: "-".to_string(),
|
||||||
|
name: "ThetaDev".to_owned(),
|
||||||
|
created_at: datetime!(2023-02-18 16:30 +0),
|
||||||
|
latest_version: Some(2),
|
||||||
|
color: Some("#1f91ee".to_owned()),
|
||||||
|
visibility: talon::model::Visibility::Featured,
|
||||||
|
source_icon: None,
|
||||||
|
source_url: None,
|
||||||
|
has_icon: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn website_delete_icon(tln: TalonTest) {
|
||||||
|
let icon = File::open(path!("assets" / "icon.png")).unwrap();
|
||||||
|
tln.icons.insert_icon(icon, "-").unwrap();
|
||||||
|
|
||||||
|
let resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.delete("http://talon.localhost:3000/api/website/-/icon")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.header("x-api-key", API_KEY_ROOT)
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn website_delete_icon_404(tln: TalonTest) {
|
||||||
|
let resp = tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.delete("http://talon.localhost:3000/api/website/-/icon")
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.header("x-api-key", API_KEY_ROOT)
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
);
|
||||||
|
resp.assert_status(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
fn website_delete(tln: TalonTest) {
|
fn website_delete(tln: TalonTest) {
|
||||||
let resp = tokio_test::block_on(
|
let resp = tokio_test::block_on(
|
||||||
|
@ -1172,6 +1331,10 @@ mod api {
|
||||||
.send(),
|
.send(),
|
||||||
);
|
);
|
||||||
resp.assert_status(StatusCode::BAD_REQUEST);
|
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Check that no zombie version remains
|
||||||
|
assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1);
|
||||||
|
assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
@ -1190,6 +1353,10 @@ mod api {
|
||||||
.send(),
|
.send(),
|
||||||
);
|
);
|
||||||
resp.assert_status(StatusCode::BAD_REQUEST);
|
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
// Check that no zombie version remains
|
||||||
|
assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1);
|
||||||
|
assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
@ -1219,11 +1386,13 @@ mod api {
|
||||||
#[case::websites_all("websitesAll", Method::GET)]
|
#[case::websites_all("websitesAll", Method::GET)]
|
||||||
#[case::version_delete("website/test/version/1", Method::DELETE)]
|
#[case::version_delete("website/test/version/1", Method::DELETE)]
|
||||||
#[case::version_upload("website/test/upload", Method::POST)]
|
#[case::version_upload("website/test/upload", Method::POST)]
|
||||||
|
#[case::icon_upload("website/test/icon", Method::PUT)]
|
||||||
|
#[case::icon_delete("website/test/icon", Method::DELETE)]
|
||||||
fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) {
|
fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) {
|
||||||
let resp = tokio_test::block_on(
|
let resp = tokio_test::block_on(
|
||||||
TestClient::new(tln.endpoint())
|
TestClient::new(tln.endpoint())
|
||||||
.request(
|
.request(
|
||||||
method,
|
method.clone(),
|
||||||
format!("http://talon.localhost:3000/api/{endpoint}"),
|
format!("http://talon.localhost:3000/api/{endpoint}"),
|
||||||
)
|
)
|
||||||
.header(header::HOST, "talon.localhost:3000")
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
@ -1231,5 +1400,34 @@ mod api {
|
||||||
.send(),
|
.send(),
|
||||||
);
|
);
|
||||||
resp.assert_status(StatusCode::UNAUTHORIZED);
|
resp.assert_status(StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let resp = match method {
|
||||||
|
Method::POST => tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.request(
|
||||||
|
method.clone(),
|
||||||
|
format!("http://talon.localhost:3000/api/{endpoint}"),
|
||||||
|
)
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
|
.header("x-api-key", API_KEY_RO)
|
||||||
|
.data(tln.clone())
|
||||||
|
.send(),
|
||||||
|
),
|
||||||
|
Method::GET | Method::PUT | Method::PATCH => return,
|
||||||
|
_ => tokio_test::block_on(
|
||||||
|
TestClient::new(tln.endpoint())
|
||||||
|
.request(
|
||||||
|
method.clone(),
|
||||||
|
format!("http://talon.localhost:3000/api/{endpoint}"),
|
||||||
|
)
|
||||||
|
.header(header::HOST, "talon.localhost:3000")
|
||||||
|
.header("x-api-key", API_KEY_RO)
|
||||||
|
.data(tln.clone())
|
||||||
|
.body_json(&serde_json::Value::Null)
|
||||||
|
.send(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
resp.assert_status(StatusCode::FORBIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,9 @@
|
||||||
* a value or a type, so tell TypeScript to enforce using
|
* a value or a type, so tell TypeScript to enforce using
|
||||||
* `import type` instead of `import` for Types.
|
* `import type` instead of `import` for Types.
|
||||||
*/
|
*/
|
||||||
"importsNotUsedAsValues": "error",
|
"importsNotUsedAsValues": "error",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
// for TS5 "verbatimModuleSyntax": true,
|
||||||
/**
|
/**
|
||||||
* To have warnings / errors of the Svelte compiler at the
|
* To have warnings / errors of the Svelte compiler at the
|
||||||
* correct position, enable source maps by default.
|
* correct position, enable source maps by default.
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
extends: ["eslint:recommended"],
|
|
||||||
env: { browser: true, es6: true, node: true },
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["*.ts", "*.svelte"],
|
|
||||||
extends: [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
],
|
|
||||||
globals: {
|
|
||||||
Atomics: "readonly",
|
|
||||||
SharedArrayBuffer: "readonly",
|
|
||||||
},
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
parserOptions: {
|
|
||||||
project: "./tsconfig.json",
|
|
||||||
},
|
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["*.svelte"],
|
|
||||||
processor: "svelte3/svelte3",
|
|
||||||
parserOptions: {
|
|
||||||
extraFileExtensions: [".svelte"],
|
|
||||||
},
|
|
||||||
plugins: ["svelte3", "@typescript-eslint"],
|
|
||||||
settings: {
|
|
||||||
"svelte3/typescript": true,
|
|
||||||
"svelte3/ignore-styles": () => true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rules: {},
|
|
||||||
ignorePatterns: [".rollup/**", "public/**", "dist/**"],
|
|
||||||
};
|
|
1440
ui/menu/package-lock.json
generated
1440
ui/menu/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -6,13 +6,11 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rollup -c -w",
|
"dev": "rollup -c -w",
|
||||||
"build": "rollup -c",
|
"build": "rollup -c",
|
||||||
"start": "sirv public --single",
|
"start": "sirv public -p 5000 --single",
|
||||||
"lint": "eslint .",
|
|
||||||
"fix": "eslint . --fix",
|
|
||||||
"check": "svelte-check --tsconfig ../../tsconfig.json",
|
"check": "svelte-check --tsconfig ../../tsconfig.json",
|
||||||
"format": "prettier --plugin=./node_modules/prettier-plugin-svelte --write .",
|
"format": "prettier --plugin=./node_modules/prettier-plugin-svelte --write .",
|
||||||
"pc": "npm run fix & npm run check & npm run format",
|
"pc": "npm run check & npm run format",
|
||||||
"ci": "npm run lint & npm run check & prettier --plugin=./node_modules/prettier-plugin-svelte --check ."
|
"ci": "npm run check & prettier --plugin=./node_modules/prettier-plugin-svelte --check ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.15.5",
|
"@babel/core": "^7.15.5",
|
||||||
|
@ -25,12 +23,8 @@
|
||||||
"@rollup/plugin-typescript": "^8.2.5",
|
"@rollup/plugin-typescript": "^8.2.5",
|
||||||
"@rollup/pluginutils": "^4.1.1",
|
"@rollup/pluginutils": "^4.1.1",
|
||||||
"@tsconfig/svelte": "^2.0.1",
|
"@tsconfig/svelte": "^2.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.31.2",
|
"prettier": "^2.8.7",
|
||||||
"@typescript-eslint/parser": "^4.31.2",
|
"prettier-plugin-svelte": "^2.10.0",
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"eslint-plugin-svelte3": "^3.2.1",
|
|
||||||
"prettier": "^2.2.1",
|
|
||||||
"prettier-plugin-svelte": "^1.2.0",
|
|
||||||
"rollup": "^2.57.0",
|
"rollup": "^2.57.0",
|
||||||
"rollup-plugin-brotli": "^3.1.0",
|
"rollup-plugin-brotli": "^3.1.0",
|
||||||
"rollup-plugin-copy": "^3.4.0",
|
"rollup-plugin-copy": "^3.4.0",
|
||||||
|
@ -39,11 +33,11 @@
|
||||||
"rollup-plugin-svelte": "^7.1.0",
|
"rollup-plugin-svelte": "^7.1.0",
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
"rollup-plugin-terser": "^7.0.2",
|
||||||
"sass": "^1.42.1",
|
"sass": "^1.42.1",
|
||||||
"sirv-cli": "^1.0.14",
|
"sirv-cli": "^2.0.2",
|
||||||
"svelte": "^3.43.0",
|
"svelte": "^3.43.0",
|
||||||
"svelte-check": "^2.2.6",
|
"svelte-check": "^3.1.4",
|
||||||
"svelte-keydown": "^0.3.1",
|
"svelte-keydown": "^0.6.0",
|
||||||
"svelte-modals": "^1.0.4",
|
"svelte-modals": "^1.2.1",
|
||||||
"svelte-preprocess": "^4.9.5",
|
"svelte-preprocess": "^4.9.5",
|
||||||
"tslib": "^2.5.0",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
|
|
|
@ -190,7 +190,7 @@
|
||||||
|
|
||||||
<script id="talon-config" type="application/json">
|
<script id="talon-config" type="application/json">
|
||||||
{
|
{
|
||||||
"api": "http://talon.localhost:3000/api",
|
"internal": "http://talon.localhost:3000",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"root_domain": "localhost:3000"
|
"root_domain": "localhost:3000"
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
let currentWebsite: Website;
|
let currentWebsite: Website;
|
||||||
|
|
||||||
currentWebsiteStore.subscribe((ws) => {
|
currentWebsiteStore.subscribe((ws) => {
|
||||||
console.log("current ws changed", ws);
|
|
||||||
currentWebsite = ws;
|
currentWebsite = ws;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -23,19 +22,8 @@
|
||||||
color = "#7935df";
|
color = "#7935df";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
.backdrop
|
|
||||||
position: fixed
|
|
||||||
top: 0
|
|
||||||
bottom: 0
|
|
||||||
right: 0
|
|
||||||
left: 0
|
|
||||||
background: rgba(0, 0, 0, 0.6)
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="wrapper" style={`--talon-color: ${color}`}>
|
<div class="wrapper" style={`--talon-color: ${color}`}>
|
||||||
{#if currentWebsite}
|
{#if currentWebsite}
|
||||||
<Menu />
|
<Menu />
|
||||||
|
@ -46,3 +34,13 @@
|
||||||
<div class="backdrop" slot="backdrop" on:click={closeModal} />
|
<div class="backdrop" slot="backdrop" on:click={closeModal} />
|
||||||
</Modals>
|
</Modals>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
.backdrop
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
background: rgba(0, 0, 0, 0.6)
|
||||||
|
</style>
|
||||||
|
|
|
@ -1,21 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { isMobile } from "../util/functions";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
export let hide = false;
|
export let hide = false;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<button class:hide on:click>
|
||||||
|
<Icon iconName="menu" size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
|
@import "../style/mixin"
|
||||||
|
|
||||||
button
|
button
|
||||||
position: fixed
|
position: fixed
|
||||||
bottom: 25px
|
bottom: 25px
|
||||||
right: 25px
|
right: 25px
|
||||||
|
|
||||||
height: 56px
|
height: 56px
|
||||||
width: 56px
|
width: 56px
|
||||||
|
|
||||||
|
@include mobile
|
||||||
|
bottom: 0px
|
||||||
|
right: 0px
|
||||||
|
height: 35px
|
||||||
|
width: 35px
|
||||||
|
border-radius: 50% 0 0
|
||||||
|
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
justify-content: center
|
||||||
|
@ -31,7 +42,3 @@
|
||||||
bottom: -56px
|
bottom: -56px
|
||||||
opacity: 0
|
opacity: 0
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<button class:hide on:click>
|
|
||||||
<Icon iconName="menu" size={25} />
|
|
||||||
</button>
|
|
||||||
|
|
|
@ -9,9 +9,29 @@
|
||||||
|
|
||||||
let icon: [number, number, string];
|
let icon: [number, number, string];
|
||||||
$: icon = icons[iconName] ?? [0, 0, ""];
|
$: icon = icons[iconName] ?? [0, 0, ""];
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<span class="icon" style="width: {size}px; height: {size}px;">
|
||||||
|
{#if icon}
|
||||||
|
<span
|
||||||
|
style="width: {(size * scale * 4) / 3}px; height: {(size * scale * 4) / 3}px"
|
||||||
|
class:transparent
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size * scale}
|
||||||
|
height={size * scale}
|
||||||
|
viewBox="0 0 {icon[0]} {icon[1]}"
|
||||||
|
>
|
||||||
|
<path d={icon[2]} fill={color} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
|
|
||||||
|
@ -22,22 +42,3 @@
|
||||||
&.transparent
|
&.transparent
|
||||||
background: none
|
background: none
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<span class="icon" style="width: {size}px; height: {size}px;">
|
|
||||||
{#if icon}
|
|
||||||
<span
|
|
||||||
style="width: {(size * scale * 4) / 3}px; height: {(size * scale * 4) / 3}px"
|
|
||||||
class:transparent>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size * scale}
|
|
||||||
height={size * scale}
|
|
||||||
viewBox="0 0 {icon[0]} {icon[1]}">
|
|
||||||
<path d={icon[2]} fill={color} />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
|
@ -4,9 +4,25 @@
|
||||||
export let scale = 1;
|
export let scale = 1;
|
||||||
export let alt = "??";
|
export let alt = "??";
|
||||||
export let color = "#4b228a";
|
export let color = "#4b228a";
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<span class="icon" style="width: {size}px; height: {size}px;">
|
||||||
|
{#if imageSrc}
|
||||||
|
<span
|
||||||
|
style="width: {size * scale}px; height: {size *
|
||||||
|
scale}px; background-image: url('{imageSrc}')"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
style="width: {size * scale}px; height: {size * scale}px; font-size: {size *
|
||||||
|
scale *
|
||||||
|
0.55}px; background: {color}"
|
||||||
|
>
|
||||||
|
{alt}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
|
|
||||||
|
@ -17,15 +33,3 @@
|
||||||
text-transform: uppercase
|
text-transform: uppercase
|
||||||
border-radius: 50%
|
border-radius: 50%
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<span class="icon" style="width: {size}px; height: {size}px;">
|
|
||||||
{#if imageSrc}
|
|
||||||
<span
|
|
||||||
style="width: {size * scale}px; height: {size * scale}px; background-image: url('{imageSrc}')" />
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
style="width: {size * scale}px; height: {size * scale}px; font-size: {size * scale * 0.55}px; background: {color}">
|
|
||||||
{alt}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
|
@ -1,176 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { fly } from "svelte/transition";
|
|
||||||
import { closeModal } from "svelte-modals";
|
|
||||||
import Keydown from "svelte-keydown";
|
|
||||||
|
|
||||||
import PageIcon from "./PageIcon.svelte";
|
|
||||||
import Icon from "./Icon.svelte";
|
|
||||||
import { formatDate, getWebsiteVersionUrl } from "../util/functions";
|
|
||||||
import InlineIcon from "./InlineIcon.svelte";
|
|
||||||
import Tag from "./Tag.svelte";
|
|
||||||
import type { Version, Website } from "talon-client";
|
|
||||||
import { client, currentWebsiteStore } from "../util/api";
|
|
||||||
import { talonConfig } from "../util/talonData";
|
|
||||||
|
|
||||||
let currentWebsite: Website;
|
|
||||||
currentWebsiteStore.subscribe((ws) => {
|
|
||||||
currentWebsite = ws;
|
|
||||||
});
|
|
||||||
|
|
||||||
export let isOpen: boolean;
|
|
||||||
$: {
|
|
||||||
if (isOpen && currentWebsite) {
|
|
||||||
client
|
|
||||||
.websiteSubdomainVersionsGet({ subdomain: currentWebsite.subdomain })
|
|
||||||
.then((v) => {
|
|
||||||
versions = v;
|
|
||||||
if (v && v.length > 0) {
|
|
||||||
currentVersion = v[v.length - 1];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let versions: Version[] = [];
|
|
||||||
let currentVersion: Version = null;
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
@use "../style/values"
|
|
||||||
|
|
||||||
.modal
|
|
||||||
position: fixed
|
|
||||||
top: 0
|
|
||||||
bottom: 0
|
|
||||||
right: 0
|
|
||||||
left: 0
|
|
||||||
display: flex
|
|
||||||
justify-content: center
|
|
||||||
align-items: center
|
|
||||||
pointer-events: none
|
|
||||||
|
|
||||||
> div
|
|
||||||
position: relative
|
|
||||||
overflow-y: auto
|
|
||||||
overflow-x: hidden
|
|
||||||
|
|
||||||
margin: 75px auto
|
|
||||||
padding: 20px
|
|
||||||
width: 600px
|
|
||||||
max-width: 90%
|
|
||||||
max-height: 90%
|
|
||||||
|
|
||||||
background: values.$color-base
|
|
||||||
border-radius: 15px
|
|
||||||
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5)
|
|
||||||
|
|
||||||
pointer-events: auto
|
|
||||||
|
|
||||||
.tag
|
|
||||||
display: flex
|
|
||||||
align-items: center
|
|
||||||
|
|
||||||
span
|
|
||||||
font-size: 2em
|
|
||||||
margin-left: 0.25em
|
|
||||||
|
|
||||||
button
|
|
||||||
position: absolute
|
|
||||||
right: 5px
|
|
||||||
top: 5px
|
|
||||||
background: none
|
|
||||||
border: none
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
filter: brightness(50%)
|
|
||||||
|
|
||||||
.dhead
|
|
||||||
width: 100%
|
|
||||||
font-size: 1.4em
|
|
||||||
border-style: solid
|
|
||||||
border-image-source: linear-gradient(to right, values.$color-base-1, values.$color-base-2, values.$color-base-1)
|
|
||||||
border-image-slice: 0 0 1 0
|
|
||||||
border-image-width: 2px
|
|
||||||
|
|
||||||
.smalltag
|
|
||||||
margin: 0.3em 0
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
> *
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
a
|
|
||||||
filter: none
|
|
||||||
|
|
||||||
a:hover
|
|
||||||
text-decoration: none
|
|
||||||
filter: brightness(130%)
|
|
||||||
|
|
||||||
span
|
|
||||||
padding: 0.4em
|
|
||||||
background-color: values.$color-base-1
|
|
||||||
|
|
||||||
span:first-child
|
|
||||||
font-weight: bold
|
|
||||||
background-color: var(--talon-color)
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<Keydown paused={!isOpen} on:Escape={closeModal} />
|
|
||||||
|
|
||||||
{#if isOpen && currentWebsite}
|
|
||||||
<div class="modal" role="dialog" transition:fly={{ y: 50 }} on:introstart on:outroend>
|
|
||||||
<div>
|
|
||||||
<div class="tag">
|
|
||||||
<PageIcon website={currentWebsite} size={60} scale={0.8} />
|
|
||||||
<span>{currentWebsite.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if currentVersion}
|
|
||||||
<p class="dhead">
|
|
||||||
<InlineIcon iconName="question" />
|
|
||||||
Current version #{currentVersion.id}
|
|
||||||
</p>
|
|
||||||
<Tag key="Upload date" value={formatDate(currentVersion.createdAt)} />
|
|
||||||
<!--<Tag key="Uploaded by" value={currentVersion.user} />-->
|
|
||||||
|
|
||||||
{#each Object.entries(currentVersion.data) as [key, value]}
|
|
||||||
<Tag {key} {value} />
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if versions && versions.length}
|
|
||||||
<p class="dhead">
|
|
||||||
<InlineIcon iconName="history" />
|
|
||||||
History
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#each versions as version}
|
|
||||||
<p class="smalltag">
|
|
||||||
<a href={getWebsiteVersionUrl(currentWebsite.subdomain, version.id)}>
|
|
||||||
<span>#{version.id}</span>
|
|
||||||
<span>{formatDate(version.createdAt)}</span>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="dhead" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
Powered by
|
|
||||||
<a
|
|
||||||
href="https://code.thetadev.de/ThetaDev/Talon/src/tag/{talonConfig.version}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
referrerpolicy="no-referrer">Talon
|
|
||||||
{talonConfig.version}</a>
|
|
||||||
</div>
|
|
||||||
<!--<p><a href="" target="_blank">View licenses</a></p>-->
|
|
||||||
<button on:click={closeModal}>
|
|
||||||
<Icon iconName="close" size={40} scale={0.6} transparent={true} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
|
@ -7,16 +7,8 @@
|
||||||
|
|
||||||
let icon: [number, number, string];
|
let icon: [number, number, string];
|
||||||
$: icon = icons[iconName] ?? [0, 0, ""];
|
$: icon = icons[iconName] ?? [0, 0, ""];
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="sass">
|
|
||||||
@use "../style/values"
|
|
||||||
|
|
||||||
span
|
|
||||||
display: inline-block
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@ -25,7 +17,15 @@
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 {icon[0]} {icon[1]}">
|
viewBox="0 0 {icon[0]} {icon[1]}"
|
||||||
|
>
|
||||||
<path d={icon[2]} fill={color} />
|
<path d={icon[2]} fill={color} />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@use "../style/values"
|
||||||
|
|
||||||
|
span
|
||||||
|
display: inline-block
|
||||||
|
</style>
|
||||||
|
|
62
ui/menu/src/components/InstanceInfoModal.svelte
Normal file
62
ui/menu/src/components/InstanceInfoModal.svelte
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { talonConfig } from "../util/talonData";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
import type { Info } from "talon-client";
|
||||||
|
import { client } from "../util/api";
|
||||||
|
import Tag from "./Tag.svelte";
|
||||||
|
import { formatDate, trimCommit } from "../util/functions";
|
||||||
|
import InlineIcon from "./InlineIcon.svelte";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (isOpen) {
|
||||||
|
client.infoGet().then((info) => {
|
||||||
|
instanceInfo = info;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instanceInfo: Info | null = null;
|
||||||
|
const repoBaseUrl = "https://code.thetadev.de/ThetaDev/Talon";
|
||||||
|
let repoUrl = `${repoBaseUrl}/src/tag/v${talonConfig.version}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen}>
|
||||||
|
{#if instanceInfo}
|
||||||
|
<p class="divider">
|
||||||
|
<InlineIcon iconName="question" />
|
||||||
|
Instance info
|
||||||
|
</p>
|
||||||
|
<Tag key="Number of files" value={instanceInfo.stats.nFiles.toString()} />
|
||||||
|
<Tag key="Number of websites" value={instanceInfo.stats.nWebsites.toString()} />
|
||||||
|
<Tag key="Start time" value={formatDate(instanceInfo.startTime)} />
|
||||||
|
|
||||||
|
<p class="divider">
|
||||||
|
<InlineIcon iconName="cog" />
|
||||||
|
Version info
|
||||||
|
</p>
|
||||||
|
<Tag key="Version" value={talonConfig.version} href={repoUrl} external={true} />
|
||||||
|
<Tag key="Build mode" value={instanceInfo.version.buildMode} />
|
||||||
|
<Tag key="Build target" value={instanceInfo.version.buildTarget} />
|
||||||
|
<Tag
|
||||||
|
key="Commit"
|
||||||
|
value={trimCommit(instanceInfo.version.commit)}
|
||||||
|
href={repoBaseUrl + "/src/commit/" + instanceInfo.version.commit}
|
||||||
|
external={true}
|
||||||
|
/>
|
||||||
|
<Tag key="Commit date" value={formatDate(instanceInfo.version.commitDate)} />
|
||||||
|
<Tag key="Rust version" value={instanceInfo.version.rustVersion} />
|
||||||
|
{:else}
|
||||||
|
<p>Loading instance info...</p>
|
||||||
|
<p class="divider" />
|
||||||
|
<a href={repoUrl} target="_blank" rel="noreferrer" referrerpolicy="no-referrer"
|
||||||
|
>Talon
|
||||||
|
{talonConfig.version}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@use "../style/values"
|
||||||
|
</style>
|
|
@ -4,7 +4,7 @@
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import MenuItem from "./MenuItem.svelte";
|
import MenuItem from "./MenuItem.svelte";
|
||||||
import MenuItemPage from "./MenuItemPage.svelte";
|
import MenuItemPage from "./MenuItemPage.svelte";
|
||||||
import InfoModal from "./InfoModal.svelte";
|
import PageInfoModal from "./PageInfoModal.svelte";
|
||||||
import FloatingButton from "./FloatingButton.svelte";
|
import FloatingButton from "./FloatingButton.svelte";
|
||||||
|
|
||||||
import type { Focusable } from "../util/types";
|
import type { Focusable } from "../util/types";
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
import MenuItemInput from "./MenuItemInput.svelte";
|
import MenuItemInput from "./MenuItemInput.svelte";
|
||||||
import { currentWebsiteStore, websitesStore } from "../util/api";
|
import { currentWebsiteStore, websitesStore } from "../util/api";
|
||||||
import { Visibility, Website } from "talon-client";
|
import { Visibility, Website } from "talon-client";
|
||||||
import { getWebsiteUrl } from "../util/functions";
|
import { getWebsiteUrl, isMobile } from "../util/functions";
|
||||||
|
|
||||||
let currentWebsite: Website;
|
let currentWebsite: Website;
|
||||||
let websites: Website[];
|
let websites: Website[];
|
||||||
|
@ -32,10 +32,6 @@
|
||||||
sidebarShown = false;
|
sidebarShown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMobile(): boolean {
|
|
||||||
return window.innerWidth < 768;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSearch(): void {
|
function openSearch(): void {
|
||||||
searchOpen = true;
|
searchOpen = true;
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
|
@ -53,7 +49,7 @@
|
||||||
closeSearch();
|
closeSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchKeypress(e: KeyboardEvent) {
|
function searchKeyup(e: KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Enter":
|
case "Enter":
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
|
@ -70,8 +66,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInfo() {
|
function openPageInfo() {
|
||||||
openModal(InfoModal);
|
openModal(PageInfoModal, null, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidebarShown = !isMobile();
|
let sidebarShown = !isMobile();
|
||||||
|
@ -92,9 +88,46 @@
|
||||||
}
|
}
|
||||||
return ws.visibility === Visibility.Featured;
|
return ws.visibility === Visibility.Featured;
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<nav class:hide={!sidebarShown}>
|
||||||
|
<div>
|
||||||
|
<MenuItemInput
|
||||||
|
active={searchOpen || Boolean(searchText).valueOf()}
|
||||||
|
on:click={openSearch}
|
||||||
|
on:focusout={closeSearch}
|
||||||
|
on:keyup={searchKeyup}
|
||||||
|
bind:input={searchInput}
|
||||||
|
bind:text={searchText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#each displayedWebsites as website, i}
|
||||||
|
<MenuItemPage {website} active={searchOpen && searchText && i === 0} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if currentWebsite && currentWebsite.sourceUrl}
|
||||||
|
<MenuItem
|
||||||
|
text="View source"
|
||||||
|
link={currentWebsite.sourceUrl}
|
||||||
|
newTab={true}
|
||||||
|
privacy={true}
|
||||||
|
>
|
||||||
|
<Icon iconName={currentWebsite.sourceIcon} size={40} scale={0.6} />
|
||||||
|
</MenuItem>
|
||||||
|
{/if}
|
||||||
|
<MenuItem text="Info" on:click={openPageInfo}>
|
||||||
|
<PageIcon website={currentWebsite} />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem text="Hide sidebar" on:click={hideSidebar}>
|
||||||
|
<Icon iconName="arrowRight" size={40} scale={0.6} />
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<FloatingButton hide={sidebarShown} on:click={showSidebar} />
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
@use "../style/mixin"
|
@use "../style/mixin"
|
||||||
|
@ -106,7 +139,7 @@
|
||||||
height: 100%
|
height: 100%
|
||||||
z-index: 999999
|
z-index: 999999
|
||||||
|
|
||||||
padding: 1em 0.4em
|
padding: 3em 0.4em 0.4em
|
||||||
|
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
|
@ -132,39 +165,3 @@
|
||||||
nav
|
nav
|
||||||
display: none
|
display: none
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<nav class:hide={!sidebarShown}>
|
|
||||||
<div>
|
|
||||||
<MenuItemInput
|
|
||||||
active={searchOpen || Boolean(searchText).valueOf()}
|
|
||||||
on:click={openSearch}
|
|
||||||
on:focusout={closeSearch}
|
|
||||||
on:keyup={searchKeypress}
|
|
||||||
bind:input={searchInput}
|
|
||||||
bind:text={searchText} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{#each displayedWebsites as website, i}
|
|
||||||
<MenuItemPage {website} active={searchOpen && searchText && i === 0} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{#if currentWebsite && currentWebsite.sourceUrl}
|
|
||||||
<MenuItem
|
|
||||||
text="View source"
|
|
||||||
link={currentWebsite.sourceUrl}
|
|
||||||
newTab={true}
|
|
||||||
privacy={true}>
|
|
||||||
<Icon iconName={currentWebsite.sourceIcon} size={40} scale={0.6} />
|
|
||||||
</MenuItem>
|
|
||||||
{/if}
|
|
||||||
<MenuItem text="Info" on:click={openInfo}>
|
|
||||||
<PageIcon website={currentWebsite} />
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem text="Hide sidebar" on:click={hideSidebar}>
|
|
||||||
<Icon iconName="arrowRight" size={40} scale={0.6} />
|
|
||||||
</MenuItem>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<FloatingButton hide={sidebarShown} on:click={showSidebar} />
|
|
||||||
|
|
|
@ -4,9 +4,21 @@
|
||||||
export let active = false;
|
export let active = false;
|
||||||
export let newTab = false;
|
export let newTab = false;
|
||||||
export let privacy = false;
|
export let privacy = false;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
class:active
|
||||||
|
href={link}
|
||||||
|
target={newTab ? "_blank" : null}
|
||||||
|
rel={privacy ? "noopener noreferrer" : null}
|
||||||
|
on:click
|
||||||
|
>
|
||||||
|
<span>{text}</span>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
|
|
||||||
|
@ -46,15 +58,3 @@
|
||||||
> span, > :global(*)
|
> span, > :global(*)
|
||||||
display: flex
|
display: flex
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
class:active
|
|
||||||
href={link}
|
|
||||||
target={newTab ? '_blank' : null}
|
|
||||||
rel={privacy ? 'noopener noreferrer' : null}
|
|
||||||
on:click>
|
|
||||||
<span>{text}</span>
|
|
||||||
<slot />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -18,9 +18,22 @@
|
||||||
inputElm.blur();
|
inputElm.blur();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<MenuItem {active} on:click>
|
||||||
|
<input
|
||||||
|
placeholder="Search..."
|
||||||
|
bind:this={inputElm}
|
||||||
|
bind:value={text}
|
||||||
|
on:focusout
|
||||||
|
on:keypress|stopPropagation
|
||||||
|
on:keydown|stopPropagation
|
||||||
|
on:keyup|stopPropagation
|
||||||
|
use:selectTextOnFocus
|
||||||
|
/>
|
||||||
|
<Icon iconName="search" size={40} scale={0.6} />
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
input
|
input
|
||||||
background: none
|
background: none
|
||||||
|
@ -34,14 +47,3 @@
|
||||||
&:focus
|
&:focus
|
||||||
border-bottom: solid #fff 2px
|
border-bottom: solid #fff 2px
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<MenuItem {active} on:click>
|
|
||||||
<input
|
|
||||||
placeholder="Search..."
|
|
||||||
bind:this={inputElm}
|
|
||||||
bind:value={text}
|
|
||||||
on:focusout
|
|
||||||
on:keyup
|
|
||||||
use:selectTextOnFocus />
|
|
||||||
<Icon iconName="search" size={40} scale={0.6} />
|
|
||||||
</MenuItem>
|
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
website.name.length > MAX_TEXT_LEN
|
website.name.length > MAX_TEXT_LEN
|
||||||
? website.name.substring(0, 20).trim() + "..."
|
? website.name.substring(0, 20).trim() + "..."
|
||||||
: website.name;
|
: website.name;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MenuItem {active} {text} link={getWebsiteUrl(website.subdomain)}>
|
<MenuItem {active} {text} link={getWebsiteUrl(website.subdomain)}>
|
||||||
|
|
66
ui/menu/src/components/Modal.svelte
Normal file
66
ui/menu/src/components/Modal.svelte
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import { closeModal } from "svelte-modals";
|
||||||
|
import Keydown from "svelte-keydown";
|
||||||
|
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
export let isOpen = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Keydown paused={!isOpen} on:Escape={closeModal} />
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="modal" role="dialog" transition:fly={{ y: 50 }} on:introstart on:outroend>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
<button on:click={closeModal}>
|
||||||
|
<Icon iconName="close" size={40} scale={0.6} transparent={true} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@use "../style/values"
|
||||||
|
|
||||||
|
.modal
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
z-index: 1000000
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
> div
|
||||||
|
position: relative
|
||||||
|
overflow-y: auto
|
||||||
|
overflow-x: hidden
|
||||||
|
|
||||||
|
margin: 75px auto
|
||||||
|
padding: 20px
|
||||||
|
width: 600px
|
||||||
|
max-width: 90%
|
||||||
|
max-height: 90%
|
||||||
|
|
||||||
|
background: values.$color-base
|
||||||
|
border-radius: 15px
|
||||||
|
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5)
|
||||||
|
|
||||||
|
pointer-events: auto
|
||||||
|
|
||||||
|
button
|
||||||
|
position: absolute
|
||||||
|
right: 5px
|
||||||
|
top: 5px
|
||||||
|
background: none
|
||||||
|
border: none
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
filter: brightness(50%)
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,21 +1,25 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageIcon from "./ImageIcon.svelte";
|
import ImageIcon from "./ImageIcon.svelte";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import type { Website } from "ui/apiclient";
|
import type { Website } from "talon-client";
|
||||||
|
import { talonConfig } from "../util/talonData";
|
||||||
|
import { getAbbreviation } from "../util/functions";
|
||||||
|
|
||||||
export let website: Website;
|
export let website: Website;
|
||||||
export let size = 40;
|
export let size = 40;
|
||||||
export let scale = 0.8;
|
export let scale = 0.8;
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if website}
|
{#if website}
|
||||||
<ImageIcon
|
<ImageIcon
|
||||||
imageSrc=""
|
imageSrc={website.hasIcon
|
||||||
|
? `${talonConfig.internal}/icons/${website.subdomain}`
|
||||||
|
: null}
|
||||||
color={website.color}
|
color={website.color}
|
||||||
alt={website.name.substring(0, 2)}
|
alt={getAbbreviation(website.name)}
|
||||||
{size}
|
{size}
|
||||||
{scale} />
|
{scale}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon iconName="question" {size} scale={scale * 0.75} />
|
<Icon iconName="question" {size} scale={scale * 0.75} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
142
ui/menu/src/components/PageInfoModal.svelte
Normal file
142
ui/menu/src/components/PageInfoModal.svelte
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import PageIcon from "./PageIcon.svelte";
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
getSubdomainAndVersion,
|
||||||
|
getWebsiteUrl,
|
||||||
|
getWebsiteVersionUrl,
|
||||||
|
isUrl,
|
||||||
|
trimCommit,
|
||||||
|
} from "../util/functions";
|
||||||
|
import InlineIcon from "./InlineIcon.svelte";
|
||||||
|
import Tag from "./Tag.svelte";
|
||||||
|
import type { Version, Website } from "talon-client";
|
||||||
|
import { client, currentWebsiteStore } from "../util/api";
|
||||||
|
import { talonConfig } from "../util/talonData";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
import { openModal } from "svelte-modals";
|
||||||
|
import InstanceInfoModal from "./InstanceInfoModal.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let currentWebsite: Website;
|
||||||
|
currentWebsiteStore.subscribe((ws) => {
|
||||||
|
currentWebsite = ws;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentVid: number | null = getSubdomainAndVersion()[1];
|
||||||
|
|
||||||
|
export let isOpen: boolean;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const v = await client.websiteSubdomainVersionsGet({
|
||||||
|
subdomain: currentWebsite.subdomain,
|
||||||
|
});
|
||||||
|
|
||||||
|
versions = v;
|
||||||
|
if (v && v.length > 0) {
|
||||||
|
latestVersion = v[v.length - 1];
|
||||||
|
|
||||||
|
if (currentVid !== null) {
|
||||||
|
currentVersion = v.find((v) => v.id == currentVid);
|
||||||
|
} else {
|
||||||
|
currentVersion = latestVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let versions: Version[] = [];
|
||||||
|
let currentVersion: Version | undefined;
|
||||||
|
let latestVersion: Version | undefined;
|
||||||
|
|
||||||
|
function getVersionAttr(version: Version): string | null {
|
||||||
|
return (
|
||||||
|
version.data["version"] ||
|
||||||
|
version.data["Version"] ||
|
||||||
|
trimCommit(version.data["commit"]) ||
|
||||||
|
trimCommit(version.data["Commit"]) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInstanceInfo() {
|
||||||
|
openModal(InstanceInfoModal, null, { replace: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen}>
|
||||||
|
{#if currentWebsite}
|
||||||
|
<div class="tag">
|
||||||
|
<PageIcon website={currentWebsite} size={60} scale={0.8} />
|
||||||
|
<span>{currentWebsite.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if currentVersion}
|
||||||
|
<p class="divider">
|
||||||
|
<InlineIcon iconName="question" />
|
||||||
|
Current version #{currentVersion.id}
|
||||||
|
|
||||||
|
{#if latestVersion && latestVersion !== currentVersion}
|
||||||
|
<a class="latest-tag" href={getWebsiteUrl(currentWebsite.subdomain)}
|
||||||
|
>Latest: #{latestVersion.id}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<Tag key="Upload date" value={formatDate(currentVersion.createdAt)} />
|
||||||
|
|
||||||
|
{#each Object.entries(currentVersion.data) as [key, value]}
|
||||||
|
<Tag {key} {value} href={isUrl(value) ? value : null} external={true} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if versions && versions.length}
|
||||||
|
<p class="divider">
|
||||||
|
<InlineIcon iconName="history" />
|
||||||
|
History
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#each { length: versions.length } as _, index}
|
||||||
|
{@const reverseIndex = versions.length - 1 - index}
|
||||||
|
{@const version = versions[reverseIndex]}
|
||||||
|
<Tag
|
||||||
|
href={getWebsiteVersionUrl(currentWebsite.subdomain, version.id)}
|
||||||
|
key={"#" + version.id}
|
||||||
|
value={formatDate(version.createdAt)}
|
||||||
|
value2={getVersionAttr(version)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p>Loading website...</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="divider" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Powered by
|
||||||
|
<button class="link" on:click={openInstanceInfo}
|
||||||
|
>Talon {talonConfig.version}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
@use "../style/values"
|
||||||
|
|
||||||
|
.tag
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
span
|
||||||
|
font-size: 2em
|
||||||
|
margin-left: 0.25em
|
||||||
|
|
||||||
|
.latest-tag
|
||||||
|
background-color: lime
|
||||||
|
color: values.$color-text-d1
|
||||||
|
margin: 0 1em
|
||||||
|
overflow: hidden
|
||||||
|
white-space: nowrap
|
||||||
|
padding: 0 0.4em
|
||||||
|
border-radius: 1em
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,10 +1,30 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let key = "";
|
export let key = "";
|
||||||
export let value = "";
|
export let value = "";
|
||||||
export let href: string = null;
|
export let value2: string | null = null;
|
||||||
|
export let href: string | null = null;
|
||||||
|
export let external = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
target={external ? "blank" : null}
|
||||||
|
rel={external ? "noreferrer" : null}
|
||||||
|
referrerpolicy={external ? "no-referrer" : null}
|
||||||
|
>
|
||||||
|
<span>{key}</span>
|
||||||
|
{#if value2}<span class="val2">{value2}</span>{/if}
|
||||||
|
<span>{value}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span>{key}</span>
|
||||||
|
{#if value2}<span class="val2">{value2}</span>{/if}
|
||||||
|
<span>{value}</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
<style lang="sass">
|
<style lang="sass">
|
||||||
@use "../style/values"
|
@use "../style/values"
|
||||||
|
|
||||||
|
@ -15,6 +35,7 @@
|
||||||
a
|
a
|
||||||
filter: none
|
filter: none
|
||||||
display: flex
|
display: flex
|
||||||
|
max-width: 100%
|
||||||
|
|
||||||
a:hover
|
a:hover
|
||||||
text-decoration: none
|
text-decoration: none
|
||||||
|
@ -23,14 +44,14 @@
|
||||||
span
|
span
|
||||||
padding: 0.4em
|
padding: 0.4em
|
||||||
background-color: values.$color-base-1
|
background-color: values.$color-base-1
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
|
||||||
span:first-child
|
span:first-child
|
||||||
font-weight: bold
|
font-weight: bold
|
||||||
background-color: var(--talon-color)
|
background-color: var(--talon-color)
|
||||||
</style>
|
|
||||||
|
|
||||||
<p>
|
.val2
|
||||||
{#if href}
|
background-color: values.$color-base-2
|
||||||
<a {href}> <span>{key}</span> <span>{value}</span> </a>
|
</style>
|
||||||
{:else}<span>{key}</span> <span>{value}</span>{/if}
|
|
||||||
</p>
|
|
||||||
|
|
|
@ -6,11 +6,18 @@
|
||||||
font-family: sans-serif
|
font-family: sans-serif
|
||||||
color: values.$color-text
|
color: values.$color-text
|
||||||
|
|
||||||
a
|
a, .link
|
||||||
display: inline
|
display: inline
|
||||||
|
text-decoration: none
|
||||||
|
cursor: pointer
|
||||||
|
background: none
|
||||||
|
border: none
|
||||||
|
box-shadow: none
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
.link
|
||||||
color: var(--talon-color)
|
color: var(--talon-color)
|
||||||
filter: brightness(150%)
|
filter: brightness(150%)
|
||||||
text-decoration: none
|
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
text-decoration: underline
|
text-decoration: underline
|
||||||
|
@ -19,9 +26,6 @@ a
|
||||||
p
|
p
|
||||||
margin: 0.8em 0
|
margin: 0.8em 0
|
||||||
|
|
||||||
button
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
::placeholder, ::-webkit-input-placeholder
|
::placeholder, ::-webkit-input-placeholder
|
||||||
color: values.$color-text-1
|
color: values.$color-text-1
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
@ -30,3 +34,11 @@ button
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
justify-content: center
|
||||||
|
|
||||||
|
.divider
|
||||||
|
width: 100%
|
||||||
|
font-size: 1.4em
|
||||||
|
border-style: solid
|
||||||
|
border-image-source: linear-gradient(to right, values.$color-base-1, values.$color-base-2, values.$color-base-1)
|
||||||
|
border-image-slice: 0 0 1 0
|
||||||
|
border-image-width: 2px
|
||||||
|
|
|
@ -13,3 +13,4 @@ $color-base-2: color.scale($color-base, $lightness: 20%)
|
||||||
$color-primary-light: color.scale($color-primary, $lightness: 15%)
|
$color-primary-light: color.scale($color-primary, $lightness: 15%)
|
||||||
$color-primary-dark: color.scale($color-primary, $lightness: -15%)
|
$color-primary-dark: color.scale($color-primary, $lightness: -15%)
|
||||||
$color-text-1: color.scale($color-text, $lightness: -15%)
|
$color-text-1: color.scale($color-text, $lightness: -15%)
|
||||||
|
$color-text-d1: color.scale($color-base, $lightness: -20%)
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import { Writable, writable } from "svelte/store";
|
import { type Writable, writable } from "svelte/store";
|
||||||
|
|
||||||
import { Configuration, DefaultApi, Website } from "talon-client";
|
import { Configuration, DefaultApi, type Website } from "talon-client";
|
||||||
import { getSubdomain } from "./functions";
|
import { getSubdomain } from "./functions";
|
||||||
import { talonConfig } from "./talonData";
|
import { talonConfig } from "./talonData";
|
||||||
|
|
||||||
export const websitesStore: Writable<Website[]> = writable([]);
|
export const websitesStore: Writable<Website[]> = writable([]);
|
||||||
export const currentWebsiteStore: Writable<Website> = writable(null);
|
export const currentWebsiteStore: Writable<Website> = writable(null);
|
||||||
|
|
||||||
export const client = new DefaultApi(new Configuration({ basePath: talonConfig.api }));
|
export const client = new DefaultApi(
|
||||||
|
new Configuration({ basePath: `${talonConfig.internal}/api` })
|
||||||
|
);
|
||||||
|
|
||||||
export function fetchWebsites(): void {
|
export function fetchWebsites(): void {
|
||||||
client.websitesGet().then((ws) => {
|
client.websitesGet().then((ws) => {
|
||||||
|
|
|
@ -21,6 +21,29 @@ export function getSubdomain(): string {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSubdomainAndVersion(): [string, number | null] {
|
||||||
|
const hn = window.location.hostname;
|
||||||
|
const rd_noport = talonConfig.root_domain.split(":", 1)[0];
|
||||||
|
|
||||||
|
if (hn.endsWith("." + rd_noport)) {
|
||||||
|
const subdomainSplit = hn
|
||||||
|
.substring(0, hn.length - rd_noport.length - 1)
|
||||||
|
.split("--", 2);
|
||||||
|
const subdomain = subdomainSplit[0];
|
||||||
|
|
||||||
|
let version =
|
||||||
|
subdomainSplit.length > 1 ? parseInt(subdomainSplit[1].replace(/^v/, "")) : null;
|
||||||
|
if (Number.isNaN(version)) version = null;
|
||||||
|
|
||||||
|
if (subdomain === "x") {
|
||||||
|
return ["-", version];
|
||||||
|
} else {
|
||||||
|
return [subdomain, version];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ["-", null];
|
||||||
|
}
|
||||||
|
|
||||||
export function getWebsiteUrl(subdomain: string): string {
|
export function getWebsiteUrl(subdomain: string): string {
|
||||||
const proto = window.location.protocol;
|
const proto = window.location.protocol;
|
||||||
|
|
||||||
|
@ -41,3 +64,40 @@ export function getWebsiteVersionUrl(subdomain: string, version: number): string
|
||||||
return `${proto}//${subdomain}${v}.${talonConfig.root_domain}`;
|
return `${proto}//${subdomain}${v}.${talonConfig.root_domain}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUrl(url: string): boolean {
|
||||||
|
return /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/.test(
|
||||||
|
url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimCommit(commit: string | undefined): string | undefined {
|
||||||
|
if (commit) {
|
||||||
|
return commit.slice(0, 9);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMobile(): boolean {
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a 2-letter abbreviation of the website name.
|
||||||
|
*
|
||||||
|
* If the name consists of multiple words
|
||||||
|
* (separated by spaces, underscores or CamelCase), output
|
||||||
|
* the first letters of these words.
|
||||||
|
*
|
||||||
|
* Otherwise output the first letters of the name.
|
||||||
|
*/
|
||||||
|
export function getAbbreviation(name: string): string {
|
||||||
|
const split_sep = name
|
||||||
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||||
|
.split(/[ ,.;_-]/)
|
||||||
|
.filter((x) => x.length > 0);
|
||||||
|
if (split_sep.length >= 2) {
|
||||||
|
return split_sep[0].charAt(0) + split_sep[1].charAt(0);
|
||||||
|
}
|
||||||
|
return name.substring(0, 2);
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { svgPathData as faHistory } from "@fortawesome/free-solid-svg-icons/faHi
|
||||||
import { svgPathData as faArrowRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
|
import { svgPathData as faArrowRight } from "@fortawesome/free-solid-svg-icons/faChevronRight";
|
||||||
import { svgPathData as faClose } from "@fortawesome/free-solid-svg-icons/faTimes";
|
import { svgPathData as faClose } from "@fortawesome/free-solid-svg-icons/faTimes";
|
||||||
import { svgPathData as faMenu } from "@fortawesome/free-solid-svg-icons/faTh";
|
import { svgPathData as faMenu } from "@fortawesome/free-solid-svg-icons/faTh";
|
||||||
|
import { svgPathData as faCog } from "@fortawesome/free-solid-svg-icons/faCog";
|
||||||
|
|
||||||
import { svgPathData as faGit } from "@fortawesome/free-brands-svg-icons/faGitAlt";
|
import { svgPathData as faGit } from "@fortawesome/free-brands-svg-icons/faGitAlt";
|
||||||
import { svgPathData as faGithub } from "@fortawesome/free-brands-svg-icons/faGithub";
|
import { svgPathData as faGithub } from "@fortawesome/free-brands-svg-icons/faGithub";
|
||||||
|
@ -21,6 +22,7 @@ const icons: { [key: string]: [number, number, string] } = {
|
||||||
arrowRight: [320, 512, faArrowRight],
|
arrowRight: [320, 512, faArrowRight],
|
||||||
close: [352, 512, faClose],
|
close: [352, 512, faClose],
|
||||||
menu: [512, 512, faMenu],
|
menu: [512, 512, faMenu],
|
||||||
|
cog: [512, 512, faCog],
|
||||||
|
|
||||||
git: [496, 512, faGit],
|
git: [496, 512, faGit],
|
||||||
github: [496, 512, faGithub],
|
github: [496, 512, faGithub],
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export interface TalonConfig {
|
export interface TalonConfig {
|
||||||
api: string;
|
internal: string;
|
||||||
version: string;
|
version: string;
|
||||||
root_domain: string;
|
root_domain: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
.gitignore
|
.gitignore
|
||||||
.npmignore
|
.npmignore
|
||||||
.openapi-generator-ignore
|
|
||||||
README.md
|
README.md
|
||||||
package.json
|
package.json
|
||||||
src/apis/DefaultApi.ts
|
src/apis/DefaultApi.ts
|
||||||
|
|
4
ui/talon-client/package-lock.json
generated
4
ui/talon-client/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "talon-client",
|
"name": "talon-client",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "talon-client",
|
"name": "talon-client",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^4.0"
|
"typescript": "^4.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "talon-client",
|
"name": "talon-client",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "OpenAPI client for talon-client",
|
"description": "OpenAPI client for talon-client",
|
||||||
"author": "OpenAPI-Generator",
|
"author": "OpenAPI-Generator",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
@ -52,6 +52,15 @@ export interface WebsiteSubdomainGetRequest {
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebsiteSubdomainIconDeleteRequest {
|
||||||
|
subdomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteSubdomainIconPutRequest {
|
||||||
|
subdomain: string;
|
||||||
|
body: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebsiteSubdomainPatchRequest {
|
export interface WebsiteSubdomainPatchRequest {
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
websiteUpdate: WebsiteUpdate;
|
websiteUpdate: WebsiteUpdate;
|
||||||
|
@ -221,6 +230,81 @@ export class DefaultApi extends runtime.BaseAPI {
|
||||||
return await response.value();
|
return await response.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a website icon
|
||||||
|
*/
|
||||||
|
async websiteSubdomainIconDeleteRaw(requestParameters: WebsiteSubdomainIconDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {
|
||||||
|
if (requestParameters.subdomain === null || requestParameters.subdomain === undefined) {
|
||||||
|
throw new runtime.RequiredError('subdomain','Required parameter requestParameters.subdomain was null or undefined when calling websiteSubdomainIconDelete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParameters: any = {};
|
||||||
|
|
||||||
|
const headerParameters: runtime.HTTPHeaders = {};
|
||||||
|
|
||||||
|
if (this.configuration && this.configuration.apiKey) {
|
||||||
|
headerParameters["X-API-Key"] = this.configuration.apiKey("X-API-Key"); // ApiKeyAuthorization authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request({
|
||||||
|
path: `/website/{subdomain}/icon`.replace(`{${"subdomain"}}`, encodeURIComponent(String(requestParameters.subdomain))),
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headerParameters,
|
||||||
|
query: queryParameters,
|
||||||
|
}, initOverrides);
|
||||||
|
|
||||||
|
return new runtime.VoidApiResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a website icon
|
||||||
|
*/
|
||||||
|
async websiteSubdomainIconDelete(requestParameters: WebsiteSubdomainIconDeleteRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {
|
||||||
|
await this.websiteSubdomainIconDeleteRaw(requestParameters, initOverrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported image formats: png, jpeg, gif, bmp, ico, tiff, webp, pnm, dds, tga, openexr, farbfeld Maximum upload size: 5MB, 4000 pixels Icons are resized to 32x32 pixels.
|
||||||
|
* Upload a website icon
|
||||||
|
*/
|
||||||
|
async websiteSubdomainIconPutRaw(requestParameters: WebsiteSubdomainIconPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<void>> {
|
||||||
|
if (requestParameters.subdomain === null || requestParameters.subdomain === undefined) {
|
||||||
|
throw new runtime.RequiredError('subdomain','Required parameter requestParameters.subdomain was null or undefined when calling websiteSubdomainIconPut.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestParameters.body === null || requestParameters.body === undefined) {
|
||||||
|
throw new runtime.RequiredError('body','Required parameter requestParameters.body was null or undefined when calling websiteSubdomainIconPut.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParameters: any = {};
|
||||||
|
|
||||||
|
const headerParameters: runtime.HTTPHeaders = {};
|
||||||
|
|
||||||
|
headerParameters['Content-Type'] = 'application/octet-stream';
|
||||||
|
|
||||||
|
if (this.configuration && this.configuration.apiKey) {
|
||||||
|
headerParameters["X-API-Key"] = this.configuration.apiKey("X-API-Key"); // ApiKeyAuthorization authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.request({
|
||||||
|
path: `/website/{subdomain}/icon`.replace(`{${"subdomain"}}`, encodeURIComponent(String(requestParameters.subdomain))),
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headerParameters,
|
||||||
|
query: queryParameters,
|
||||||
|
body: requestParameters.body as any,
|
||||||
|
}, initOverrides);
|
||||||
|
|
||||||
|
return new runtime.VoidApiResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported image formats: png, jpeg, gif, bmp, ico, tiff, webp, pnm, dds, tga, openexr, farbfeld Maximum upload size: 5MB, 4000 pixels Icons are resized to 32x32 pixels.
|
||||||
|
* Upload a website icon
|
||||||
|
*/
|
||||||
|
async websiteSubdomainIconPut(requestParameters: WebsiteSubdomainIconPutRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<void> {
|
||||||
|
await this.websiteSubdomainIconPutRaw(requestParameters, initOverrides);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update website
|
* Update website
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -45,11 +45,11 @@ export interface Info {
|
||||||
*/
|
*/
|
||||||
version: InfoVersion;
|
version: InfoVersion;
|
||||||
/**
|
/**
|
||||||
* Current uptime of the server in seconds
|
* Start time of the server
|
||||||
* @type {number}
|
* @type {Date}
|
||||||
* @memberof Info
|
* @memberof Info
|
||||||
*/
|
*/
|
||||||
uptime: number;
|
startTime: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -59,7 +59,7 @@ export function instanceOfInfo(value: object): boolean {
|
||||||
let isInstance = true;
|
let isInstance = true;
|
||||||
isInstance = isInstance && "stats" in value;
|
isInstance = isInstance && "stats" in value;
|
||||||
isInstance = isInstance && "version" in value;
|
isInstance = isInstance && "version" in value;
|
||||||
isInstance = isInstance && "uptime" in value;
|
isInstance = isInstance && "startTime" in value;
|
||||||
|
|
||||||
return isInstance;
|
return isInstance;
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ export function InfoFromJSONTyped(json: any, ignoreDiscriminator: boolean): Info
|
||||||
|
|
||||||
'stats': InfoStatsFromJSON(json['stats']),
|
'stats': InfoStatsFromJSON(json['stats']),
|
||||||
'version': InfoVersionFromJSON(json['version']),
|
'version': InfoVersionFromJSON(json['version']),
|
||||||
'uptime': json['uptime'],
|
'startTime': (new Date(json['start_time'])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,6 +91,6 @@ export function InfoToJSON(value?: Info | null): any {
|
||||||
|
|
||||||
'stats': InfoStatsToJSON(value.stats),
|
'stats': InfoStatsToJSON(value.stats),
|
||||||
'version': InfoVersionToJSON(value.version),
|
'version': InfoVersionToJSON(value.version),
|
||||||
'uptime': value.uptime,
|
'start_time': (value.startTime.toISOString()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,6 +82,12 @@ export interface Website {
|
||||||
* @memberof Website
|
* @memberof Website
|
||||||
*/
|
*/
|
||||||
sourceIcon?: SourceIcon;
|
sourceIcon?: SourceIcon;
|
||||||
|
/**
|
||||||
|
* Does the website have an icon?
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof Website
|
||||||
|
*/
|
||||||
|
hasIcon: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -93,6 +99,7 @@ export function instanceOfWebsite(value: object): boolean {
|
||||||
isInstance = isInstance && "name" in value;
|
isInstance = isInstance && "name" in value;
|
||||||
isInstance = isInstance && "createdAt" in value;
|
isInstance = isInstance && "createdAt" in value;
|
||||||
isInstance = isInstance && "visibility" in value;
|
isInstance = isInstance && "visibility" in value;
|
||||||
|
isInstance = isInstance && "hasIcon" in value;
|
||||||
|
|
||||||
return isInstance;
|
return isInstance;
|
||||||
}
|
}
|
||||||
|
@ -115,6 +122,7 @@ export function WebsiteFromJSONTyped(json: any, ignoreDiscriminator: boolean): W
|
||||||
'visibility': VisibilityFromJSON(json['visibility']),
|
'visibility': VisibilityFromJSON(json['visibility']),
|
||||||
'sourceUrl': !exists(json, 'source_url') ? undefined : json['source_url'],
|
'sourceUrl': !exists(json, 'source_url') ? undefined : json['source_url'],
|
||||||
'sourceIcon': !exists(json, 'source_icon') ? undefined : SourceIconFromJSON(json['source_icon']),
|
'sourceIcon': !exists(json, 'source_icon') ? undefined : SourceIconFromJSON(json['source_icon']),
|
||||||
|
'hasIcon': json['has_icon'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,5 +143,6 @@ export function WebsiteToJSON(value?: Website | null): any {
|
||||||
'visibility': VisibilityToJSON(value.visibility),
|
'visibility': VisibilityToJSON(value.visibility),
|
||||||
'source_url': value.sourceUrl,
|
'source_url': value.sourceUrl,
|
||||||
'source_icon': SourceIconToJSON(value.sourceIcon),
|
'source_icon': SourceIconToJSON(value.sourceIcon),
|
||||||
|
'has_icon': value.hasIcon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue