Compare commits
10 commits
7f533cc583
...
432ef1a72c
| Author | SHA1 | Date | |
|---|---|---|---|
| 432ef1a72c | |||
| ab43295b29 | |||
| 8c9bdbb58e | |||
| e679d167fc | |||
| 4c1a8a9489 | |||
| 261623960e | |||
| 7553dd31dc | |||
| 94269d82f0 | |||
| 8fac396d7e | |||
| bb118f6144 |
35 changed files with 1078 additions and 286 deletions
146
Cargo.lock
generated
146
Cargo.lock
generated
|
|
@ -370,6 +370,19 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atom_syndication"
|
||||||
|
version = "0.12.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"derive_builder",
|
||||||
|
"diligent-date-parser",
|
||||||
|
"never",
|
||||||
|
"quick-xml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
|
|
@ -953,6 +966,7 @@ dependencies = [
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
"strsim",
|
||||||
"syn 2.0.100",
|
"syn 2.0.100",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -988,6 +1002,37 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.100",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn 2.0.100",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
|
|
@ -1000,6 +1045,15 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "diligent-date-parser"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|
@ -1356,6 +1410,25 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|
@ -1507,6 +1580,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -1974,14 +2048,6 @@ version = "2.7.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "migration"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-std",
|
|
||||||
"sea-orm-migration",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|
@ -2042,6 +2108,12 @@ dependencies = [
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "never"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|
@ -2498,6 +2570,16 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.40"
|
version = "1.0.40"
|
||||||
|
|
@ -2780,6 +2862,18 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rss"
|
||||||
|
version = "2.0.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf"
|
||||||
|
dependencies = [
|
||||||
|
"atom_syndication",
|
||||||
|
"derive_builder",
|
||||||
|
"never",
|
||||||
|
"quick-xml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-embed"
|
name = "rust-embed"
|
||||||
version = "8.7.0"
|
version = "8.7.0"
|
||||||
|
|
@ -2944,15 +3038,17 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"migration",
|
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"rand 0.9.0",
|
"rand 0.9.0",
|
||||||
|
"rss",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
|
"samey-migration",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"strum 0.27.1",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -2960,6 +3056,14 @@ dependencies = [
|
||||||
"tower-sessions",
|
"tower-sessions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "samey-migration"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-std",
|
||||||
|
"sea-orm-migration",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -3000,7 +3104,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strum",
|
"strum 0.26.3",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -3575,6 +3679,28 @@ version = "0.26.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.27.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.27.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.100",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
|
||||||
12
Cargo.toml
12
Cargo.toml
|
|
@ -6,14 +6,18 @@ rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Bad Manners <me@badmanners.xyz>"]
|
authors = ["Bad Manners <me@badmanners.xyz>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
keywords = ["booru", "image-board", "gallery"]
|
||||||
|
categories = ["web-programming"]
|
||||||
|
description = "Sam's small image board"
|
||||||
|
repository = "https://github.com/BadMannersXYZ/samey"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "migration"]
|
members = ["migration"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
askama = { version = "0.13.0", features = ["serde_json"] }
|
askama = { version = "0.13.0", features = ["serde_json"] }
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
axum = { version = "0.8.3", features = ["multipart", "macros"] }
|
axum = { version = "0.8.3", features = ["http2", "multipart", "macros"] }
|
||||||
axum-extra = { version = "0.10.1", features = ["form"] }
|
axum-extra = { version = "0.10.1", features = ["form"] }
|
||||||
axum-login = "0.17.0"
|
axum-login = "0.17.0"
|
||||||
chrono = "0.4.40"
|
chrono = "0.4.40"
|
||||||
|
|
@ -21,11 +25,12 @@ clap = "4.5.35"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
migration = { path = "migration" }
|
samey-migration = { path = "./migration", version = "0.1.0" }
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
password-auth = "1.0.0"
|
password-auth = "1.0.0"
|
||||||
pulldown-cmark = "0.13.0"
|
pulldown-cmark = "0.13.0"
|
||||||
rand = "0.9.0"
|
rand = "0.9.0"
|
||||||
|
rss = "2.0.12"
|
||||||
rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] }
|
rust-embed = { version = "8.7.0", features = ["axum", "debug-embed"] }
|
||||||
sea-orm = { version = "1.1.8", features = [
|
sea-orm = { version = "1.1.8", features = [
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
|
|
@ -40,6 +45,7 @@ time = "0.3.41"
|
||||||
tokio = { version = "1.44.1", features = ["full"] }
|
tokio = { version = "1.44.1", features = ["full"] }
|
||||||
tower-http = { version = "0.6.2", features = ["fs"] }
|
tower-http = { version = "0.6.2", features = ["fs"] }
|
||||||
tower-sessions = "0.14.0"
|
tower-sessions = "0.14.0"
|
||||||
|
strum = { version = "0.27.1", features = ["derive"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -2,29 +2,35 @@
|
||||||
|
|
||||||
Sam's small image board.
|
Sam's small image board.
|
||||||
|
|
||||||
|
[Check out a sample instance here!](https://samey.badmanners.xyz/)
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Still very much an early WIP.
|
Still very much an early WIP.
|
||||||
|
|
||||||
### Roadmap
|
### Features
|
||||||
|
|
||||||
- [ ] Logging
|
- Image and video posts.
|
||||||
- [ ] Improved error handling
|
- Tagging with autocompletion.
|
||||||
- [ ] Caching
|
- Post pools.
|
||||||
- [ ] Lossless compression
|
- RSS feeds.
|
||||||
- [ ] Bulk edit tags/Fix tag capitalization
|
|
||||||
- [ ] User management
|
### Possible roadmap
|
||||||
- [ ] Cleanup/fixup background tasks
|
|
||||||
|
- [ ] Display thumbnails on post selection
|
||||||
- [ ] Text media
|
- [ ] Text media
|
||||||
|
- [ ] Testing
|
||||||
- [ ] Improve CSS
|
- [ ] Improve CSS
|
||||||
|
- [ ] User management
|
||||||
|
- [ ] Lossless compression
|
||||||
- [ ] Migrate to Cot...?
|
- [ ] Migrate to Cot...?
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `ffmpeg` and `ffprobe`
|
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `ffmpeg` (with `ffprobe`)
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
services:
|
services:
|
||||||
samey:
|
samey:
|
||||||
image: badmanners/samey:latest
|
image: badmanners/samey:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
container_name: samey
|
container_name: samey
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
[package]
|
[package]
|
||||||
name = "migration"
|
name = "samey-migration"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
publish = false
|
rust-version = "1.85"
|
||||||
|
license = "MIT"
|
||||||
[lib]
|
authors = ["Bad Manners <me@badmanners.xyz>"]
|
||||||
name = "migration"
|
readme = "README.md"
|
||||||
path = "src/lib.rs"
|
keywords = ["booru", "image-board", "gallery"]
|
||||||
|
categories = ["web-programming"]
|
||||||
|
description = "Migrations for Samey, Sam's small image board"
|
||||||
|
repository = "https://github.com/BadMannersXYZ/samey"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
||||||
|
sea-orm-migration = { version = "1.1.8", features = [
|
||||||
[dependencies.sea-orm-migration]
|
"sqlx-sqlite",
|
||||||
version = "1.1.8"
|
"runtime-tokio-rustls",
|
||||||
features = ["sqlx-sqlite", "runtime-tokio-rustls"]
|
] }
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
#[async_std::main]
|
#[async_std::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
cli::run_cli(migration::Migrator).await;
|
cli::run_cli(samey_migration::Migrator).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||||
use migration::Expr;
|
|
||||||
use password_auth::verify_password;
|
use password_auth::verify_password;
|
||||||
|
use samey_migration::Expr;
|
||||||
use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
@ -221,3 +221,9 @@ impl ExpiredDeletion for SessionStorage {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<axum_login::Error<Backend>> for SameyError {
|
||||||
|
fn from(value: axum_login::Error<Backend>) -> Self {
|
||||||
|
value.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME";
|
pub(crate) const APPLICATION_NAME_KEY: &str = "APPLICATION_NAME";
|
||||||
|
pub(crate) const BASE_URL_KEY: &str = "BASE_URL";
|
||||||
pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION";
|
pub(crate) const AGE_CONFIRMATION_KEY: &str = "AGE_CONFIRMATION";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct AppConfig {
|
pub(crate) struct AppConfig {
|
||||||
pub(crate) application_name: String,
|
pub(crate) application_name: String,
|
||||||
|
pub(crate) base_url: String,
|
||||||
pub(crate) age_confirmation: bool,
|
pub(crate) age_confirmation: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +26,14 @@ impl AppConfig {
|
||||||
Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(),
|
Some(row) => row.data.as_str().unwrap_or("Samey").to_owned(),
|
||||||
None => "Samey".to_owned(),
|
None => "Samey".to_owned(),
|
||||||
};
|
};
|
||||||
|
let base_url = match SameyConfig::find()
|
||||||
|
.filter(samey_config::Column::Key.eq(BASE_URL_KEY))
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(row) => row.data.as_str().unwrap_or("").to_owned(),
|
||||||
|
None => "".to_owned(),
|
||||||
|
};
|
||||||
let age_confirmation = match SameyConfig::find()
|
let age_confirmation = match SameyConfig::find()
|
||||||
.filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY))
|
.filter(samey_config::Column::Key.eq(AGE_CONFIRMATION_KEY))
|
||||||
.one(db)
|
.one(db)
|
||||||
|
|
@ -34,6 +44,7 @@ impl AppConfig {
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
application_name,
|
application_name,
|
||||||
|
base_url,
|
||||||
age_confirmation,
|
age_confirmation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
src/error.rs
96
src/error.rs
|
|
@ -4,54 +4,114 @@ use axum::{
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(askama::Template)]
|
||||||
|
#[template(path = "pages/bad_request.html")]
|
||||||
|
struct BadRequestTemplate<'a> {
|
||||||
|
error: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(askama::Template)]
|
||||||
|
#[template(path = "pages/unauthorized.html")]
|
||||||
|
struct UnauthorizedTemplate;
|
||||||
|
|
||||||
|
#[derive(askama::Template)]
|
||||||
|
#[template(path = "pages/forbidden.html")]
|
||||||
|
struct ForbiddenTemplate;
|
||||||
|
|
||||||
#[derive(askama::Template)]
|
#[derive(askama::Template)]
|
||||||
#[template(path = "pages/not_found.html")]
|
#[template(path = "pages/not_found.html")]
|
||||||
struct NotFoundTemplate;
|
struct NotFoundTemplate;
|
||||||
|
|
||||||
|
#[derive(askama::Template)]
|
||||||
|
#[template(path = "pages/internal_server_error.html")]
|
||||||
|
struct InternalServerErrorTemplate;
|
||||||
|
|
||||||
|
/// Errors from Samey.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum SameyError {
|
pub enum SameyError {
|
||||||
|
/// Integer conversion error.
|
||||||
#[error("Integer conversion error: {0}")]
|
#[error("Integer conversion error: {0}")]
|
||||||
IntConversion(#[from] std::num::TryFromIntError),
|
IntConversion(#[from] std::num::TryFromIntError),
|
||||||
|
/// Integer parsing error.
|
||||||
|
#[error("Integer parsing error: {0}")]
|
||||||
|
IntParse(#[from] std::num::ParseIntError),
|
||||||
|
/// IO error.
|
||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
IO(#[from] std::io::Error),
|
IO(#[from] std::io::Error),
|
||||||
|
/// Task error.
|
||||||
#[error("Task error: {0}")]
|
#[error("Task error: {0}")]
|
||||||
Join(#[from] tokio::task::JoinError),
|
Join(#[from] tokio::task::JoinError),
|
||||||
|
/// Template render error.
|
||||||
#[error("Template render error: {0}")]
|
#[error("Template render error: {0}")]
|
||||||
Render(#[from] askama::Error),
|
Render(#[from] askama::Error),
|
||||||
|
/// Database error.
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
Database(#[from] sea_orm::error::DbErr),
|
Database(#[from] sea_orm::error::DbErr),
|
||||||
|
/// File streaming error.
|
||||||
#[error("File streaming error: {0}")]
|
#[error("File streaming error: {0}")]
|
||||||
Multipart(#[from] axum::extract::multipart::MultipartError),
|
Multipart(#[from] axum::extract::multipart::MultipartError),
|
||||||
|
/// Image error.
|
||||||
#[error("Image error: {0}")]
|
#[error("Image error: {0}")]
|
||||||
Image(#[from] image::ImageError),
|
Image(#[from] image::ImageError),
|
||||||
#[error("Not found")]
|
/// Authentication error.
|
||||||
NotFound,
|
|
||||||
#[error("Authentication error: {0}")]
|
#[error("Authentication error: {0}")]
|
||||||
Authentication(String),
|
Authentication(String),
|
||||||
|
/// Not found.
|
||||||
|
#[error("Not found")]
|
||||||
|
NotFound,
|
||||||
|
/// Not allowed.
|
||||||
#[error("Not allowed")]
|
#[error("Not allowed")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
/// Bad request.
|
||||||
#[error("Bad request: {0}")]
|
#[error("Bad request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
/// Custom internal error.
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for SameyError {
|
impl IntoResponse for SameyError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
println!("Server error - {}", &self);
|
|
||||||
match &self {
|
match &self {
|
||||||
SameyError::IntConversion(_)
|
SameyError::IntConversion(_)
|
||||||
|
| SameyError::IntParse(_)
|
||||||
| SameyError::IO(_)
|
| SameyError::IO(_)
|
||||||
| SameyError::Join(_)
|
| SameyError::Join(_)
|
||||||
| SameyError::Render(_)
|
| SameyError::Render(_)
|
||||||
| SameyError::Database(_)
|
| SameyError::Database(_)
|
||||||
| SameyError::Image(_)
|
| SameyError::Image(_)
|
||||||
| SameyError::Other(_) => {
|
| SameyError::Other(_) => {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response()
|
println!("Internal server error - {:?}", &self);
|
||||||
}
|
(
|
||||||
SameyError::Multipart(_) | SameyError::BadRequest(_) => {
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(StatusCode::BAD_REQUEST, "Invalid request").into_response()
|
Html(
|
||||||
|
InternalServerErrorTemplate {}
|
||||||
|
.render()
|
||||||
|
.expect("shouldn't fail to render InternalServerErrorTemplate"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
SameyError::Multipart(error) => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(
|
||||||
|
BadRequestTemplate {
|
||||||
|
error: &error.body_text(),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.expect("shouldn't fail to render BadRequestTemplate"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
SameyError::BadRequest(error) => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(
|
||||||
|
BadRequestTemplate { error }
|
||||||
|
.render()
|
||||||
|
.expect("shouldn't fail to render BadRequestTemplate"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
SameyError::NotFound => (
|
SameyError::NotFound => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
Html(
|
Html(
|
||||||
|
|
@ -61,10 +121,24 @@ impl IntoResponse for SameyError {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
SameyError::Authentication(_) => {
|
SameyError::Authentication(_) => (
|
||||||
(StatusCode::UNAUTHORIZED, "Not authorized").into_response()
|
StatusCode::UNAUTHORIZED,
|
||||||
}
|
Html(
|
||||||
SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(),
|
UnauthorizedTemplate {}
|
||||||
|
.render()
|
||||||
|
.expect("shouldn't fail to render UnauthorizedTemplate"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
SameyError::Forbidden => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Html(
|
||||||
|
ForbiddenTemplate {}
|
||||||
|
.render()
|
||||||
|
.expect("shouldn't fail to render ForbiddenTemplate"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
src/lib.rs
46
src/lib.rs
|
|
@ -1,9 +1,11 @@
|
||||||
|
//! Sam's small image board.
|
||||||
|
|
||||||
pub(crate) mod auth;
|
pub(crate) mod auth;
|
||||||
pub(crate) mod config;
|
pub(crate) mod config;
|
||||||
pub(crate) mod entities;
|
pub(crate) mod entities;
|
||||||
pub(crate) mod error;
|
pub(crate) mod error;
|
||||||
pub(crate) mod query;
|
pub(crate) mod query;
|
||||||
pub(crate) mod rating;
|
pub(crate) mod tags;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
pub(crate) mod views;
|
pub(crate) mod views;
|
||||||
|
|
||||||
|
|
@ -33,9 +35,6 @@ use crate::entities::{prelude::SameyUser, samey_user};
|
||||||
pub use crate::error::SameyError;
|
pub use crate::error::SameyError;
|
||||||
use crate::views::*;
|
use crate::views::*;
|
||||||
|
|
||||||
pub(crate) const NEGATIVE_PREFIX: &str = "-";
|
|
||||||
pub(crate) const RATING_PREFIX: &str = "rating:";
|
|
||||||
|
|
||||||
#[derive(rust_embed::Embed)]
|
#[derive(rust_embed::Embed)]
|
||||||
#[folder = "static/"]
|
#[folder = "static/"]
|
||||||
struct Asset;
|
struct Asset;
|
||||||
|
|
@ -63,14 +62,26 @@ pub(crate) struct AppState {
|
||||||
app_config: Arc<RwLock<AppConfig>>,
|
app_config: Arc<RwLock<AppConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function to create a single user.
|
||||||
|
///
|
||||||
|
/// You can specify if they must be an admin user via the `is_admin` flag.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use samey::create_user;
|
||||||
|
///
|
||||||
|
/// # async fn _main() {
|
||||||
|
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
|
||||||
|
/// create_user(db, "admin", "secretPassword", true).await.expect("Unable to add admin user");
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
username: String,
|
username: &str,
|
||||||
password: String,
|
password: &str,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
) -> Result<(), SameyError> {
|
) -> Result<(), SameyError> {
|
||||||
SameyUser::insert(samey_user::ActiveModel {
|
SameyUser::insert(samey_user::ActiveModel {
|
||||||
username: Set(username),
|
username: Set(username.into()),
|
||||||
password: Set(generate_hash(password)),
|
password: Set(generate_hash(password)),
|
||||||
is_admin: Set(is_admin),
|
is_admin: Set(is_admin),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -80,6 +91,18 @@ pub async fn create_user(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates an Axum router for a Samey application.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use samey::get_router;
|
||||||
|
///
|
||||||
|
/// # async fn _main() {
|
||||||
|
/// let db = sea_orm::Database::connect("sqlite:db.sqlite3?mode=rwc").await.unwrap();
|
||||||
|
/// let app = get_router(db, "files").await.unwrap();
|
||||||
|
/// let listener = tokio::net::TcpListener::bind(("0.0.0.0", 3000)).await.unwrap();
|
||||||
|
/// axum::serve(listener, app).await.unwrap();
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
pub async fn get_router(
|
pub async fn get_router(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
files_dir: impl AsRef<Path>,
|
files_dir: impl AsRef<Path>,
|
||||||
|
|
@ -92,7 +115,9 @@ pub async fn get_router(
|
||||||
fs::create_dir_all(files_dir.as_ref()).await?;
|
fs::create_dir_all(files_dir.as_ref()).await?;
|
||||||
|
|
||||||
let session_store = SessionStorage::new(db.clone());
|
let session_store = SessionStorage::new(db.clone());
|
||||||
let session_layer = SessionManagerLayer::new(session_store);
|
let session_layer = SessionManagerLayer::new(session_store).with_expiry(
|
||||||
|
tower_sessions::Expiry::OnInactivity(time::Duration::weeks(1)),
|
||||||
|
);
|
||||||
let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build();
|
let auth_layer = AuthManagerLayerBuilder::new(Backend::new(db), session_layer).build();
|
||||||
|
|
||||||
Ok(Router::new()
|
Ok(Router::new()
|
||||||
|
|
@ -121,12 +146,14 @@ pub async fn get_router(
|
||||||
.route_with_tsr("/pools", get(get_pools))
|
.route_with_tsr("/pools", get(get_pools))
|
||||||
.route_with_tsr("/pools/{page}", get(get_pools_page))
|
.route_with_tsr("/pools/{page}", get(get_pools_page))
|
||||||
.route_with_tsr("/pool", post(create_pool))
|
.route_with_tsr("/pool", post(create_pool))
|
||||||
.route_with_tsr("/pool/{pool_id}", get(view_pool))
|
.route_with_tsr("/pool/{pool_id}", get(view_pool).delete(delete_pool))
|
||||||
.route_with_tsr("/pool/{pool_id}/name", put(change_pool_name))
|
.route_with_tsr("/pool/{pool_id}/name", put(change_pool_name))
|
||||||
.route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility))
|
.route_with_tsr("/pool/{pool_id}/public", put(change_pool_visibility))
|
||||||
.route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool))
|
.route_with_tsr("/pool/{pool_id}/post", post(add_post_to_pool))
|
||||||
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
|
.route_with_tsr("/pool/{pool_id}/sort", put(sort_pool))
|
||||||
.route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post))
|
.route_with_tsr("/pool_post/{pool_post_id}", delete(remove_pool_post))
|
||||||
|
// Bulk edit tag routes
|
||||||
|
.route_with_tsr("/bulk_edit_tag", get(bulk_edit_tag).post(edit_tag))
|
||||||
// Settings routes
|
// Settings routes
|
||||||
.route_with_tsr("/settings", get(settings).post(update_settings))
|
.route_with_tsr("/settings", get(settings).post(update_settings))
|
||||||
// Search routes
|
// Search routes
|
||||||
|
|
@ -134,6 +161,7 @@ pub async fn get_router(
|
||||||
.route_with_tsr("/posts/{page}", get(posts_page))
|
.route_with_tsr("/posts/{page}", get(posts_page))
|
||||||
// Other routes
|
// Other routes
|
||||||
.route_with_tsr("/remove", delete(remove_field))
|
.route_with_tsr("/remove", delete(remove_field))
|
||||||
|
.route("/posts.xml", get(rss_page))
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.nest_service("/files", ServeDir::new(files_dir))
|
.nest_service("/files", ServeDir::new(files_dir))
|
||||||
|
|
|
||||||
61
src/main.rs
61
src/main.rs
|
|
@ -1,54 +1,89 @@
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv6Addr},
|
||||||
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use migration::{Migrator, MigratorTrait};
|
|
||||||
use samey::{create_user, get_router};
|
use samey::{create_user, get_router};
|
||||||
|
use samey_migration::{Migrator, MigratorTrait};
|
||||||
use sea_orm::Database;
|
use sea_orm::Database;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct Config {
|
struct Config {
|
||||||
|
#[arg(short, long, default_value = "sqlite:db.sqlite3?mode=rwc")]
|
||||||
|
database: String,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "files")]
|
||||||
|
files_directory: PathBuf,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
Run,
|
Run {
|
||||||
|
#[arg(short, long, default_value_t = IpAddr::V6(Ipv6Addr::UNSPECIFIED))]
|
||||||
|
address: IpAddr,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value_t = 3000)]
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
|
||||||
Migrate,
|
Migrate,
|
||||||
|
|
||||||
AddAdminUser {
|
AddAdminUser {
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
username: String,
|
username: String,
|
||||||
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
password: String,
|
password: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Commands {
|
||||||
|
fn default() -> Self {
|
||||||
|
Commands::Run {
|
||||||
|
address: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
|
||||||
|
port: 3000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
|
let config = Config::parse();
|
||||||
|
let db = Database::connect(config.database)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to connect to database");
|
.expect("Unable to connect to database");
|
||||||
let config = Config::parse();
|
match config.command.unwrap_or_default() {
|
||||||
match config.command {
|
Commands::Migrate => {
|
||||||
Some(Commands::Migrate) => {
|
|
||||||
Migrator::up(&db, None)
|
Migrator::up(&db, None)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to apply migrations");
|
.expect("Unable to apply migrations");
|
||||||
}
|
}
|
||||||
Some(Commands::AddAdminUser { username, password }) => {
|
|
||||||
create_user(db, username, password, true)
|
Commands::AddAdminUser { username, password } => {
|
||||||
|
create_user(db, &username, &password, true)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to add admin user");
|
.expect("Unable to add admin user");
|
||||||
}
|
}
|
||||||
Some(Commands::Run) | None => {
|
|
||||||
|
Commands::Run { address, port } => {
|
||||||
Migrator::up(&db, None)
|
Migrator::up(&db, None)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to apply migrations");
|
.expect("Unable to apply migrations");
|
||||||
let app = get_router(db, "files")
|
let app = get_router(db, config.files_directory)
|
||||||
.await
|
.await
|
||||||
.expect("Unable to start router");
|
.expect("Unable to start router");
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
let listener = tokio::net::TcpListener::bind((address, port))
|
||||||
.await
|
.await
|
||||||
.expect("Unable to listen to port");
|
.expect("Unable to bind TCP listener");
|
||||||
println!("Listening on http://localhost:3000");
|
if address.is_ipv6() {
|
||||||
|
println!("Listening on http://[{}]:{}", address, port);
|
||||||
|
} else {
|
||||||
|
println!("Listening on http://{}:{}", address, port);
|
||||||
|
}
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
src/query.rs
73
src/query.rs
|
|
@ -1,25 +1,31 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use migration::{Expr, Query};
|
use chrono::NaiveDateTime;
|
||||||
|
use samey_migration::{Expr, Query};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr,
|
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity,
|
||||||
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel,
|
IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns,
|
||||||
Selector,
|
SelectModel, Selector,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
NEGATIVE_PREFIX, RATING_PREFIX, SameyError,
|
SameyError,
|
||||||
auth::User,
|
auth::User,
|
||||||
entities::{
|
entities::{
|
||||||
prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost},
|
prelude::{SameyPool, SameyPoolPost, SameyPost, SameyTag, SameyTagPost},
|
||||||
samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post,
|
samey_pool, samey_pool_post, samey_post, samey_tag, samey_tag_post,
|
||||||
},
|
},
|
||||||
|
tags::{MEDIA_TYPE_PREFIX, NEGATIVE_PREFIX, RATING_PREFIX},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
pub(crate) struct PostOverview {
|
pub(crate) struct PostOverview {
|
||||||
pub(crate) id: i32,
|
pub(crate) id: i32,
|
||||||
pub(crate) thumbnail: String,
|
pub(crate) thumbnail: String,
|
||||||
|
pub(crate) media: String,
|
||||||
|
pub(crate) title: Option<String>,
|
||||||
|
pub(crate) description: Option<String>,
|
||||||
|
pub(crate) uploaded_at: NaiveDateTime,
|
||||||
pub(crate) tags: Option<String>,
|
pub(crate) tags: Option<String>,
|
||||||
pub(crate) media_type: String,
|
pub(crate) media_type: String,
|
||||||
pub(crate) rating: String,
|
pub(crate) rating: String,
|
||||||
|
|
@ -33,17 +39,23 @@ pub(crate) fn search_posts(
|
||||||
let mut exclude_tags = HashSet::<String>::new();
|
let mut exclude_tags = HashSet::<String>::new();
|
||||||
let mut include_ratings = HashSet::<String>::new();
|
let mut include_ratings = HashSet::<String>::new();
|
||||||
let mut exclude_ratings = HashSet::<String>::new();
|
let mut exclude_ratings = HashSet::<String>::new();
|
||||||
|
let mut include_types = HashSet::<String>::new();
|
||||||
|
let mut exclude_types = HashSet::<String>::new();
|
||||||
if let Some(tags) = tags {
|
if let Some(tags) = tags {
|
||||||
for mut tag in tags.iter().map(|tag| tag.to_lowercase()) {
|
for tag in tags.iter().map(|tag| tag.to_lowercase()) {
|
||||||
if tag.starts_with(NEGATIVE_PREFIX) {
|
if let Some(negative_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
|
||||||
if tag.as_str()[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) {
|
if let Some(negative_rating_tag) = negative_tag.strip_prefix(RATING_PREFIX) {
|
||||||
exclude_ratings
|
exclude_ratings.insert(negative_rating_tag.into());
|
||||||
.insert(tag.split_off(NEGATIVE_PREFIX.len() + RATING_PREFIX.len()));
|
} else if let Some(negative_type_tag) = negative_tag.strip_prefix(MEDIA_TYPE_PREFIX)
|
||||||
|
{
|
||||||
|
exclude_types.insert(negative_type_tag.into());
|
||||||
} else {
|
} else {
|
||||||
exclude_tags.insert(tag.split_off(NEGATIVE_PREFIX.len()));
|
exclude_tags.insert(negative_tag.into());
|
||||||
}
|
}
|
||||||
} else if tag.starts_with(RATING_PREFIX) {
|
} else if let Some(rating_tag) = tag.strip_prefix(RATING_PREFIX) {
|
||||||
include_ratings.insert(tag.split_off(RATING_PREFIX.len()));
|
include_ratings.insert(rating_tag.into());
|
||||||
|
} else if let Some(type_tag) = tag.strip_prefix(MEDIA_TYPE_PREFIX) {
|
||||||
|
include_types.insert(type_tag.into());
|
||||||
} else {
|
} else {
|
||||||
include_tags.insert(tag);
|
include_tags.insert(tag);
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +66,10 @@ pub(crate) fn search_posts(
|
||||||
let mut query = SameyPost::find()
|
let mut query = SameyPost::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(samey_post::Column::Id)
|
.column(samey_post::Column::Id)
|
||||||
|
.column(samey_post::Column::Media)
|
||||||
|
.column(samey_post::Column::Title)
|
||||||
|
.column(samey_post::Column::Description)
|
||||||
|
.column(samey_post::Column::UploadedAt)
|
||||||
.column(samey_post::Column::Thumbnail)
|
.column(samey_post::Column::Thumbnail)
|
||||||
.column(samey_post::Column::Rating)
|
.column(samey_post::Column::Rating)
|
||||||
.column(samey_post::Column::MediaType)
|
.column(samey_post::Column::MediaType)
|
||||||
|
|
@ -72,11 +88,21 @@ pub(crate) fn search_posts(
|
||||||
if !exclude_ratings.is_empty() {
|
if !exclude_ratings.is_empty() {
|
||||||
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
|
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
|
||||||
}
|
}
|
||||||
|
if !include_types.is_empty() {
|
||||||
|
query = query.filter(samey_post::Column::MediaType.is_in(include_types))
|
||||||
|
}
|
||||||
|
if !exclude_types.is_empty() {
|
||||||
|
query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types))
|
||||||
|
}
|
||||||
query
|
query
|
||||||
} else {
|
} else {
|
||||||
let mut query = SameyPost::find()
|
let mut query = SameyPost::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(samey_post::Column::Id)
|
.column(samey_post::Column::Id)
|
||||||
|
.column(samey_post::Column::Media)
|
||||||
|
.column(samey_post::Column::Title)
|
||||||
|
.column(samey_post::Column::Description)
|
||||||
|
.column(samey_post::Column::UploadedAt)
|
||||||
.column(samey_post::Column::Thumbnail)
|
.column(samey_post::Column::Thumbnail)
|
||||||
.column(samey_post::Column::Rating)
|
.column(samey_post::Column::Rating)
|
||||||
.column(samey_post::Column::MediaType)
|
.column(samey_post::Column::MediaType)
|
||||||
|
|
@ -134,6 +160,12 @@ pub(crate) fn search_posts(
|
||||||
if !exclude_ratings.is_empty() {
|
if !exclude_ratings.is_empty() {
|
||||||
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
|
query = query.filter(samey_post::Column::Rating.is_not_in(exclude_ratings))
|
||||||
}
|
}
|
||||||
|
if !include_types.is_empty() {
|
||||||
|
query = query.filter(samey_post::Column::MediaType.is_in(include_types))
|
||||||
|
}
|
||||||
|
if !exclude_types.is_empty() {
|
||||||
|
query = query.filter(samey_post::Column::MediaType.is_not_in(exclude_types))
|
||||||
|
}
|
||||||
query
|
query
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -263,3 +295,18 @@ pub(crate) fn filter_posts_by_user(
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn clean_dangling_tags(db: &DatabaseConnection) -> Result<(), SameyError> {
|
||||||
|
let dangling_tags = SameyTag::find()
|
||||||
|
.select_column_as(samey_tag_post::Column::Id.count(), "count")
|
||||||
|
.left_join(SameyTagPost)
|
||||||
|
.group_by(samey_tag::Column::Id)
|
||||||
|
.having(Expr::column("count".into_identity()).eq(0))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
SameyTag::delete_many()
|
||||||
|
.filter(samey_tag::Column::Id.is_in(dangling_tags.into_iter().map(|tag| tag.id)))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub(crate) enum Rating {
|
|
||||||
Unrated,
|
|
||||||
Safe,
|
|
||||||
Questionable,
|
|
||||||
Explicit,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for Rating {
|
|
||||||
fn from(value: String) -> Self {
|
|
||||||
match value.as_ref() {
|
|
||||||
"s" => Self::Safe,
|
|
||||||
"q" => Self::Questionable,
|
|
||||||
"e" => Self::Explicit,
|
|
||||||
_ => Self::Unrated,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Rating {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Rating::Unrated => f.write_str("Unrated"),
|
|
||||||
Rating::Safe => f.write_str("Safe"),
|
|
||||||
Rating::Questionable => f.write_str("Questionable"),
|
|
||||||
Rating::Explicit => f.write_str("Explicit"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
src/tags.rs
Normal file
23
src/tags.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
pub(crate) const NEGATIVE_PREFIX: &str = "-";
|
||||||
|
pub(crate) const RATING_PREFIX: &str = "rating:";
|
||||||
|
pub(crate) const MEDIA_TYPE_PREFIX: &str = "type:";
|
||||||
|
|
||||||
|
#[derive(strum::EnumIter, strum::Display, Debug)]
|
||||||
|
pub(crate) enum Rating {
|
||||||
|
#[strum(serialize = "u")]
|
||||||
|
Unrated,
|
||||||
|
#[strum(serialize = "s")]
|
||||||
|
Safe,
|
||||||
|
#[strum(serialize = "q")]
|
||||||
|
Questionable,
|
||||||
|
#[strum(serialize = "e")]
|
||||||
|
Explicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(strum::EnumIter, strum::Display, Debug)]
|
||||||
|
pub(crate) enum MediaType {
|
||||||
|
#[strum(serialize = "image")]
|
||||||
|
Image,
|
||||||
|
#[strum(serialize = "video")]
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ pub(crate) fn generate_thumbnail(
|
||||||
max_thumbnail_dimension: u32,
|
max_thumbnail_dimension: u32,
|
||||||
) -> Result<(), SameyError> {
|
) -> Result<(), SameyError> {
|
||||||
let status = Command::new("ffmpeg")
|
let status = Command::new("ffmpeg")
|
||||||
.args(&[
|
.args([
|
||||||
"-i",
|
"-i",
|
||||||
input_path,
|
input_path,
|
||||||
"-vf",
|
"-vf",
|
||||||
|
|
@ -39,7 +39,7 @@ pub(crate) fn generate_thumbnail(
|
||||||
|
|
||||||
pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> {
|
pub(crate) fn get_dimensions_for_video(input_path: &str) -> Result<(u32, u32), SameyError> {
|
||||||
let output = Command::new("ffprobe")
|
let output = Command::new("ffprobe")
|
||||||
.args(&[
|
.args([
|
||||||
"-v",
|
"-v",
|
||||||
"error",
|
"error",
|
||||||
"-select_streams",
|
"-select_streams",
|
||||||
|
|
|
||||||
442
src/views.rs
442
src/views.rs
|
|
@ -17,19 +17,20 @@ use axum_extra::extract::Form;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use image::{GenericImageView, ImageFormat, ImageReader};
|
use image::{GenericImageView, ImageFormat, ImageReader};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use migration::{Expr, OnConflict};
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use samey_migration::{OnConflict, Query as MigrationQuery};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr,
|
ActiveValue::Set, ColumnTrait, Condition, EntityTrait, FromQueryResult, IntoSimpleExpr,
|
||||||
ModelTrait, PaginatorTrait, QueryFilter, QuerySelect,
|
ModelTrait, PaginatorTrait, QueryFilter, QuerySelect,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
use tokio::{task::spawn_blocking, try_join};
|
use tokio::{task::spawn_blocking, try_join};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState, NEGATIVE_PREFIX, RATING_PREFIX,
|
AppState,
|
||||||
auth::{AuthSession, Credentials, User},
|
auth::{AuthSession, Credentials, User},
|
||||||
config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY},
|
config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY},
|
||||||
entities::{
|
entities::{
|
||||||
prelude::{
|
prelude::{
|
||||||
SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
|
SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
|
||||||
|
|
@ -40,9 +41,10 @@ use crate::{
|
||||||
},
|
},
|
||||||
error::SameyError,
|
error::SameyError,
|
||||||
query::{
|
query::{
|
||||||
PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post,
|
PoolPost, PostOverview, PostPoolData, clean_dangling_tags, filter_posts_by_user,
|
||||||
get_posts_in_pool, get_tags_for_post, search_posts,
|
get_pool_data_for_post, get_posts_in_pool, get_tags_for_post, search_posts,
|
||||||
},
|
},
|
||||||
|
tags::{MEDIA_TYPE_PREFIX, MediaType, NEGATIVE_PREFIX, RATING_PREFIX, Rating},
|
||||||
video::{generate_thumbnail, get_dimensions_for_video},
|
video::{generate_thumbnail, get_dimensions_for_video},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -90,6 +92,63 @@ pub(crate) async fn index(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RSS view
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "fragments/rss_entry.html")]
|
||||||
|
struct RssEntryTemplate<'a> {
|
||||||
|
post: PostOverview,
|
||||||
|
base_url: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub(crate) async fn rss_page(
|
||||||
|
State(AppState { app_config, db, .. }): State<AppState>,
|
||||||
|
Query(query): Query<PostsQuery>,
|
||||||
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
|
let app_config = app_config.read().await;
|
||||||
|
let application_name = app_config.application_name.clone();
|
||||||
|
let base_url = app_config.base_url.clone();
|
||||||
|
drop(app_config);
|
||||||
|
|
||||||
|
let tags = query
|
||||||
|
.tags
|
||||||
|
.as_ref()
|
||||||
|
.map(|tags| tags.split_whitespace().collect::<Vec<_>>());
|
||||||
|
|
||||||
|
let posts = search_posts(tags.as_ref(), None)
|
||||||
|
.paginate(&db, 20)
|
||||||
|
.fetch_page(0)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let channel = rss::ChannelBuilder::default()
|
||||||
|
.title(&application_name)
|
||||||
|
.link(&base_url)
|
||||||
|
.items(
|
||||||
|
posts
|
||||||
|
.into_iter()
|
||||||
|
.map(|post| {
|
||||||
|
rss::ItemBuilder::default()
|
||||||
|
.title(post.tags.clone())
|
||||||
|
.pub_date(post.uploaded_at.and_utc().to_rfc2822())
|
||||||
|
.link(format!("{}/post/{}", &base_url, post.id))
|
||||||
|
.content(
|
||||||
|
RssEntryTemplate {
|
||||||
|
post,
|
||||||
|
base_url: &base_url,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.ok(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
.collect_vec(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok(channel.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
// Auth views
|
// Auth views
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -126,24 +185,17 @@ pub(crate) async fn login(
|
||||||
mut auth_session: AuthSession,
|
mut auth_session: AuthSession,
|
||||||
Form(credentials): Form<Credentials>,
|
Form(credentials): Form<Credentials>,
|
||||||
) -> Result<impl IntoResponse, SameyError> {
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
let user = match auth_session.authenticate(credentials).await {
|
let user = match auth_session.authenticate(credentials).await? {
|
||||||
Ok(Some(user)) => user,
|
Some(user) => user,
|
||||||
Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())),
|
None => return Err(SameyError::Authentication("Invalid credentials".into())),
|
||||||
Err(_) => return Err(SameyError::Other("Auth session error".into())),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
auth_session
|
auth_session.login(&user).await?;
|
||||||
.login(&user)
|
|
||||||
.await
|
|
||||||
.map_err(|_| SameyError::Other("Login failed".into()))?;
|
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
|
pub(crate) async fn logout(mut auth_session: AuthSession) -> Result<impl IntoResponse, SameyError> {
|
||||||
auth_session
|
auth_session.logout().await?;
|
||||||
.logout()
|
|
||||||
.await
|
|
||||||
.map_err(|_| SameyError::Other("Logout error".into()))?;
|
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,10 +255,9 @@ impl FromStr for Format {
|
||||||
"application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")),
|
"application/x-matroska" | "video/mastroska" => Ok(Self::Video(".mkv")),
|
||||||
"video/quicktime" => Ok(Self::Video(".mov")),
|
"video/quicktime" => Ok(Self::Video(".mov")),
|
||||||
_ => Ok(Self::Image(
|
_ => Ok(Self::Image(
|
||||||
ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!(
|
ImageFormat::from_mime_type(content_type).ok_or(SameyError::BadRequest(
|
||||||
"Unknown content type: {}",
|
format!("Unknown content type: {}", content_type),
|
||||||
content_type
|
))?,
|
||||||
)))?,
|
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +328,7 @@ pub(crate) async fn upload(
|
||||||
"media-file" => {
|
"media-file" => {
|
||||||
let content_type = field
|
let content_type = field
|
||||||
.content_type()
|
.content_type()
|
||||||
.ok_or(SameyError::Other("Missing content type".into()))?;
|
.ok_or(SameyError::BadRequest("Missing content type".into()))?;
|
||||||
match Format::from_str(content_type)? {
|
match Format::from_str(content_type)? {
|
||||||
format @ Format::Video(video_format) => {
|
format @ Format::Video(video_format) => {
|
||||||
media_type = Some(format.media_type());
|
media_type = Some(format.media_type());
|
||||||
|
|
@ -300,6 +351,7 @@ pub(crate) async fn upload(
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
.open(&file_path)?;
|
.open(&file_path)?;
|
||||||
while let Some(chunk) = field.chunk().await? {
|
while let Some(chunk) = field.chunk().await? {
|
||||||
file.write_all(&chunk)?;
|
file.write_all(&chunk)?;
|
||||||
|
|
@ -350,6 +402,7 @@ pub(crate) async fn upload(
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
.open(&file_path)?;
|
.open(&file_path)?;
|
||||||
while let Some(chunk) = field.chunk().await? {
|
while let Some(chunk) = field.chunk().await? {
|
||||||
file.write_all(&chunk)?;
|
file.write_all(&chunk)?;
|
||||||
|
|
@ -443,7 +496,9 @@ pub(crate) async fn upload(
|
||||||
|
|
||||||
Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
|
Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
|
||||||
} else {
|
} else {
|
||||||
Err(SameyError::Other("Missing parameters for upload".into()))
|
Err(SameyError::BadRequest(
|
||||||
|
"Missing parameters for upload".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -471,32 +526,43 @@ pub(crate) async fn search_tags(
|
||||||
State(AppState { db, .. }): State<AppState>,
|
State(AppState { db, .. }): State<AppState>,
|
||||||
Form(body): Form<SearchTagsForm>,
|
Form(body): Form<SearchTagsForm>,
|
||||||
) -> Result<impl IntoResponse, SameyError> {
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
let tags = match body.tags[..body.selection_end].split(' ').last() {
|
let tags = match body.tags[..body.selection_end].split(' ').next_back() {
|
||||||
Some(mut tag) => {
|
Some(mut tag) => {
|
||||||
tag = tag.trim();
|
tag = tag.trim();
|
||||||
if tag.is_empty() {
|
if tag.is_empty() {
|
||||||
vec![]
|
vec![]
|
||||||
} else if tag.starts_with(NEGATIVE_PREFIX) {
|
} else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
|
||||||
if tag[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) {
|
if stripped_tag.starts_with(RATING_PREFIX) {
|
||||||
[
|
Rating::iter()
|
||||||
format!("{}u", RATING_PREFIX),
|
.map(|rating| format!("{}{}", RATING_PREFIX, rating))
|
||||||
format!("{}s", RATING_PREFIX),
|
.filter(|t| t.starts_with(stripped_tag))
|
||||||
format!("{}q", RATING_PREFIX),
|
.map(|tag| SearchTag {
|
||||||
format!("{}e", RATING_PREFIX),
|
value: format!("-{}", &tag),
|
||||||
]
|
name: tag,
|
||||||
.into_iter()
|
})
|
||||||
.filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..]))
|
.collect()
|
||||||
.map(|tag| SearchTag {
|
} else if stripped_tag.starts_with(MEDIA_TYPE_PREFIX) {
|
||||||
value: format!("-{}", &tag),
|
MediaType::iter()
|
||||||
name: tag,
|
.map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
|
||||||
})
|
.filter(|t| t.starts_with(stripped_tag))
|
||||||
.collect()
|
.map(|tag| SearchTag {
|
||||||
|
value: format!("-{}", &tag),
|
||||||
|
name: tag,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
SameyTag::find()
|
SameyTag::find()
|
||||||
.filter(Expr::cust_with_expr(
|
.filter(
|
||||||
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
|
Condition::any()
|
||||||
tag[NEGATIVE_PREFIX.len()..].to_lowercase(),
|
.add(
|
||||||
))
|
samey_tag::Column::NormalizedName
|
||||||
|
.starts_with(stripped_tag.to_lowercase()),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
samey_tag::Column::NormalizedName
|
||||||
|
.contains(format!(":{}", stripped_tag.to_lowercase())),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -508,25 +574,33 @@ pub(crate) async fn search_tags(
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
} else if tag.starts_with(RATING_PREFIX) {
|
} else if tag.starts_with(RATING_PREFIX) {
|
||||||
[
|
Rating::iter()
|
||||||
format!("{}u", RATING_PREFIX),
|
.map(|rating| format!("{}{}", RATING_PREFIX, rating))
|
||||||
format!("{}s", RATING_PREFIX),
|
.filter(|t| t.starts_with(tag))
|
||||||
format!("{}q", RATING_PREFIX),
|
.map(|tag| SearchTag {
|
||||||
format!("{}e", RATING_PREFIX),
|
value: tag.clone(),
|
||||||
]
|
name: tag,
|
||||||
.into_iter()
|
})
|
||||||
.filter(|t| t.starts_with(tag))
|
.collect()
|
||||||
.map(|tag| SearchTag {
|
} else if tag.starts_with(MEDIA_TYPE_PREFIX) {
|
||||||
value: tag.clone(),
|
MediaType::iter()
|
||||||
name: tag,
|
.map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
|
||||||
})
|
.filter(|t| t.starts_with(tag))
|
||||||
.collect()
|
.map(|tag| SearchTag {
|
||||||
|
value: tag.clone(),
|
||||||
|
name: tag,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
SameyTag::find()
|
SameyTag::find()
|
||||||
.filter(Expr::cust_with_expr(
|
.filter(
|
||||||
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
|
Condition::any()
|
||||||
tag.to_lowercase(),
|
.add(samey_tag::Column::NormalizedName.starts_with(tag.to_lowercase()))
|
||||||
))
|
.add(
|
||||||
|
samey_tag::Column::NormalizedName
|
||||||
|
.contains(format!(":{}", tag.to_lowercase())),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -1058,12 +1132,10 @@ pub(crate) async fn sort_pool(
|
||||||
};
|
};
|
||||||
let max_index = if body.new_index == posts.len().saturating_sub(1) {
|
let max_index = if body.new_index == posts.len().saturating_sub(1) {
|
||||||
None
|
None
|
||||||
|
} else if body.new_index < body.old_index {
|
||||||
|
Some(body.new_index)
|
||||||
} else {
|
} else {
|
||||||
if body.new_index < body.old_index {
|
Some(body.new_index + 1)
|
||||||
Some(body.new_index)
|
|
||||||
} else {
|
|
||||||
Some(body.new_index + 1)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let min = min_index.map(|index| posts[index].position).unwrap_or(0.0);
|
let min = min_index.map(|index| posts[index].position).unwrap_or(0.0);
|
||||||
let max = max_index
|
let max = max_index
|
||||||
|
|
@ -1091,12 +1163,170 @@ pub(crate) async fn sort_pool(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_pool(
|
||||||
|
State(AppState { db, .. }): State<AppState>,
|
||||||
|
auth_session: AuthSession,
|
||||||
|
Path(pool_id): Path<i32>,
|
||||||
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
|
let pool = SameyPool::find_by_id(pool_id)
|
||||||
|
.one(&db)
|
||||||
|
.await?
|
||||||
|
.ok_or(SameyError::NotFound)?;
|
||||||
|
|
||||||
|
let can_edit = match auth_session.user.as_ref() {
|
||||||
|
None => false,
|
||||||
|
Some(user) => user.is_admin || pool.uploader_id == user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !can_edit {
|
||||||
|
return Err(SameyError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
SameyPool::delete_by_id(pool_id).exec(&db).await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk edit tag views
|
||||||
|
|
||||||
|
enum BulkEditTagMessage {
|
||||||
|
None,
|
||||||
|
Success,
|
||||||
|
Failure(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/bulk_edit_tag.html")]
|
||||||
|
struct BulkEditTagTemplate {
|
||||||
|
application_name: String,
|
||||||
|
age_confirmation: bool,
|
||||||
|
message: BulkEditTagMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn bulk_edit_tag(
|
||||||
|
State(AppState { app_config, .. }): State<AppState>,
|
||||||
|
auth_session: AuthSession,
|
||||||
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
|
if auth_session.user.is_none_or(|user| !user.is_admin) {
|
||||||
|
return Err(SameyError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_config = app_config.read().await;
|
||||||
|
let application_name = app_config.application_name.clone();
|
||||||
|
let age_confirmation = app_config.age_confirmation;
|
||||||
|
drop(app_config);
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
BulkEditTagTemplate {
|
||||||
|
application_name,
|
||||||
|
age_confirmation,
|
||||||
|
message: BulkEditTagMessage::None,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(crate) struct EditTagForm {
|
||||||
|
tags: String,
|
||||||
|
new_tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn edit_tag(
|
||||||
|
State(AppState { db, app_config, .. }): State<AppState>,
|
||||||
|
auth_session: AuthSession,
|
||||||
|
Form(body): Form<EditTagForm>,
|
||||||
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
|
if auth_session.user.is_none_or(|user| !user.is_admin) {
|
||||||
|
return Err(SameyError::Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_config = app_config.read().await;
|
||||||
|
let application_name = app_config.application_name.clone();
|
||||||
|
let age_confirmation = app_config.age_confirmation;
|
||||||
|
drop(app_config);
|
||||||
|
|
||||||
|
let old_tag: Vec<_> = body.tags.split_whitespace().collect();
|
||||||
|
if old_tag.len() != 1 {
|
||||||
|
return Ok(Html(
|
||||||
|
BulkEditTagTemplate {
|
||||||
|
application_name,
|
||||||
|
age_confirmation,
|
||||||
|
message: BulkEditTagMessage::Failure("expected single tag to edit".into()),
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let old_tag = old_tag.first().unwrap();
|
||||||
|
let normalized_old_tag = old_tag.to_lowercase();
|
||||||
|
|
||||||
|
let new_tag: Vec<_> = body.new_tag.split_whitespace().collect();
|
||||||
|
if new_tag.len() != 1 {
|
||||||
|
return Ok(Html(
|
||||||
|
BulkEditTagTemplate {
|
||||||
|
application_name,
|
||||||
|
age_confirmation,
|
||||||
|
message: BulkEditTagMessage::Failure("expected single new tag".into()),
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let new_tag = new_tag.first().unwrap();
|
||||||
|
let normalized_new_tag = new_tag.to_lowercase();
|
||||||
|
|
||||||
|
let old_tag_db = SameyTag::find()
|
||||||
|
.filter(samey_tag::Column::NormalizedName.eq(&normalized_old_tag))
|
||||||
|
.one(&db)
|
||||||
|
.await?
|
||||||
|
.ok_or(SameyError::NotFound)?;
|
||||||
|
|
||||||
|
if let Some(new_tag_db) = SameyTag::find()
|
||||||
|
.filter(samey_tag::Column::NormalizedName.eq(&normalized_new_tag))
|
||||||
|
.one(&db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let subquery = MigrationQuery::select()
|
||||||
|
.column((SameyTagPost, samey_tag_post::Column::PostId))
|
||||||
|
.from(SameyTagPost)
|
||||||
|
.and_where(samey_tag_post::Column::TagId.eq(new_tag_db.id))
|
||||||
|
.to_owned();
|
||||||
|
SameyTagPost::update_many()
|
||||||
|
.filter(samey_tag_post::Column::TagId.eq(old_tag_db.id))
|
||||||
|
.filter(samey_tag_post::Column::PostId.not_in_subquery(subquery))
|
||||||
|
.set(samey_tag_post::ActiveModel {
|
||||||
|
tag_id: Set(new_tag_db.id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(&db)
|
||||||
|
.await?;
|
||||||
|
SameyTag::delete_by_id(old_tag_db.id).exec(&db).await?;
|
||||||
|
} else {
|
||||||
|
SameyTag::update(samey_tag::ActiveModel {
|
||||||
|
id: Set(old_tag_db.id),
|
||||||
|
name: Set(new_tag.to_string()),
|
||||||
|
normalized_name: Set(normalized_new_tag),
|
||||||
|
})
|
||||||
|
.exec(&db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
BulkEditTagTemplate {
|
||||||
|
application_name,
|
||||||
|
age_confirmation,
|
||||||
|
message: BulkEditTagMessage::Success,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// Settings views
|
// Settings views
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pages/settings.html")]
|
#[template(path = "pages/settings.html")]
|
||||||
struct SettingsTemplate {
|
struct SettingsTemplate {
|
||||||
application_name: String,
|
application_name: String,
|
||||||
|
base_url: String,
|
||||||
age_confirmation: bool,
|
age_confirmation: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1110,6 +1340,7 @@ pub(crate) async fn settings(
|
||||||
|
|
||||||
let app_config = app_config.read().await;
|
let app_config = app_config.read().await;
|
||||||
let application_name = app_config.application_name.clone();
|
let application_name = app_config.application_name.clone();
|
||||||
|
let base_url = app_config.base_url.clone();
|
||||||
let age_confirmation = app_config.age_confirmation;
|
let age_confirmation = app_config.age_confirmation;
|
||||||
drop(app_config);
|
drop(app_config);
|
||||||
|
|
||||||
|
|
@ -1129,6 +1360,7 @@ pub(crate) async fn settings(
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
SettingsTemplate {
|
SettingsTemplate {
|
||||||
application_name,
|
application_name,
|
||||||
|
base_url,
|
||||||
age_confirmation,
|
age_confirmation,
|
||||||
}
|
}
|
||||||
.render_with_values(&values)?,
|
.render_with_values(&values)?,
|
||||||
|
|
@ -1138,11 +1370,18 @@ pub(crate) async fn settings(
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct UpdateSettingsForm {
|
pub(crate) struct UpdateSettingsForm {
|
||||||
application_name: String,
|
application_name: String,
|
||||||
|
base_url: String,
|
||||||
|
favicon_post_id: String,
|
||||||
age_confirmation: Option<bool>,
|
age_confirmation: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_settings(
|
pub(crate) async fn update_settings(
|
||||||
State(AppState { db, app_config, .. }): State<AppState>,
|
State(AppState {
|
||||||
|
db,
|
||||||
|
app_config,
|
||||||
|
files_dir,
|
||||||
|
..
|
||||||
|
}): State<AppState>,
|
||||||
auth_session: AuthSession,
|
auth_session: AuthSession,
|
||||||
Form(body): Form<UpdateSettingsForm>,
|
Form(body): Form<UpdateSettingsForm>,
|
||||||
) -> Result<impl IntoResponse, SameyError> {
|
) -> Result<impl IntoResponse, SameyError> {
|
||||||
|
|
@ -1152,18 +1391,27 @@ pub(crate) async fn update_settings(
|
||||||
|
|
||||||
let mut configs = vec![];
|
let mut configs = vec![];
|
||||||
|
|
||||||
if !body.application_name.is_empty() {
|
let application_name = body.application_name.trim();
|
||||||
|
if !application_name.is_empty() {
|
||||||
let _ = mem::replace(
|
let _ = mem::replace(
|
||||||
&mut app_config.write().await.application_name,
|
&mut app_config.write().await.application_name,
|
||||||
body.application_name.clone(),
|
application_name.into(),
|
||||||
);
|
);
|
||||||
configs.push(samey_config::ActiveModel {
|
configs.push(samey_config::ActiveModel {
|
||||||
key: Set(APPLICATION_NAME_KEY.into()),
|
key: Set(APPLICATION_NAME_KEY.into()),
|
||||||
data: Set(body.application_name.into()),
|
data: Set(application_name.into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let base_url = body.base_url.trim_end_matches('/');
|
||||||
|
let _ = mem::replace(&mut app_config.write().await.base_url, base_url.into());
|
||||||
|
configs.push(samey_config::ActiveModel {
|
||||||
|
key: Set(BASE_URL_KEY.into()),
|
||||||
|
data: Set(base_url.into()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
let age_confirmation = body.age_confirmation.is_some();
|
let age_confirmation = body.age_confirmation.is_some();
|
||||||
let _ = mem::replace(
|
let _ = mem::replace(
|
||||||
&mut app_config.write().await.age_confirmation,
|
&mut app_config.write().await.age_confirmation,
|
||||||
|
|
@ -1186,6 +1434,21 @@ pub(crate) async fn update_settings(
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(favicon_post_id) = body.favicon_post_id.split_whitespace().next() {
|
||||||
|
match favicon_post_id.parse::<i32>() {
|
||||||
|
Ok(favicon_post_id) => {
|
||||||
|
let post = SameyPost::find_by_id(favicon_post_id)
|
||||||
|
.one(&db)
|
||||||
|
.await?
|
||||||
|
.ok_or(SameyError::NotFound)?;
|
||||||
|
ImageReader::open(files_dir.join(post.thumbnail))?
|
||||||
|
.decode()?
|
||||||
|
.save_with_format(files_dir.join("favicon.png"), ImageFormat::Png)?;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(SameyError::IntParse(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1248,6 +1511,10 @@ pub(crate) async fn view_post_page(
|
||||||
Some(parent_post) => Some(PostOverview {
|
Some(parent_post) => Some(PostOverview {
|
||||||
id: parent_id,
|
id: parent_id,
|
||||||
thumbnail: parent_post.thumbnail,
|
thumbnail: parent_post.thumbnail,
|
||||||
|
title: parent_post.title,
|
||||||
|
description: parent_post.description,
|
||||||
|
uploaded_at: parent_post.uploaded_at,
|
||||||
|
media: parent_post.media,
|
||||||
tags: Some(
|
tags: Some(
|
||||||
get_tags_for_post(post_id)
|
get_tags_for_post(post_id)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
|
|
@ -1277,6 +1544,10 @@ pub(crate) async fn view_post_page(
|
||||||
children_posts.push(PostOverview {
|
children_posts.push(PostOverview {
|
||||||
id: child_post.id,
|
id: child_post.id,
|
||||||
thumbnail: child_post.thumbnail,
|
thumbnail: child_post.thumbnail,
|
||||||
|
title: child_post.title,
|
||||||
|
description: child_post.description,
|
||||||
|
uploaded_at: child_post.uploaded_at,
|
||||||
|
media: child_post.media,
|
||||||
tags: Some(
|
tags: Some(
|
||||||
get_tags_for_post(child_post.id)
|
get_tags_for_post(child_post.id)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
|
|
@ -1371,6 +1642,7 @@ struct SubmitPostDetailsTemplate {
|
||||||
parent_post: Option<PostOverview>,
|
parent_post: Option<PostOverview>,
|
||||||
sources: Vec<samey_post_source::Model>,
|
sources: Vec<samey_post_source::Model>,
|
||||||
tags: Vec<samey_tag::Model>,
|
tags: Vec<samey_tag::Model>,
|
||||||
|
tags_text: String,
|
||||||
can_edit: bool,
|
can_edit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1395,14 +1667,14 @@ pub(crate) async fn submit_post_details(
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = match body.title.trim() {
|
let title = match body.title.trim() {
|
||||||
title if title.is_empty() => None,
|
"" => None,
|
||||||
title => Some(title.to_owned()),
|
title => Some(title.to_owned()),
|
||||||
};
|
};
|
||||||
let description = match body.description.trim() {
|
let description = match body.description.trim() {
|
||||||
description if description.is_empty() => None,
|
"" => None,
|
||||||
description => Some(description.to_owned()),
|
description => Some(description.to_owned()),
|
||||||
};
|
};
|
||||||
let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() {
|
let parent_post = if let Ok(parent_id) = body.parent_post.trim().parse() {
|
||||||
match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
|
match filter_posts_by_user(SameyPost::find_by_id(parent_id), auth_session.user.as_ref())
|
||||||
.one(&db)
|
.one(&db)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -1410,6 +1682,10 @@ pub(crate) async fn submit_post_details(
|
||||||
Some(parent_post) => Some(PostOverview {
|
Some(parent_post) => Some(PostOverview {
|
||||||
id: parent_id,
|
id: parent_id,
|
||||||
thumbnail: parent_post.thumbnail,
|
thumbnail: parent_post.thumbnail,
|
||||||
|
title: parent_post.title,
|
||||||
|
description: parent_post.description,
|
||||||
|
uploaded_at: parent_post.uploaded_at,
|
||||||
|
media: parent_post.media,
|
||||||
tags: Some(
|
tags: Some(
|
||||||
get_tags_for_post(post_id)
|
get_tags_for_post(post_id)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
|
|
@ -1497,17 +1773,31 @@ pub(crate) async fn submit_post_details(
|
||||||
upload_tags.sort_by(|a, b| a.name.cmp(&b.name));
|
upload_tags.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
upload_tags
|
upload_tags
|
||||||
};
|
};
|
||||||
|
let mut tags_text = String::new();
|
||||||
|
for tag in &tags {
|
||||||
|
if !tags_text.is_empty() {
|
||||||
|
tags_text.push(' ');
|
||||||
|
}
|
||||||
|
tags_text.push_str(&tag.name);
|
||||||
|
}
|
||||||
|
|
||||||
let sources = SameyPostSource::find()
|
let sources = SameyPostSource::find()
|
||||||
.filter(samey_post_source::Column::PostId.eq(post_id))
|
.filter(samey_post_source::Column::PostId.eq(post_id))
|
||||||
.all(&db)
|
.all(&db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = clean_dangling_tags(&db).await {
|
||||||
|
println!("Error when cleaning dangling tags - {}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
SubmitPostDetailsTemplate {
|
SubmitPostDetailsTemplate {
|
||||||
post,
|
post,
|
||||||
sources,
|
sources,
|
||||||
tags,
|
tags,
|
||||||
|
tags_text,
|
||||||
parent_post,
|
parent_post,
|
||||||
can_edit: true,
|
can_edit: true,
|
||||||
}
|
}
|
||||||
|
|
@ -1615,9 +1905,11 @@ pub(crate) async fn delete_post(
|
||||||
SameyPost::delete_by_id(post.id).exec(&db).await?;
|
SameyPost::delete_by_id(post.id).exec(&db).await?;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let base_path = files_dir.as_ref();
|
let _ = std::fs::remove_file(files_dir.join(post.media));
|
||||||
let _ = std::fs::remove_file(base_path.join(post.media));
|
let _ = std::fs::remove_file(files_dir.join(post.thumbnail));
|
||||||
let _ = std::fs::remove_file(base_path.join(post.thumbnail));
|
if let Err(err) = clean_dangling_tags(&db).await {
|
||||||
|
println!("Error when cleaning dangling tags - {}", err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/files/favicon.png" />
|
||||||
<script src="/static/htmx.js"></script>
|
<script src="/static/htmx.js"></script>
|
||||||
<script defer src="/static/alpine.js"></script>
|
<script defer src="/static/alpine.js"></script>
|
||||||
<link rel="stylesheet" href="/static/water.css" />
|
<link rel="stylesheet" href="/static/water.css" />
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,55 @@
|
||||||
<form hx-put="/post_details/{{ post.id }}" hx-target="this" hx-swap="outerHTML">
|
<article id="post-details">
|
||||||
<div>
|
<form hx-put="/post_details/{{ post.id }}" hx-target="#post-details" hx-swap="outerHTML">
|
||||||
<label>Tags</label>
|
<div>
|
||||||
{% let tags_value = tags %} {% include "fragments/tags_input.html" %}
|
<label>Tags</label>
|
||||||
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
{% let tags_value = tags %} {% include "fragments/tags_input.html" %}
|
||||||
</div>
|
<div
|
||||||
<div>
|
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
||||||
<label>Title</label>
|
hx-target="next .tags-autocomplete"
|
||||||
<input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
|
hx-swap="innerHTML"
|
||||||
</div>
|
hx-delete="/remove"
|
||||||
<div>
|
hidden
|
||||||
<label>Description</label>
|
></div>
|
||||||
<textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
|
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Is public post?</label>
|
<label>Title</label>
|
||||||
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
|
<input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Rating</label>
|
<label>Description</label>
|
||||||
<select name="rating">
|
<textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
|
||||||
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
|
</div>
|
||||||
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
|
<div>
|
||||||
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
|
<label>Is public post?</label>
|
||||||
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
|
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label>Rating</label>
|
||||||
<label>Source(s)</label>
|
<select name="rating">
|
||||||
<ul id="sources">
|
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
|
||||||
{% for source in sources %}
|
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
|
||||||
{% include "fragments/post_source.html" %}
|
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
|
||||||
{% endfor %}
|
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
|
||||||
</ul>
|
</select>
|
||||||
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</button>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label>Source(s)</label>
|
||||||
<label>Parent post</label>
|
<ul id="sources">
|
||||||
<input name="parent_post" type="text" pattern="[0-9]*" value="{% if let Some(parent_id) = post.parent_id %}{{ parent_id }}{% endif %}" placeholder="Post ID" />
|
{% for source in sources %}
|
||||||
</div>
|
{% include "fragments/post_source.html" %}
|
||||||
<div>
|
{% endfor %}
|
||||||
<button>Save changes</button>
|
</ul>
|
||||||
<button hx-get="/post_details/{{ post.id }}">Cancel</button>
|
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</button>
|
||||||
<button hx-confirm="Are you sure that you want to delete this post? This can't be undone!" hx-delete="/post/{{ post.id }}" hx-target="body" hx-replace-url="/">Delete post</button>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<label>Parent post</label>
|
||||||
|
<input name="parent_post" type="text" pattern="[0-9]*" value="{% if let Some(parent_id) = post.parent_id %}{{ parent_id }}{% endif %}" placeholder="Post ID" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button>Save changes</button>
|
||||||
|
<button hx-get="/post_details/{{ post.id }}">Cancel</button>
|
||||||
|
<button hx-confirm="Are you sure that you want to delete this post? This can't be undone!" hx-delete="/post/{{ post.id }}" hx-target="body" hx-replace-url="/">Delete post</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<video
|
<video
|
||||||
id="media"
|
id="media"
|
||||||
src="/files/{{ post.media }}"
|
src="/files/{{ post.media }}"
|
||||||
controls="controls"
|
controls="true"
|
||||||
:style="{ width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
|
style="width: 100%; height: 100%"
|
||||||
|
:style="{ 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
|
||||||
></video>
|
></video>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
<article id="post-details" hx-target="this" hx-swap="outerHTML">
|
<article id="post-details">
|
||||||
<h2>
|
<h2>
|
||||||
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{%
|
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
|
||||||
endif %}
|
|
||||||
</h2>
|
</h2>
|
||||||
{% if let Some(description) = post.description %}
|
{% if let Some(description) = post.description %}
|
||||||
<div id="description">{{ description | markdown }}</div>
|
<div id="description">{{ description | markdown }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table>
|
<table>
|
||||||
|
{% if can_edit %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Is public post?</th>
|
<th>Is public post?</th>
|
||||||
<td>
|
<td>{% if post.is_public %}Yes{% else %}No{% endif %}</td>
|
||||||
{% if post.is_public %}Yes{% else %}No{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Rating</th>
|
<th>Rating</th>
|
||||||
<td>
|
<td>
|
||||||
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe
|
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %}
|
||||||
{% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {%
|
Safe {% when "q" %} Questionable {% when "e" %} Explicit {% else %}
|
||||||
endmatch %}
|
Unknown {% endmatch %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -54,6 +53,12 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button hx-get="/post_details/{{ post.id }}/edit">Edit post</button>
|
<button
|
||||||
|
hx-get="/post_details/{{ post.id }}/edit"
|
||||||
|
hx-target="#post-details"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
Edit post
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</article>
|
||||||
|
|
|
||||||
11
templates/fragments/rss_entry.html
Normal file
11
templates/fragments/rss_entry.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<h1>
|
||||||
|
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
|
||||||
|
</h1>
|
||||||
|
{% match post.media_type.as_ref() %}{% when "image" %}
|
||||||
|
<img src="{{ base_url }}/files/{{ post.media }}" />
|
||||||
|
{% when "video" %}
|
||||||
|
<video src="{{ base_url }}/files/{{ post.media }}" controls="true"></video>
|
||||||
|
{% else %}{% endmatch %}{% if let Some(description) = post.description %}
|
||||||
|
<h2>Description</h2>
|
||||||
|
<div>{{ description | markdown }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
{% for tag in tags %}
|
{% if !tags.is_empty() %}
|
||||||
|
<li>
|
||||||
|
<button hx-delete="/remove" hx-target="closest ul" hx-swap="innerHTML">
|
||||||
|
(close)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}{% for tag in tags %}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
hx-post="/select_tag"
|
hx-post="/select_tag"
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,17 @@ parent_post %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<article id="parent-post" hx-swap-oob="outerHTML" hidden></article>
|
<article id="parent-post" hx-swap-oob="outerHTML" hidden></article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul id="tags-list" hx-swap-oob="outerHTML">
|
<article id="tags-list" hx-swap-oob="outerHTML">
|
||||||
{% for tag in tags %}
|
<h2>Tags</h2>
|
||||||
<li>
|
{% if tags.is_empty() %}
|
||||||
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
<p>No tags in post. Consider adding some!</p>
|
||||||
</li>
|
{% else %}
|
||||||
{% endfor %}
|
<ul>
|
||||||
</ul>
|
{% for tag in tags %}
|
||||||
|
<li>
|
||||||
|
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
name="tags"
|
name="tags"
|
||||||
placeholder="Tags"
|
placeholder="Tags"
|
||||||
hx-post="/search_tags"
|
hx-post="/search_tags"
|
||||||
hx-trigger="input changed"
|
hx-trigger="input changed delay:500ms"
|
||||||
hx-target="next .tags-autocomplete"
|
hx-target="next .tags-autocomplete"
|
||||||
hx-vals="js:{selection_end: event.target.selectionEnd}"
|
hx-swap="innerHTML"
|
||||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
hx-vals="js:{selection_end: document.querySelector('.tags').selectionEnd}"
|
||||||
|
hx-on::after-settle="document.querySelector('.tags').focus(); document.querySelector('.tags').setSelectionRange(-1, -1);"
|
||||||
value="{{ tags_value }}"
|
value="{{ tags_value }}"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="search-autocomplete"
|
aria-controls="search-autocomplete"
|
||||||
|
|
|
||||||
14
templates/pages/bad_request.html
Normal file
14
templates/pages/bad_request.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Bad request</title>
|
||||||
|
{% include "fragments/common_headers.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div><a href="/">< To home</a></div>
|
||||||
|
<main>
|
||||||
|
<h1>Bad request</h1>
|
||||||
|
<p>The provided parameters are invalid: {{ error }}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
templates/pages/bulk_edit_tag.html
Normal file
37
templates/pages/bulk_edit_tag.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Bulk edit tag - {{ application_name }}</title>
|
||||||
|
<meta property="og:site_name" content="{{ application_name }}" />
|
||||||
|
{% include "fragments/common_headers.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
|
||||||
|
%}{% endif %}
|
||||||
|
<div><a href="/">< To home</a></div>
|
||||||
|
<main>
|
||||||
|
<h1>Bulk edit tag</h1>
|
||||||
|
<article>
|
||||||
|
<h2>Select tag to edit</h2>
|
||||||
|
<form method="post" action="/bulk_edit_tag">
|
||||||
|
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
|
||||||
|
<div
|
||||||
|
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
||||||
|
hx-target="next .tags-autocomplete"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-delete="/remove"
|
||||||
|
hidden
|
||||||
|
></div>
|
||||||
|
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
||||||
|
<input type="text" name="new_tag" placeholder="New tag" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
{% match message %}{% when BulkEditTagMessage::Success %}
|
||||||
|
<div>Success!</div>
|
||||||
|
{% when BulkEditTagMessage::Failure with (msg) %}
|
||||||
|
<div>Error: {{ msg }}</div>
|
||||||
|
{% when BulkEditTagMessage::None %}{% endmatch %}
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
templates/pages/forbidden.html
Normal file
14
templates/pages/forbidden.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Forbidden</title>
|
||||||
|
{% include "fragments/common_headers.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div><a href="/">< To home</a></div>
|
||||||
|
<main>
|
||||||
|
<h1>Forbidden</h1>
|
||||||
|
<p>The requested action is not allowed.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
<h2>Search</h2>
|
<h2>Search</h2>
|
||||||
<form method="get" action="/posts/1">
|
<form method="get" action="/posts/1">
|
||||||
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
|
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
|
||||||
|
<div
|
||||||
|
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
||||||
|
hx-target="next .tags-autocomplete"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-delete="/remove"
|
||||||
|
hidden
|
||||||
|
></div>
|
||||||
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -34,6 +41,9 @@
|
||||||
<a href="/create_pool">Create pool</a>
|
<a href="/create_pool">Create pool</a>
|
||||||
</li>
|
</li>
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
|
<li>
|
||||||
|
<a href="/bulk_edit_tag">Bulk edit tag</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings">Settings</a>
|
<a href="/settings">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
17
templates/pages/internal_server_error.html
Normal file
17
templates/pages/internal_server_error.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Internal server error</title>
|
||||||
|
{% include "fragments/common_headers.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div><a href="/">< To home</a></div>
|
||||||
|
<main>
|
||||||
|
<h1>Internal server error</h1>
|
||||||
|
<p>
|
||||||
|
Something went wrong! Ask your administrator to check the application
|
||||||
|
logs.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -106,6 +106,16 @@
|
||||||
value="true"
|
value="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-confirm="Are you sure that you want to delete this pool? This can't be undone!"
|
||||||
|
hx-delete="/pool/{{ pool.id }}"
|
||||||
|
hx-target="body"
|
||||||
|
hx-replace-url="/"
|
||||||
|
>
|
||||||
|
Delete pool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,18 @@
|
||||||
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
|
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
|
||||||
%}{% endif %}
|
%}{% endif %}
|
||||||
<div><a href="/">< To home</a></div>
|
<div><a href="/">< To home</a></div>
|
||||||
|
<div><a href="{% if let Some(tags_text) = tags_text %}/posts.xml?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts.xml{% endif %}">RSS feed</a></div>
|
||||||
<article>
|
<article>
|
||||||
<h2>Search</h2>
|
<h2>Search</h2>
|
||||||
<form method="get" action="/posts">
|
<form method="get" action="/posts">
|
||||||
{% let tags_value = tags_text.clone().unwrap_or("".into()) %} {% include "fragments/tags_input.html" %}
|
{% let tags_value = tags_text.clone().unwrap_or("".into()) %} {% include "fragments/tags_input.html" %}
|
||||||
|
<div
|
||||||
|
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
||||||
|
hx-target="next .tags-autocomplete"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-delete="/remove"
|
||||||
|
hidden
|
||||||
|
></div>
|
||||||
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@
|
||||||
value="{{ application_name }}"
|
value="{{ application_name }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Base URL</label>
|
||||||
|
<input name="base_url" type="text" value="{{ base_url }}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Favicon post ID</label>
|
||||||
|
<input name="favicon_post_id" type="text" pattern="[0-9]*" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Ask for age confirmation?</label>
|
<label>Ask for age confirmation?</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
14
templates/pages/unauthorized.html
Normal file
14
templates/pages/unauthorized.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Unauthorized</title>
|
||||||
|
{% include "fragments/common_headers.html" %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div><a href="/">< To home</a></div>
|
||||||
|
<main>
|
||||||
|
<h1>Unauthorized</h1>
|
||||||
|
<p>The provided credentials are invalid.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<h1>Upload media</h1>
|
<h1>Upload media</h1>
|
||||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||||
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
|
{% let tags_value = "" %} {% include "fragments/tags_input.html" %}
|
||||||
|
<div
|
||||||
|
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
||||||
|
hx-target="next .tags-autocomplete"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-delete="/remove"
|
||||||
|
hidden
|
||||||
|
></div>
|
||||||
<ul class="reset tags-autocomplete" id="upload-autocomplete"></ul>
|
<ul class="reset tags-autocomplete" id="upload-autocomplete"></ul>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|
|
||||||
|
|
@ -29,26 +29,6 @@
|
||||||
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
|
{% if age_confirmation %}{% include "fragments/age_restricted_check.html"
|
||||||
%}{% endif %}
|
%}{% endif %}
|
||||||
<div><a href="{% if let Some(tags_text) = tags_text %}/posts/1?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts/1{% endif %}">< To posts</a></div>
|
<div><a href="{% if let Some(tags_text) = tags_text %}/posts/1?tags={{ tags_text.replace(' ', "+") }}{% else %}/posts/1{% endif %}">< To posts</a></div>
|
||||||
<article>
|
|
||||||
<h2>Search</h2>
|
|
||||||
<form method="get" action="/posts">
|
|
||||||
<input
|
|
||||||
class="tags"
|
|
||||||
type="text"
|
|
||||||
id="search-tags"
|
|
||||||
name="tags"
|
|
||||||
placeholder="Tags"
|
|
||||||
hx-post="/search_tags"
|
|
||||||
hx-trigger="input changed"
|
|
||||||
hx-target="next .tags-autocomplete"
|
|
||||||
hx-vals="js:{selection_end: event.target.selectionEnd}"
|
|
||||||
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
|
||||||
value="{% if let Some(tags_text) = tags_text %}{{ tags_text }}{% endif %}"
|
|
||||||
/>
|
|
||||||
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
|
||||||
<button type="submit">Search</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
<article>
|
<article>
|
||||||
<table>
|
<table>
|
||||||
{% for item in pool_data %}
|
{% for item in pool_data %}
|
||||||
|
|
@ -112,15 +92,19 @@
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article>
|
<article id="tags-list">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
{% if tags.is_empty() %}
|
{% if tags.is_empty() %}
|
||||||
<p>No tags in post. Consider adding some!</p>
|
<p>No tags in post. Consider adding some!</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul id="tags-list">
|
<ul>
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<li>
|
<li>
|
||||||
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
{% if let Some(tags_text) = tags_text %}
|
||||||
|
<a href="/posts?tags={{ tags_text.replace(' ', "+") }}+{{ tag.name }}">+</a> <a href="/posts?tags={{ tags_text.replace(' ', "+") }}+-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/posts?tags={{ tag.name }}">+</a> <a href="/posts?tags=-{{ tag.name }}">-</a> <a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue