Compare commits
No commits in common. "432ef1a72cfbb0a793ab02ed70f649cd2340e267" and "7f533cc58358abc2cfa467927bdd726d4258e381" have entirely different histories.
432ef1a72c
...
7f533cc583
35 changed files with 287 additions and 1079 deletions
146
Cargo.lock
generated
146
Cargo.lock
generated
|
|
@ -370,19 +370,6 @@ 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"
|
||||||
|
|
@ -966,7 +953,6 @@ dependencies = [
|
||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
|
||||||
"syn 2.0.100",
|
"syn 2.0.100",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1002,37 +988,6 @@ 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"
|
||||||
|
|
@ -1045,15 +1000,6 @@ 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"
|
||||||
|
|
@ -1410,25 +1356,6 @@ 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"
|
||||||
|
|
@ -1580,7 +1507,6 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
|
@ -2048,6 +1974,14 @@ 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"
|
||||||
|
|
@ -2108,12 +2042,6 @@ 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"
|
||||||
|
|
@ -2570,16 +2498,6 @@ 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"
|
||||||
|
|
@ -2862,18 +2780,6 @@ 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"
|
||||||
|
|
@ -3038,17 +2944,15 @@ 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",
|
||||||
|
|
@ -3056,14 +2960,6 @@ 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"
|
||||||
|
|
@ -3104,7 +3000,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strum 0.26.3",
|
"strum",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -3679,28 +3575,6 @@ 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,18 +6,14 @@ 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 = ["http2", "multipart", "macros"] }
|
axum = { version = "0.8.3", features = ["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"
|
||||||
|
|
@ -25,12 +21,11 @@ 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"
|
||||||
samey-migration = { path = "./migration", version = "0.1.0" }
|
migration = { path = "migration" }
|
||||||
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",
|
||||||
|
|
@ -45,7 +40,6 @@ 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
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -2,35 +2,29 @@
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
### Features
|
### Roadmap
|
||||||
|
|
||||||
- Image and video posts.
|
- [ ] Logging
|
||||||
- Tagging with autocompletion.
|
- [ ] Improved error handling
|
||||||
- Post pools.
|
- [ ] Caching
|
||||||
- RSS feeds.
|
|
||||||
|
|
||||||
### Possible roadmap
|
|
||||||
|
|
||||||
- [ ] Display thumbnails on post selection
|
|
||||||
- [ ] Text media
|
|
||||||
- [ ] Testing
|
|
||||||
- [ ] Improve CSS
|
|
||||||
- [ ] User management
|
|
||||||
- [ ] Lossless compression
|
- [ ] Lossless compression
|
||||||
|
- [ ] Bulk edit tags/Fix tag capitalization
|
||||||
|
- [ ] User management
|
||||||
|
- [ ] Cleanup/fixup background tasks
|
||||||
|
- [ ] Text media
|
||||||
|
- [ ] Improve CSS
|
||||||
- [ ] Migrate to Cot...?
|
- [ ] Migrate to Cot...?
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `ffmpeg` and `ffprobe`
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- `ffmpeg` (with `ffprobe`)
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
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,19 +1,16 @@
|
||||||
[package]
|
[package]
|
||||||
name = "samey-migration"
|
name = "migration"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
publish = false
|
||||||
license = "MIT"
|
|
||||||
authors = ["Bad Manners <me@badmanners.xyz>"]
|
[lib]
|
||||||
readme = "README.md"
|
name = "migration"
|
||||||
keywords = ["booru", "image-board", "gallery"]
|
path = "src/lib.rs"
|
||||||
categories = ["web-programming"]
|
|
||||||
description = "Migrations for Samey, Sam's small image board"
|
|
||||||
repository = "https://github.com/BadMannersXYZ/samey"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
|
async-std = { version = "1", features = ["attributes", "tokio1"] }
|
||||||
sea-orm-migration = { version = "1.1.8", features = [
|
|
||||||
"sqlx-sqlite",
|
[dependencies.sea-orm-migration]
|
||||||
"runtime-tokio-rustls",
|
version = "1.1.8"
|
||||||
] }
|
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(samey_migration::Migrator).await;
|
cli::run_cli(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,9 +221,3 @@ 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,13 +6,11 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,14 +24,6 @@ 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)
|
||||||
|
|
@ -44,7 +34,6 @@ 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,114 +4,54 @@ 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),
|
||||||
/// Authentication error.
|
|
||||||
#[error("Authentication error: {0}")]
|
|
||||||
Authentication(String),
|
|
||||||
/// Not found.
|
|
||||||
#[error("Not found")]
|
#[error("Not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
/// Not allowed.
|
#[error("Authentication error: {0}")]
|
||||||
|
Authentication(String),
|
||||||
#[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(_) => {
|
||||||
println!("Internal server error - {:?}", &self);
|
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong!").into_response()
|
||||||
(
|
}
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
SameyError::Multipart(_) | SameyError::BadRequest(_) => {
|
||||||
Html(
|
(StatusCode::BAD_REQUEST, "Invalid request").into_response()
|
||||||
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(
|
||||||
|
|
@ -121,24 +61,10 @@ impl IntoResponse for SameyError {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
SameyError::Authentication(_) => (
|
SameyError::Authentication(_) => {
|
||||||
StatusCode::UNAUTHORIZED,
|
(StatusCode::UNAUTHORIZED, "Not authorized").into_response()
|
||||||
Html(
|
}
|
||||||
UnauthorizedTemplate {}
|
SameyError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(),
|
||||||
.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,11 +1,9 @@
|
||||||
//! 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 tags;
|
pub(crate) mod rating;
|
||||||
pub(crate) mod video;
|
pub(crate) mod video;
|
||||||
pub(crate) mod views;
|
pub(crate) mod views;
|
||||||
|
|
||||||
|
|
@ -35,6 +33,9 @@ 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;
|
||||||
|
|
@ -62,26 +63,14 @@ 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: &str,
|
username: String,
|
||||||
password: &str,
|
password: String,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
) -> Result<(), SameyError> {
|
) -> Result<(), SameyError> {
|
||||||
SameyUser::insert(samey_user::ActiveModel {
|
SameyUser::insert(samey_user::ActiveModel {
|
||||||
username: Set(username.into()),
|
username: Set(username),
|
||||||
password: Set(generate_hash(password)),
|
password: Set(generate_hash(password)),
|
||||||
is_admin: Set(is_admin),
|
is_admin: Set(is_admin),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
@ -91,18 +80,6 @@ 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>,
|
||||||
|
|
@ -115,9 +92,7 @@ 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).with_expiry(
|
let session_layer = SessionManagerLayer::new(session_store);
|
||||||
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()
|
||||||
|
|
@ -146,14 +121,12 @@ 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).delete(delete_pool))
|
.route_with_tsr("/pool/{pool_id}", get(view_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
|
||||||
|
|
@ -161,7 +134,6 @@ 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,89 +1,54 @@
|
||||||
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 config = Config::parse();
|
let db = Database::connect("sqlite:db.sqlite3?mode=rwc")
|
||||||
let db = Database::connect(config.database)
|
|
||||||
.await
|
.await
|
||||||
.expect("Unable to connect to database");
|
.expect("Unable to connect to database");
|
||||||
match config.command.unwrap_or_default() {
|
let config = Config::parse();
|
||||||
Commands::Migrate => {
|
match config.command {
|
||||||
|
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 }) => {
|
||||||
Commands::AddAdminUser { username, password } => {
|
create_user(db, username, password, true)
|
||||||
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, config.files_directory)
|
let app = get_router(db, "files")
|
||||||
.await
|
.await
|
||||||
.expect("Unable to start router");
|
.expect("Unable to start router");
|
||||||
let listener = tokio::net::TcpListener::bind((address, port))
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||||
.await
|
.await
|
||||||
.expect("Unable to bind TCP listener");
|
.expect("Unable to listen to port");
|
||||||
if address.is_ipv6() {
|
println!("Listening on http://localhost:3000");
|
||||||
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,31 +1,25 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use migration::{Expr, Query};
|
||||||
use samey_migration::{Expr, Query};
|
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoIdentity,
|
ColumnTrait, Condition, DatabaseConnection, EntityTrait, FromQueryResult, IntoSimpleExpr,
|
||||||
IntoSimpleExpr, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns,
|
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Select, SelectColumns, SelectModel,
|
||||||
SelectModel, Selector,
|
Selector,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
SameyError,
|
NEGATIVE_PREFIX, RATING_PREFIX, 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,
|
||||||
|
|
@ -39,23 +33,17 @@ 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 tag in tags.iter().map(|tag| tag.to_lowercase()) {
|
for mut tag in tags.iter().map(|tag| tag.to_lowercase()) {
|
||||||
if let Some(negative_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
|
if tag.starts_with(NEGATIVE_PREFIX) {
|
||||||
if let Some(negative_rating_tag) = negative_tag.strip_prefix(RATING_PREFIX) {
|
if tag.as_str()[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) {
|
||||||
exclude_ratings.insert(negative_rating_tag.into());
|
exclude_ratings
|
||||||
} else if let Some(negative_type_tag) = negative_tag.strip_prefix(MEDIA_TYPE_PREFIX)
|
.insert(tag.split_off(NEGATIVE_PREFIX.len() + RATING_PREFIX.len()));
|
||||||
{
|
|
||||||
exclude_types.insert(negative_type_tag.into());
|
|
||||||
} else {
|
} else {
|
||||||
exclude_tags.insert(negative_tag.into());
|
exclude_tags.insert(tag.split_off(NEGATIVE_PREFIX.len()));
|
||||||
}
|
}
|
||||||
} else if let Some(rating_tag) = tag.strip_prefix(RATING_PREFIX) {
|
} else if tag.starts_with(RATING_PREFIX) {
|
||||||
include_ratings.insert(rating_tag.into());
|
include_ratings.insert(tag.split_off(RATING_PREFIX.len()));
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
|
|
@ -66,10 +54,6 @@ 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)
|
||||||
|
|
@ -88,21 +72,11 @@ 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)
|
||||||
|
|
@ -160,12 +134,6 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -295,18 +263,3 @@ 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(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
31
src/rating.rs
Normal file
31
src/rating.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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
23
src/tags.rs
|
|
@ -1,23 +0,0 @@
|
||||||
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,20 +17,19 @@ 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,
|
AppState, NEGATIVE_PREFIX, RATING_PREFIX,
|
||||||
auth::{AuthSession, Credentials, User},
|
auth::{AuthSession, Credentials, User},
|
||||||
config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY, BASE_URL_KEY},
|
config::{AGE_CONFIRMATION_KEY, APPLICATION_NAME_KEY},
|
||||||
entities::{
|
entities::{
|
||||||
prelude::{
|
prelude::{
|
||||||
SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
|
SameyConfig, SameyPool, SameyPoolPost, SameyPost, SameyPostSource, SameyTag,
|
||||||
|
|
@ -41,10 +40,9 @@ use crate::{
|
||||||
},
|
},
|
||||||
error::SameyError,
|
error::SameyError,
|
||||||
query::{
|
query::{
|
||||||
PoolPost, PostOverview, PostPoolData, clean_dangling_tags, filter_posts_by_user,
|
PoolPost, PostOverview, PostPoolData, filter_posts_by_user, get_pool_data_for_post,
|
||||||
get_pool_data_for_post, get_posts_in_pool, get_tags_for_post, search_posts,
|
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},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,63 +90,6 @@ 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)]
|
||||||
|
|
@ -185,17 +126,24 @@ 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 {
|
||||||
Some(user) => user,
|
Ok(Some(user)) => user,
|
||||||
None => return Err(SameyError::Authentication("Invalid credentials".into())),
|
Ok(None) => return Err(SameyError::Authentication("Invalid credentials".into())),
|
||||||
|
Err(_) => return Err(SameyError::Other("Auth session error".into())),
|
||||||
};
|
};
|
||||||
|
|
||||||
auth_session.login(&user).await?;
|
auth_session
|
||||||
|
.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.logout().await?;
|
auth_session
|
||||||
|
.logout()
|
||||||
|
.await
|
||||||
|
.map_err(|_| SameyError::Other("Logout error".into()))?;
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,9 +203,10 @@ 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::BadRequest(
|
ImageFormat::from_mime_type(content_type).ok_or(SameyError::Other(format!(
|
||||||
format!("Unknown content type: {}", content_type),
|
"Unknown content type: {}",
|
||||||
))?,
|
content_type
|
||||||
|
)))?,
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +277,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::BadRequest("Missing content type".into()))?;
|
.ok_or(SameyError::Other("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());
|
||||||
|
|
@ -351,7 +300,6 @@ 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)?;
|
||||||
|
|
@ -402,7 +350,6 @@ 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)?;
|
||||||
|
|
@ -496,9 +443,7 @@ pub(crate) async fn upload(
|
||||||
|
|
||||||
Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
|
Ok(Redirect::to(&format!("/post/{}", uploaded_post)))
|
||||||
} else {
|
} else {
|
||||||
Err(SameyError::BadRequest(
|
Err(SameyError::Other("Missing parameters for upload".into()))
|
||||||
"Missing parameters for upload".into(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,43 +471,32 @@ 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(' ').next_back() {
|
let tags = match body.tags[..body.selection_end].split(' ').last() {
|
||||||
Some(mut tag) => {
|
Some(mut tag) => {
|
||||||
tag = tag.trim();
|
tag = tag.trim();
|
||||||
if tag.is_empty() {
|
if tag.is_empty() {
|
||||||
vec![]
|
vec![]
|
||||||
} else if let Some(stripped_tag) = tag.strip_prefix(NEGATIVE_PREFIX) {
|
} else if tag.starts_with(NEGATIVE_PREFIX) {
|
||||||
if stripped_tag.starts_with(RATING_PREFIX) {
|
if tag[NEGATIVE_PREFIX.len()..].starts_with(RATING_PREFIX) {
|
||||||
Rating::iter()
|
[
|
||||||
.map(|rating| format!("{}{}", RATING_PREFIX, rating))
|
format!("{}u", RATING_PREFIX),
|
||||||
.filter(|t| t.starts_with(stripped_tag))
|
format!("{}s", RATING_PREFIX),
|
||||||
.map(|tag| SearchTag {
|
format!("{}q", RATING_PREFIX),
|
||||||
value: format!("-{}", &tag),
|
format!("{}e", RATING_PREFIX),
|
||||||
name: tag,
|
]
|
||||||
})
|
.into_iter()
|
||||||
.collect()
|
.filter(|t| t.starts_with(&tag[NEGATIVE_PREFIX.len()..]))
|
||||||
} else if stripped_tag.starts_with(MEDIA_TYPE_PREFIX) {
|
.map(|tag| SearchTag {
|
||||||
MediaType::iter()
|
value: format!("-{}", &tag),
|
||||||
.map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
|
name: tag,
|
||||||
.filter(|t| t.starts_with(stripped_tag))
|
})
|
||||||
.map(|tag| SearchTag {
|
.collect()
|
||||||
value: format!("-{}", &tag),
|
|
||||||
name: tag,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
SameyTag::find()
|
SameyTag::find()
|
||||||
.filter(
|
.filter(Expr::cust_with_expr(
|
||||||
Condition::any()
|
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
|
||||||
.add(
|
tag[NEGATIVE_PREFIX.len()..].to_lowercase(),
|
||||||
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?
|
||||||
|
|
@ -574,33 +508,25 @@ pub(crate) async fn search_tags(
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
} else if tag.starts_with(RATING_PREFIX) {
|
} else if tag.starts_with(RATING_PREFIX) {
|
||||||
Rating::iter()
|
[
|
||||||
.map(|rating| format!("{}{}", RATING_PREFIX, rating))
|
format!("{}u", RATING_PREFIX),
|
||||||
.filter(|t| t.starts_with(tag))
|
format!("{}s", RATING_PREFIX),
|
||||||
.map(|tag| SearchTag {
|
format!("{}q", RATING_PREFIX),
|
||||||
value: tag.clone(),
|
format!("{}e", RATING_PREFIX),
|
||||||
name: tag,
|
]
|
||||||
})
|
.into_iter()
|
||||||
.collect()
|
.filter(|t| t.starts_with(tag))
|
||||||
} else if tag.starts_with(MEDIA_TYPE_PREFIX) {
|
.map(|tag| SearchTag {
|
||||||
MediaType::iter()
|
value: tag.clone(),
|
||||||
.map(|rating| format!("{}{}", MEDIA_TYPE_PREFIX, rating))
|
name: tag,
|
||||||
.filter(|t| t.starts_with(tag))
|
})
|
||||||
.map(|tag| SearchTag {
|
.collect()
|
||||||
value: tag.clone(),
|
|
||||||
name: tag,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
SameyTag::find()
|
SameyTag::find()
|
||||||
.filter(
|
.filter(Expr::cust_with_expr(
|
||||||
Condition::any()
|
"LOWER(\"samey_tag\".\"name\") LIKE CONCAT(?, '%')",
|
||||||
.add(samey_tag::Column::NormalizedName.starts_with(tag.to_lowercase()))
|
tag.to_lowercase(),
|
||||||
.add(
|
))
|
||||||
samey_tag::Column::NormalizedName
|
|
||||||
.contains(format!(":{}", tag.to_lowercase())),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(10)
|
.limit(10)
|
||||||
.all(&db)
|
.all(&db)
|
||||||
.await?
|
.await?
|
||||||
|
|
@ -1132,10 +1058,12 @@ 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 {
|
||||||
Some(body.new_index + 1)
|
if body.new_index < body.old_index {
|
||||||
|
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
|
||||||
|
|
@ -1163,170 +1091,12 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1340,7 +1110,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -1360,7 +1129,6 @@ 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)?,
|
||||||
|
|
@ -1370,18 +1138,11 @@ 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 {
|
State(AppState { db, app_config, .. }): 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> {
|
||||||
|
|
@ -1391,27 +1152,18 @@ pub(crate) async fn update_settings(
|
||||||
|
|
||||||
let mut configs = vec![];
|
let mut configs = vec![];
|
||||||
|
|
||||||
let application_name = body.application_name.trim();
|
if !body.application_name.is_empty() {
|
||||||
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,
|
||||||
application_name.into(),
|
body.application_name.clone(),
|
||||||
);
|
);
|
||||||
configs.push(samey_config::ActiveModel {
|
configs.push(samey_config::ActiveModel {
|
||||||
key: Set(APPLICATION_NAME_KEY.into()),
|
key: Set(APPLICATION_NAME_KEY.into()),
|
||||||
data: Set(application_name.into()),
|
data: Set(body.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,
|
||||||
|
|
@ -1434,21 +1186,6 @@ 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("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1511,10 +1248,6 @@ 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)
|
||||||
|
|
@ -1544,10 +1277,6 @@ 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)
|
||||||
|
|
@ -1642,7 +1371,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1667,14 +1395,14 @@ pub(crate) async fn submit_post_details(
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = match body.title.trim() {
|
let title = match body.title.trim() {
|
||||||
"" => None,
|
title if title.is_empty() => None,
|
||||||
title => Some(title.to_owned()),
|
title => Some(title.to_owned()),
|
||||||
};
|
};
|
||||||
let description = match body.description.trim() {
|
let description = match body.description.trim() {
|
||||||
"" => None,
|
description if description.is_empty() => None,
|
||||||
description => Some(description.to_owned()),
|
description => Some(description.to_owned()),
|
||||||
};
|
};
|
||||||
let parent_post = if let Ok(parent_id) = body.parent_post.trim().parse() {
|
let parent_post = if let Some(parent_id) = body.parent_post.trim().parse().ok() {
|
||||||
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?
|
||||||
|
|
@ -1682,10 +1410,6 @@ 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)
|
||||||
|
|
@ -1773,31 +1497,17 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
@ -1905,11 +1615,9 @@ 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 _ = std::fs::remove_file(files_dir.join(post.media));
|
let base_path = files_dir.as_ref();
|
||||||
let _ = std::fs::remove_file(files_dir.join(post.thumbnail));
|
let _ = std::fs::remove_file(base_path.join(post.media));
|
||||||
if let Err(err) = clean_dangling_tags(&db).await {
|
let _ = std::fs::remove_file(base_path.join(post.thumbnail));
|
||||||
println!("Error when cleaning dangling tags - {}", err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Redirect::to("/"))
|
Ok(Redirect::to("/"))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<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,55 +1,46 @@
|
||||||
<article id="post-details">
|
<form hx-put="/post_details/{{ post.id }}" hx-target="this" hx-swap="outerHTML">
|
||||||
<form hx-put="/post_details/{{ post.id }}" hx-target="#post-details" hx-swap="outerHTML">
|
<div>
|
||||||
<div>
|
<label>Tags</label>
|
||||||
<label>Tags</label>
|
{% let tags_value = tags %} {% include "fragments/tags_input.html" %}
|
||||||
{% let tags_value = tags %} {% include "fragments/tags_input.html" %}
|
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
||||||
<div
|
</div>
|
||||||
hx-trigger="keyup[key=='Escape'] from:previous .tags"
|
<div>
|
||||||
hx-target="next .tags-autocomplete"
|
<label>Title</label>
|
||||||
hx-swap="innerHTML"
|
<input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
|
||||||
hx-delete="/remove"
|
</div>
|
||||||
hidden
|
<div>
|
||||||
></div>
|
<label>Description</label>
|
||||||
<ul class="reset tags-autocomplete" id="search-autocomplete"></ul>
|
<textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Title</label>
|
<label>Is public post?</label>
|
||||||
<input name="title" type="text" maxlength="100" placeholder="Title" value="{% if let Some(title) = post.title %}{{ title }}{% endif %}" />
|
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Description</label>
|
<label>Rating</label>
|
||||||
<textarea name="description" placeholder="Description in Markdown">{% if let Some(description) = post.description %}{{ description }}{% endif %}</textarea>
|
<select name="rating">
|
||||||
</div>
|
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
|
||||||
<div>
|
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
|
||||||
<label>Is public post?</label>
|
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
|
||||||
<input name="is_public" type="checkbox" {% if post.is_public %}checked{% endif %} value="true" />
|
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
|
||||||
</div>
|
</select>
|
||||||
<div>
|
</div>
|
||||||
<label>Rating</label>
|
<div>
|
||||||
<select name="rating">
|
<label>Source(s)</label>
|
||||||
<option value="u" {% if post.rating == "u" %}selected{% endif %}>Unrated</option>
|
<ul id="sources">
|
||||||
<option value="s" {% if post.rating == "s" %}selected{% endif %}>Safe</option>
|
{% for source in sources %}
|
||||||
<option value="q" {% if post.rating == "q" %}selected{% endif %}>Questionable</option>
|
{% include "fragments/post_source.html" %}
|
||||||
<option value="e" {% if post.rating == "e" %}selected{% endif %}>Explicit</option>
|
{% endfor %}
|
||||||
</select>
|
</ul>
|
||||||
</div>
|
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</button>
|
||||||
<div>
|
</div>
|
||||||
<label>Source(s)</label>
|
<div>
|
||||||
<ul id="sources">
|
<label>Parent post</label>
|
||||||
{% for source in 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" />
|
||||||
{% include "fragments/post_source.html" %}
|
</div>
|
||||||
{% endfor %}
|
<div>
|
||||||
</ul>
|
<button>Save changes</button>
|
||||||
<button hx-post="/post_source" hx-target="#sources" hx-swap="beforeend">+ Add source</button>
|
<button hx-get="/post_details/{{ post.id }}">Cancel</button>
|
||||||
</div>
|
<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>
|
||||||
<label>Parent post</label>
|
</div>
|
||||||
<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,7 +1,6 @@
|
||||||
<video
|
<video
|
||||||
id="media"
|
id="media"
|
||||||
src="/files/{{ post.media }}"
|
src="/files/{{ post.media }}"
|
||||||
controls="true"
|
controls="controls"
|
||||||
style="width: 100%; height: 100%"
|
:style="{ width: '100%', height: '100%', 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
|
||||||
:style="{ 'max-width': width + 'px', 'max-height': height + 'px', 'aspect-ratio': width + ' / ' + height }"
|
|
||||||
></video>
|
></video>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
<article id="post-details">
|
<article id="post-details" hx-target="this" hx-swap="outerHTML">
|
||||||
<h2>
|
<h2>
|
||||||
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{% endif %}
|
{% if let Some(title) = post.title %}{{ title }}{% else %}Details{%
|
||||||
|
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>{% if post.is_public %}Yes{% else %}No{% endif %}</td>
|
<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" %}
|
{% match post.rating.as_ref() %} {% when "u" %} Unrated {% when "s" %} Safe
|
||||||
Safe {% when "q" %} Questionable {% when "e" %} Explicit {% else %}
|
{% when "q" %} Questionable {% when "e" %} Explicit {% else %} Unknown {%
|
||||||
Unknown {% endmatch %}
|
endmatch %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -53,12 +54,6 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button
|
<button hx-get="/post_details/{{ post.id }}/edit">Edit post</button>
|
||||||
hx-get="/post_details/{{ post.id }}/edit"
|
|
||||||
hx-target="#post-details"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
Edit post
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<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,10 +1,4 @@
|
||||||
{% if !tags.is_empty() %}
|
{% for tag in tags %}
|
||||||
<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,17 +18,10 @@ 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 %}
|
||||||
<article id="tags-list" hx-swap-oob="outerHTML">
|
<ul id="tags-list" hx-swap-oob="outerHTML">
|
||||||
<h2>Tags</h2>
|
{% for tag in tags %}
|
||||||
{% if tags.is_empty() %}
|
<li>
|
||||||
<p>No tags in post. Consider adding some!</p>
|
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||||
{% else %}
|
</li>
|
||||||
<ul>
|
{% endfor %}
|
||||||
{% for tag in tags %}
|
</ul>
|
||||||
<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,11 +5,10 @@
|
||||||
name="tags"
|
name="tags"
|
||||||
placeholder="Tags"
|
placeholder="Tags"
|
||||||
hx-post="/search_tags"
|
hx-post="/search_tags"
|
||||||
hx-trigger="input changed delay:500ms"
|
hx-trigger="input changed"
|
||||||
hx-target="next .tags-autocomplete"
|
hx-target="next .tags-autocomplete"
|
||||||
hx-swap="innerHTML"
|
hx-vals="js:{selection_end: event.target.selectionEnd}"
|
||||||
hx-vals="js:{selection_end: document.querySelector('.tags').selectionEnd}"
|
hx-on::after-settle="this.focus(); this.setSelectionRange(-1, -1);"
|
||||||
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"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<!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,13 +14,6 @@
|
||||||
<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>
|
||||||
|
|
@ -41,9 +34,6 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<!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,16 +106,6 @@
|
||||||
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,18 +9,10 @@
|
||||||
{% 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,14 +20,6 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<!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,13 +13,6 @@
|
||||||
<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,6 +29,26 @@
|
||||||
{% 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 %}
|
||||||
|
|
@ -92,19 +112,15 @@
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<article id="tags-list">
|
<article>
|
||||||
<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>
|
<ul id="tags-list">
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<li>
|
<li>
|
||||||
{% if let Some(tags_text) = tags_text %}
|
<a href="/posts?tags={{ tag.name }}">{{ tag.name }}</a>
|
||||||
<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