Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91ca486e64 | |||
| 2389bca42b | |||
| 520960d009 | |||
| 0cda791d44 | |||
| a65488c304 | |||
| 4d9d0a894c | |||
| fd1e78ba8c | |||
| 99e2cbc1f0 | |||
| 71f444b9aa | |||
| a8de7cfa33 | |||
| f7dcefeea6 | |||
| 757ebea2ba | |||
| 4d41513994 | |||
| 43441ee430 | |||
| 90fd4f86f8 | |||
| 77f6b5c5e2 |
Generated
+233
-1
@@ -29,6 +29,15 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -323,12 +332,24 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -621,6 +642,15 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.12"
|
version = "0.3.12"
|
||||||
@@ -884,12 +914,31 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1425,6 +1474,32 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"zune-core",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -1574,6 +1649,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1619,6 +1703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"adler2",
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1632,6 +1717,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -1649,6 +1744,15 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1888,6 +1992,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1940,6 +2057,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2093,6 +2222,23 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -2376,6 +2522,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -2402,6 +2557,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2724,6 +2885,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
@@ -3012,6 +3182,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3090,6 +3290,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3113,6 +3319,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -3255,12 +3467,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.8"
|
version = "0.1.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"cot",
|
"cot",
|
||||||
"futures",
|
"futures",
|
||||||
|
"image",
|
||||||
"multer",
|
"multer",
|
||||||
"password-auth",
|
"password-auth",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -3268,6 +3482,9 @@ dependencies = [
|
|||||||
"serde_html_form",
|
"serde_html_form",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3808,3 +4025,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core",
|
||||||
|
]
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.8"
|
version = "0.1.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -14,5 +14,10 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
multer = "3"
|
multer = "3"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
tokio = { version = "1", features = ["fs"] }
|
tokio = { version = "1", features = ["fs"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
urlencoding = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
+638
-28
@@ -6,7 +6,13 @@ use cot::request::extractors::Path;
|
|||||||
use cot::response::{IntoResponse, Redirect, Response};
|
use cot::response::{IntoResponse, Redirect, Response};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use image::ImageDecoder;
|
||||||
|
use image::ImageFormat;
|
||||||
|
use image::ImageReader;
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use image::imageops::FilterType;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
use crate::i18n::{Lang, Translations};
|
use crate::i18n::{Lang, Translations};
|
||||||
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit};
|
||||||
@@ -14,6 +20,9 @@ use crate::telegram;
|
|||||||
|
|
||||||
const SESSION_USER_ID: &str = "user_id";
|
const SESSION_USER_ID: &str = "user_id";
|
||||||
const SESSION_USER_NAME: &str = "user_name";
|
const SESSION_USER_NAME: &str = "user_name";
|
||||||
|
const SESSION_OIDC_STATE: &str = "oidc_state";
|
||||||
|
const MAX_UPLOADED_IMAGE_DIMENSION: u32 = 1920;
|
||||||
|
const UPLOADED_IMAGE_JPEG_QUALITY: u8 = 82;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -88,6 +97,74 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_query_param(request: &Request, key: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{}=", key);
|
||||||
|
request.uri().query().and_then(|q| {
|
||||||
|
q.split('&')
|
||||||
|
.find_map(|p| p.strip_prefix(&prefix).map(|v| v.to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_format_from_ext(ext: &str) -> Option<ImageFormat> {
|
||||||
|
match ext {
|
||||||
|
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
|
||||||
|
"png" => Some(ImageFormat::Png),
|
||||||
|
"webp" => Some(ImageFormat::WebP),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result<Option<Vec<u8>>> {
|
||||||
|
let Some(format) = image_format_from_ext(ext) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut decoder = ImageReader::with_format(Cursor::new(data), format)
|
||||||
|
.into_decoder()
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let orientation = decoder
|
||||||
|
.orientation()
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let mut image = image::DynamicImage::from_decoder(decoder)
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
image.apply_orientation(orientation);
|
||||||
|
|
||||||
|
let resized = image.resize(
|
||||||
|
MAX_UPLOADED_IMAGE_DIMENSION,
|
||||||
|
MAX_UPLOADED_IMAGE_DIMENSION,
|
||||||
|
FilterType::Lanczos3,
|
||||||
|
);
|
||||||
|
let rgb = resized.to_rgb8();
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, UPLOADED_IMAGE_JPEG_QUALITY);
|
||||||
|
encoder
|
||||||
|
.encode_image(&rgb)
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Some(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_uploaded_image(
|
||||||
|
upload_dir: &str,
|
||||||
|
file_id: uuid::Uuid,
|
||||||
|
ext: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> cot::Result<String> {
|
||||||
|
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
|
||||||
|
let path = format!("{}/{}.jpg", upload_dir, file_id);
|
||||||
|
tokio::fs::write(&path, &encoded)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(path)
|
||||||
|
} else {
|
||||||
|
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||||
|
tokio::fs::write(&path, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Soft pastel palette for client calendar colors.
|
/// Soft pastel palette for client calendar colors.
|
||||||
const CLIENT_COLORS: &[&str] = &[
|
const CLIENT_COLORS: &[&str] = &[
|
||||||
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
|
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
|
||||||
@@ -193,6 +270,9 @@ struct LoginTemplate<'a> {
|
|||||||
t: &'a Translations,
|
t: &'a Translations,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
turnstile_site_key: String,
|
||||||
|
auth_password_enabled: bool,
|
||||||
|
auth_sso_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -254,6 +334,8 @@ struct SettingsTemplate<'a> {
|
|||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
settings: Vec<Setting>,
|
settings: Vec<Setting>,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
auth_password_checked: bool,
|
||||||
|
auth_sso_checked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -274,6 +356,7 @@ struct ScheduleNewTemplate<'a> {
|
|||||||
clients: Vec<Client>,
|
clients: Vec<Client>,
|
||||||
users: Vec<User>,
|
users: Vec<User>,
|
||||||
current_user_id: i64,
|
current_user_id: i64,
|
||||||
|
timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -283,7 +366,7 @@ struct ScheduleEditTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
visit: Visit,
|
visit: Visit,
|
||||||
clients: Vec<Client>,
|
client: Client,
|
||||||
users: Vec<User>,
|
users: Vec<User>,
|
||||||
media: Vec<Media>,
|
media: Vec<Media>,
|
||||||
}
|
}
|
||||||
@@ -345,10 +428,55 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
return Redirect::new(format!("/admin/setup?lang={}", lang.code())).into_response();
|
return Redirect::new(format!("/admin/setup?lang={}", lang.code())).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
|
|
||||||
|
let settings = Setting::objects().all(&db).await?;
|
||||||
|
let get_val = |key: &str| -> String {
|
||||||
|
settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == key)
|
||||||
|
.map(|s| s.value.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_setting = get_val("auth_password_enabled");
|
||||||
|
let sso_setting = get_val("auth_sso_enabled");
|
||||||
|
let oidc_configured = !get_val("oidc_issuer_url").trim().is_empty();
|
||||||
|
|
||||||
|
// Default: password enabled if setting was never saved
|
||||||
|
let auth_password_enabled = if password_setting.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
password_setting == "true"
|
||||||
|
};
|
||||||
|
let auth_sso_enabled = sso_setting == "true" && oidc_configured;
|
||||||
|
|
||||||
|
// Fallback: if neither is enabled, show password form
|
||||||
|
let (auth_password_enabled, auth_sso_enabled) = if !auth_password_enabled && !auth_sso_enabled {
|
||||||
|
(true, false)
|
||||||
|
} else {
|
||||||
|
(auth_password_enabled, auth_sso_enabled)
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = get_query_param(&request, "error").map(|code| {
|
||||||
|
let t = lang.t();
|
||||||
|
match code.as_str() {
|
||||||
|
"sso_group" => t.login_sso_error_group,
|
||||||
|
"sso_provider" => t.login_sso_error_provider,
|
||||||
|
"sso_disabled" => t.login_sso_error_user_disabled,
|
||||||
|
"sso" => t.login_sso_error,
|
||||||
|
_ => t.login_sso_error,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
let body = LoginTemplate {
|
let body = LoginTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
error: None,
|
error,
|
||||||
|
turnstile_site_key,
|
||||||
|
auth_password_enabled,
|
||||||
|
auth_sso_enabled,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -424,11 +552,27 @@ async fn setup_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
struct LoginForm {
|
struct LoginForm {
|
||||||
login: String,
|
login: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
#[serde(default, rename = "cf-turnstile-response")]
|
||||||
|
cf_turnstile_response: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn login_submit(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?;
|
let (lang, form): (_, LoginForm) = parse_form_from_request(request).await?;
|
||||||
|
|
||||||
|
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
|
||||||
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
|
let body = LoginTemplate {
|
||||||
|
t: lang.t(),
|
||||||
|
lang,
|
||||||
|
error: Some(lang.t().login_error.to_string()),
|
||||||
|
turnstile_site_key,
|
||||||
|
auth_password_enabled: true,
|
||||||
|
auth_sso_enabled: false,
|
||||||
|
}
|
||||||
|
.render()?;
|
||||||
|
return html_response(body, lang);
|
||||||
|
}
|
||||||
|
|
||||||
let login = form.login.clone();
|
let login = form.login.clone();
|
||||||
let user = query!(User, $login == login && $status == "active")
|
let user = query!(User, $login == login && $status == "active")
|
||||||
.get(&db)
|
.get(&db)
|
||||||
@@ -447,10 +591,14 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
let body = LoginTemplate {
|
let body = LoginTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
error: Some(lang.t().login_error.to_string()),
|
error: Some(lang.t().login_error.to_string()),
|
||||||
|
turnstile_site_key,
|
||||||
|
auth_password_enabled: true,
|
||||||
|
auth_sso_enabled: false,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -462,6 +610,325 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
|
|||||||
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OIDC Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Read an OIDC-related setting from the DB, returning empty string if absent.
|
||||||
|
async fn oidc_setting(db: &Database, name: &str) -> cot::Result<String> {
|
||||||
|
let k = name.to_string();
|
||||||
|
Ok(query!(Setting, $key == k)
|
||||||
|
.get(db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the OpenID Connect discovery document and extract a field.
|
||||||
|
async fn oidc_discover(issuer_url: &str, field: &str) -> Option<String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/.well-known/openid-configuration",
|
||||||
|
issuer_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let resp = reqwest::Client::new().get(&url).send().await.ok()?;
|
||||||
|
let json: serde_json::Value = resp.json().await.ok()?;
|
||||||
|
json.get(field)?.as_str().map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the payload of a JWT (base64url, no signature verification).
|
||||||
|
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
|
||||||
|
use base64::Engine;
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let payload = parts[1];
|
||||||
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload)
|
||||||
|
.ok()?;
|
||||||
|
serde_json::from_slice(&bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oidc_state_cookie(value: &str, max_age_seconds: u32) -> String {
|
||||||
|
format!(
|
||||||
|
"oidc_state={}; Path=/admin/oidc; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||||
|
value, max_age_seconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_cookie(request: &Request, name: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{name}=");
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.get("cookie")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies.split(';').find_map(|part| {
|
||||||
|
let part = part.trim();
|
||||||
|
part.strip_prefix(&prefix).map(|v| v.to_string())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn oidc_start(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
|
let lang = detect_lang(&request);
|
||||||
|
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||||
|
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||||
|
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||||
|
|
||||||
|
if issuer_url.trim().is_empty() || client_id.trim().is_empty() {
|
||||||
|
return Redirect::new(format!(
|
||||||
|
"/admin/login?lang={}&error=sso_provider",
|
||||||
|
lang.code()
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
|
||||||
|
Some(ep) => ep,
|
||||||
|
None => {
|
||||||
|
return Redirect::new(format!(
|
||||||
|
"/admin/login?lang={}&error=sso_provider",
|
||||||
|
lang.code()
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = rand_token();
|
||||||
|
session.insert(SESSION_OIDC_STATE, state.clone()).await?;
|
||||||
|
|
||||||
|
let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let redirect_url = format!(
|
||||||
|
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}",
|
||||||
|
authorization_endpoint,
|
||||||
|
urlencoding::encode(&client_id),
|
||||||
|
urlencoding::encode(&redirect_uri),
|
||||||
|
urlencoding::encode(&state),
|
||||||
|
);
|
||||||
|
|
||||||
|
let state_cookie = oidc_state_cookie(&state, 600);
|
||||||
|
|
||||||
|
Redirect::new(redirect_url)
|
||||||
|
.into_response()?
|
||||||
|
.with_header("set-cookie", state_cookie)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
|
let lang = detect_lang(&request);
|
||||||
|
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
|
||||||
|
|
||||||
|
// Prefer the server-side session; keep the cookie as a compatibility
|
||||||
|
// fallback for flows started before this code was deployed.
|
||||||
|
let saved_state_from_session = session
|
||||||
|
.get::<String>(SESSION_OIDC_STATE)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let saved_state_from_cookie = get_cookie(&request, "oidc_state");
|
||||||
|
let saved_state = saved_state_from_session
|
||||||
|
.as_deref()
|
||||||
|
.or(saved_state_from_cookie.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Extract code and state from query string
|
||||||
|
let query_str = request.uri().query().unwrap_or("");
|
||||||
|
let mut code = String::new();
|
||||||
|
let mut state = String::new();
|
||||||
|
for pair in query_str.split('&') {
|
||||||
|
if let Some(v) = pair.strip_prefix("code=") {
|
||||||
|
code = v.to_string();
|
||||||
|
} else if let Some(v) = pair.strip_prefix("state=") {
|
||||||
|
state = v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if code.is_empty() || state.is_empty() || state != saved_state {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "oidc",
|
||||||
|
has_session_state = saved_state_from_session.is_some(),
|
||||||
|
has_cookie_state = saved_state_from_cookie.is_some(),
|
||||||
|
code_empty = code.is_empty(),
|
||||||
|
state_empty = state.is_empty(),
|
||||||
|
"OIDC state mismatch",
|
||||||
|
);
|
||||||
|
let clear_cookie = oidc_state_cookie("", 0);
|
||||||
|
return Redirect::new(fail("sso"))
|
||||||
|
.into_response()?
|
||||||
|
.with_header("set-cookie", clear_cookie)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let _ = session.remove::<String>(SESSION_OIDC_STATE).await;
|
||||||
|
|
||||||
|
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
|
||||||
|
let client_id = oidc_setting(&db, "oidc_client_id").await?;
|
||||||
|
let client_secret = oidc_setting(&db, "oidc_client_secret").await?;
|
||||||
|
let site_domain = oidc_setting(&db, "site_domain").await?;
|
||||||
|
|
||||||
|
// Get token endpoint from discovery
|
||||||
|
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
|
||||||
|
Some(ep) => ep,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
|
||||||
|
return Redirect::new(fail("sso_provider")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let redirect_uri = format!("{}/admin/oidc/callback", site_domain.trim_end_matches('/'));
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
let token_resp = reqwest::Client::new()
|
||||||
|
.post(&token_endpoint)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("code", &code),
|
||||||
|
("redirect_uri", &redirect_uri),
|
||||||
|
("client_id", &client_id),
|
||||||
|
("client_secret", &client_secret),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let token_json: serde_json::Value = match token_resp {
|
||||||
|
Ok(resp) => match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("OIDC token response parse error: {e}");
|
||||||
|
return Redirect::new(fail("sso_provider")).into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("OIDC token request failed: {e}");
|
||||||
|
return Redirect::new(fail("sso_provider")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("OIDC no id_token in response: {token_json}");
|
||||||
|
return Redirect::new(fail("sso_provider")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
|
||||||
|
let claims = match decode_jwt_payload(id_token) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("OIDC JWT decode failed");
|
||||||
|
return Redirect::new(fail("sso_provider")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
|
||||||
|
Some(u) => u.to_string(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!("OIDC no preferred_username in claims: {claims}");
|
||||||
|
return Redirect::new(fail("sso")).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_name = claims
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Check group membership
|
||||||
|
let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?;
|
||||||
|
if !allowed_groups.trim().is_empty() {
|
||||||
|
let required: Vec<&str> = allowed_groups
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
let user_groups: Vec<String> = claims
|
||||||
|
.get("groups")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|g| g.as_str())
|
||||||
|
.map(|g| g.trim_start_matches('/').to_string())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let has_group = required
|
||||||
|
.iter()
|
||||||
|
.any(|r| user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r)));
|
||||||
|
|
||||||
|
if !has_group {
|
||||||
|
tracing::warn!(
|
||||||
|
"OIDC group check failed: user={preferred_username}, user_groups={user_groups:?}, required={required:?}"
|
||||||
|
);
|
||||||
|
return Redirect::new(fail("sso_group")).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let login = preferred_username.clone();
|
||||||
|
let existing = query!(User, $login == login).get(&db).await?;
|
||||||
|
|
||||||
|
let user = match existing {
|
||||||
|
Some(u) => {
|
||||||
|
tracing::info!(target: "oidc", username = %u.login, "SSO login: existing user");
|
||||||
|
u
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::info!(target: "oidc", username = %preferred_username, "SSO login: creating new user");
|
||||||
|
let mut new_user = User {
|
||||||
|
id: Auto::auto(),
|
||||||
|
login: preferred_username.clone(),
|
||||||
|
password_hash: String::new(),
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
telegram_chat_id: None,
|
||||||
|
telegram_notifications: Some(false),
|
||||||
|
status: "active".to_string(),
|
||||||
|
created_at: now_utc(),
|
||||||
|
updated_at: now_utc(),
|
||||||
|
};
|
||||||
|
new_user.save(&db).await?;
|
||||||
|
// Re-query to get the DB-assigned id (Auto::auto() may not be
|
||||||
|
// populated in the struct after save)
|
||||||
|
let login2 = preferred_username.clone();
|
||||||
|
match query!(User, $login == login2).get(&db).await? {
|
||||||
|
Some(u) => {
|
||||||
|
tracing::info!(target: "oidc", username = %u.login, id = ?u.id, "SSO login: new user created and fetched");
|
||||||
|
u
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(target: "oidc", username = %preferred_username, "SSO login: user not found after creation");
|
||||||
|
return Redirect::new(fail("sso")).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if user.status != "active" {
|
||||||
|
tracing::warn!(target: "oidc", username = %user.login, status = %user.status, "SSO login: user disabled");
|
||||||
|
return Redirect::new(fail("sso_disabled")).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_name = user
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(&user.login)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
tracing::info!(target: "oidc", username = %user.login, display_name = %session_name, "SSO login: session established");
|
||||||
|
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
|
||||||
|
session.insert(SESSION_USER_NAME, session_name).await?;
|
||||||
|
|
||||||
|
// Clear the oidc_state cookie
|
||||||
|
let clear_cookie = oidc_state_cookie("", 0);
|
||||||
|
Redirect::new(format!("/admin/?lang={}", lang.code()))
|
||||||
|
.into_response()?
|
||||||
|
.with_header("set-cookie", clear_cookie)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET Handlers (protected)
|
// GET Handlers (protected)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -476,8 +943,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
let tz = crate::tz::load_tz(&db).await;
|
let tz = crate::tz::load_tz(&db).await;
|
||||||
let today = crate::tz::today_in_tz(tz);
|
let today = crate::tz::today_in_tz(tz);
|
||||||
|
|
||||||
let all_visits = Visit::objects().all(&db).await?;
|
let mut all_visits = Visit::objects().all(&db).await?;
|
||||||
let clients = Client::objects().all(&db).await?;
|
all_visits.retain(|v| v.status != "deleted");
|
||||||
|
let mut clients = Client::objects().all(&db).await?;
|
||||||
|
clients.retain(|c| c.status != "deleted");
|
||||||
|
|
||||||
let mut today_visits: Vec<TodayVisit> = all_visits
|
let mut today_visits: Vec<TodayVisit> = all_visits
|
||||||
.iter()
|
.iter()
|
||||||
@@ -500,9 +969,7 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R
|
|||||||
|
|
||||||
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
|
let mut all_feedbacks: Vec<RecentFeedback> = all_visits
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|v| {
|
.filter(|v| v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some())
|
||||||
v.user_id.primary_key().unwrap() == user_id && v.client_feedback.is_some()
|
|
||||||
})
|
|
||||||
.map(|v| {
|
.map(|v| {
|
||||||
let cid: i64 = v.client_id.primary_key().unwrap();
|
let cid: i64 = v.client_id.primary_key().unwrap();
|
||||||
let client_name = clients
|
let client_name = clients
|
||||||
@@ -581,11 +1048,12 @@ async fn clients_page(request: Request, session: Session, db: Database) -> cot::
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let show_all = has_query_flag(&request, "all");
|
let show_all = has_query_flag(&request, "all");
|
||||||
let clients = if show_all {
|
let mut clients = if show_all {
|
||||||
Client::objects().all(&db).await?
|
Client::objects().all(&db).await?
|
||||||
} else {
|
} else {
|
||||||
query!(Client, $status == "active").all(&db).await?
|
query!(Client, $status == "active").all(&db).await?
|
||||||
};
|
};
|
||||||
|
clients.retain(|c| c.status != "deleted");
|
||||||
let body = ClientsTemplate {
|
let body = ClientsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
@@ -718,12 +1186,24 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
|||||||
Err(resp) => return Ok(resp),
|
Err(resp) => return Ok(resp),
|
||||||
};
|
};
|
||||||
let settings = Setting::objects().all(&db).await?;
|
let settings = Setting::objects().all(&db).await?;
|
||||||
|
let auth_password_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_password_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(true);
|
||||||
|
let auth_sso_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_sso_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
let body = SettingsTemplate {
|
let body = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: false,
|
saved: false,
|
||||||
|
auth_password_checked,
|
||||||
|
auth_sso_checked,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -796,6 +1276,17 @@ struct SettingsForm {
|
|||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
site_domain: String,
|
site_domain: String,
|
||||||
|
seo_keywords: String,
|
||||||
|
turnstile_site_key: String,
|
||||||
|
turnstile_secret_key: String,
|
||||||
|
oidc_issuer_url: String,
|
||||||
|
oidc_client_id: String,
|
||||||
|
oidc_client_secret: String,
|
||||||
|
oidc_allowed_groups: String,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_password_enabled: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_sso_enabled: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -811,6 +1302,29 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
("pricing_info", form.pricing_info),
|
("pricing_info", form.pricing_info),
|
||||||
("timezone", form.timezone),
|
("timezone", form.timezone),
|
||||||
("site_domain", form.site_domain),
|
("site_domain", form.site_domain),
|
||||||
|
("seo_keywords", form.seo_keywords),
|
||||||
|
("turnstile_site_key", form.turnstile_site_key),
|
||||||
|
("turnstile_secret_key", form.turnstile_secret_key),
|
||||||
|
("oidc_issuer_url", form.oidc_issuer_url),
|
||||||
|
("oidc_client_id", form.oidc_client_id),
|
||||||
|
("oidc_client_secret", form.oidc_client_secret),
|
||||||
|
("oidc_allowed_groups", form.oidc_allowed_groups),
|
||||||
|
(
|
||||||
|
"auth_password_enabled",
|
||||||
|
if form.auth_password_enabled.is_some() {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auth_sso_enabled",
|
||||||
|
if form.auth_sso_enabled.is_some() {
|
||||||
|
"true".to_string()
|
||||||
|
} else {
|
||||||
|
"false".to_string()
|
||||||
|
},
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let k = key.to_string();
|
let k = key.to_string();
|
||||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||||
@@ -833,12 +1347,24 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
}
|
}
|
||||||
|
|
||||||
let settings = Setting::objects().all(&db).await?;
|
let settings = Setting::objects().all(&db).await?;
|
||||||
|
let auth_password_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_password_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(true);
|
||||||
|
let auth_sso_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.key == "auth_sso_enabled")
|
||||||
|
.map(|s| s.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
let rendered = SettingsTemplate {
|
let rendered = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: true,
|
saved: true,
|
||||||
|
auth_password_checked,
|
||||||
|
auth_sso_checked,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(rendered, lang)
|
html_response(rendered, lang)
|
||||||
@@ -941,6 +1467,24 @@ async fn client_activate(
|
|||||||
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn client_delete(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
Path(client_id): Path<i64>,
|
||||||
|
) -> cot::Result<Response> {
|
||||||
|
let lang = detect_lang(&request);
|
||||||
|
if let Err(resp) = require_auth(&session, lang).await {
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? {
|
||||||
|
client.status = "deleted".to_string();
|
||||||
|
client.updated_at = now_utc();
|
||||||
|
client.save(&db).await?;
|
||||||
|
}
|
||||||
|
Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn user_archive(
|
async fn user_archive(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -1108,6 +1652,7 @@ async fn schedule_new_page(
|
|||||||
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
|
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
|
||||||
let clients = query!(Client, $status == "active").all(&db).await?;
|
let clients = query!(Client, $status == "active").all(&db).await?;
|
||||||
let users = query!(User, $status == "active").all(&db).await?;
|
let users = query!(User, $status == "active").all(&db).await?;
|
||||||
|
let tz = crate::tz::load_tz(&db).await;
|
||||||
let body = ScheduleNewTemplate {
|
let body = ScheduleNewTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
@@ -1115,6 +1660,7 @@ async fn schedule_new_page(
|
|||||||
clients,
|
clients,
|
||||||
users,
|
users,
|
||||||
current_user_id,
|
current_user_id,
|
||||||
|
timezone: tz.to_string(),
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -1156,12 +1702,18 @@ async fn schedule_events(
|
|||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
for v in &visits {
|
for v in &visits {
|
||||||
|
if v.status == "deleted" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if v.visit_date < start_date || v.visit_date > end_date {
|
if v.visit_date < start_date || v.visit_date > end_date {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let client_id_val: i64 = v.client_id.primary_key().unwrap();
|
let client_id_val: i64 = v.client_id.primary_key().unwrap();
|
||||||
let user_id_val: i64 = v.user_id.primary_key().unwrap();
|
let user_id_val: i64 = v.user_id.primary_key().unwrap();
|
||||||
let client = clients.iter().find(|c| c.id.unwrap() == client_id_val);
|
let client = clients.iter().find(|c| c.id.unwrap() == client_id_val);
|
||||||
|
if client.map(|c| c.status.as_str()) == Some("deleted") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let user = users.iter().find(|u| u.id.unwrap() == user_id_val);
|
let user = users.iter().find(|u| u.id.unwrap() == user_id_val);
|
||||||
let client_name = client.map(|c| c.name.as_str()).unwrap_or("?");
|
let client_name = client.map(|c| c.name.as_str()).unwrap_or("?");
|
||||||
let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or("");
|
let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or("");
|
||||||
@@ -1172,7 +1724,7 @@ async fn schedule_events(
|
|||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
|
|
||||||
let (bg_color, text_color) = match v.status.as_str() {
|
let (bg_color, text_color) = match v.status.as_str() {
|
||||||
"cancelled" => ("#ccc".to_string(), "#666"),
|
"cancelled" => ("#ffb3b3".to_string(), "#a00"),
|
||||||
"completed" => (format!("{}88", client_color), "#fff"),
|
"completed" => (format!("{}88", client_color), "#fff"),
|
||||||
_ => (client_color.to_string(), "#fff"),
|
_ => (client_color.to_string(), "#fff"),
|
||||||
};
|
};
|
||||||
@@ -1285,7 +1837,16 @@ async fn schedule_edit_page(
|
|||||||
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let clients = query!(Client, $status == "active").all(&db).await?;
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
|
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
||||||
|
let client = match query!(Client, $id == client_id).get(&db).await? {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
let users = query!(User, $status == "active").all(&db).await?;
|
let users = query!(User, $status == "active").all(&db).await?;
|
||||||
let mut visit_media = Media::objects().all(&db).await?;
|
let mut visit_media = Media::objects().all(&db).await?;
|
||||||
visit_media.retain(|m| {
|
visit_media.retain(|m| {
|
||||||
@@ -1301,7 +1862,7 @@ async fn schedule_edit_page(
|
|||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
visit,
|
visit,
|
||||||
clients,
|
client,
|
||||||
users,
|
users,
|
||||||
media: visit_media,
|
media: visit_media,
|
||||||
}
|
}
|
||||||
@@ -1311,7 +1872,6 @@ async fn schedule_edit_page(
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct EditVisitForm {
|
struct EditVisitForm {
|
||||||
client_id: i64,
|
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
visit_date: String,
|
visit_date: String,
|
||||||
time_start: String,
|
time_start: String,
|
||||||
@@ -1332,7 +1892,9 @@ async fn schedule_edit_submit(
|
|||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
visit.client_id = ForeignKey::PrimaryKey(Auto::fixed(form.client_id));
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id));
|
visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id));
|
||||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") {
|
if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") {
|
||||||
visit.visit_date = d;
|
visit.visit_date = d;
|
||||||
@@ -1358,7 +1920,11 @@ async fn visit_delete(
|
|||||||
if let Err(resp) = require_auth(&session, lang).await {
|
if let Err(resp) = require_auth(&session, lang).await {
|
||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
query!(Visit, $id == visit_id).delete(&db).await?;
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
visit.status = "deleted".to_string();
|
||||||
|
visit.updated_at = now_utc();
|
||||||
|
visit.save(&db).await?;
|
||||||
|
}
|
||||||
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1373,6 +1939,9 @@ async fn visit_set_done(
|
|||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
visit.status = "completed".to_string();
|
visit.status = "completed".to_string();
|
||||||
visit.updated_at = now_utc();
|
visit.updated_at = now_utc();
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
@@ -1391,6 +1960,9 @@ async fn visit_set_cancel(
|
|||||||
return Ok(resp);
|
return Ok(resp);
|
||||||
}
|
}
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
visit.status = "cancelled".to_string();
|
visit.status = "cancelled".to_string();
|
||||||
visit.updated_at = now_utc();
|
visit.updated_at = now_utc();
|
||||||
visit.save(&db).await?;
|
visit.save(&db).await?;
|
||||||
@@ -1420,16 +1992,40 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let clients_all = Client::objects().all(&db).await?;
|
||||||
|
let visits_all = Visit::objects().all(&db).await?;
|
||||||
let mut media_list = Media::objects().all(&db).await?;
|
let mut media_list = Media::objects().all(&db).await?;
|
||||||
media_list.retain(|m| m.status == "active");
|
media_list.retain(|m| {
|
||||||
|
if m.status != "active" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let cid: i64 = m.client_id.primary_key().unwrap();
|
||||||
|
if clients_all
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id.unwrap() == cid)
|
||||||
|
.map(|c| c.status.as_str())
|
||||||
|
== Some("deleted")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(fk) = &m.visit_id {
|
||||||
|
let vid: i64 = fk.primary_key().unwrap();
|
||||||
|
if visits_all
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.id.unwrap() == vid)
|
||||||
|
.map(|v| v.status.as_str())
|
||||||
|
== Some("deleted")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
if filter_client_id > 0 {
|
if filter_client_id > 0 {
|
||||||
media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id);
|
media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id);
|
||||||
}
|
}
|
||||||
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||||
|
|
||||||
let clients_all = Client::objects().all(&db).await?;
|
|
||||||
let visits_all = Visit::objects().all(&db).await?;
|
|
||||||
|
|
||||||
let items: Vec<MediaItem> = media_list
|
let items: Vec<MediaItem> = media_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
@@ -1483,6 +2079,9 @@ async fn media_upload_page(
|
|||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
||||||
};
|
};
|
||||||
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
let cid: i64 = visit.client_id.primary_key().unwrap();
|
let cid: i64 = visit.client_id.primary_key().unwrap();
|
||||||
let client = query!(Client, $id == cid).get(&db).await?;
|
let client = query!(Client, $id == cid).get(&db).await?;
|
||||||
let client_name = client.map(|c| c.name).unwrap_or_default();
|
let client_name = client.map(|c| c.name).unwrap_or_default();
|
||||||
@@ -1531,6 +2130,9 @@ async fn media_upload_submit(
|
|||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(),
|
||||||
};
|
};
|
||||||
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
let client_id: i64 = visit.client_id.primary_key().unwrap();
|
||||||
|
|
||||||
let bytes = request.into_body().into_bytes().await?;
|
let bytes = request.into_body().into_bytes().await?;
|
||||||
@@ -1580,7 +2182,6 @@ async fn media_upload_submit(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let file_id = uuid::Uuid::new_v4();
|
let file_id = uuid::Uuid::new_v4();
|
||||||
let file_path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
|
||||||
|
|
||||||
let data = field
|
let data = field
|
||||||
.bytes()
|
.bytes()
|
||||||
@@ -1589,9 +2190,15 @@ async fn media_upload_submit(
|
|||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&file_path, &data)
|
let file_path = if file_type == "photo" {
|
||||||
|
save_uploaded_image(&upload_dir, file_id, &ext, &data).await?
|
||||||
|
} else {
|
||||||
|
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
||||||
|
tokio::fs::write(&path, &data)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
path
|
||||||
|
};
|
||||||
|
|
||||||
saved_files.push((file_path, file_type.to_string()));
|
saved_files.push((file_path, file_type.to_string()));
|
||||||
}
|
}
|
||||||
@@ -1641,8 +2248,10 @@ async fn media_delete(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||||
|
let file_path = m.file_path.clone();
|
||||||
m.status = "archived".to_string();
|
m.status = "archived".to_string();
|
||||||
m.save(&db).await?;
|
m.save(&db).await?;
|
||||||
|
let _ = tokio::fs::remove_file(&file_path).await;
|
||||||
}
|
}
|
||||||
let redirect_url = referer
|
let redirect_url = referer
|
||||||
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||||
@@ -1799,10 +2408,7 @@ async fn testimonial_add(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
let file_id = uuid::Uuid::new_v4();
|
let file_id = uuid::Uuid::new_v4();
|
||||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
|
||||||
tokio::fs::write(&path, &data)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
image_path = Some(path);
|
image_path = Some(path);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1950,10 +2556,7 @@ async fn testimonial_edit(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
let file_id = uuid::Uuid::new_v4();
|
let file_id = uuid::Uuid::new_v4();
|
||||||
let path = format!("{}/{}.{}", upload_dir, file_id, ext);
|
let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?;
|
||||||
tokio::fs::write(&path, &data)
|
|
||||||
.await
|
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
||||||
new_image_path = Some(path);
|
new_image_path = Some(path);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -2025,6 +2628,8 @@ pub fn admin_router() -> Router {
|
|||||||
Route::with_handler_and_name("/logout", logout, "admin-logout"),
|
Route::with_handler_and_name("/logout", logout, "admin-logout"),
|
||||||
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
|
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
|
||||||
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
|
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
|
||||||
|
Route::with_handler_and_name("/oidc/start", oidc_start, "admin-oidc-start"),
|
||||||
|
Route::with_handler_and_name("/oidc/callback", oidc_callback, "admin-oidc-callback"),
|
||||||
// Protected
|
// Protected
|
||||||
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
|
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
|
||||||
Route::with_handler_and_name("/", admin_index, "admin-index"),
|
Route::with_handler_and_name("/", admin_index, "admin-index"),
|
||||||
@@ -2063,6 +2668,11 @@ pub fn admin_router() -> Router {
|
|||||||
client_activate,
|
client_activate,
|
||||||
"admin-client-activate",
|
"admin-client-activate",
|
||||||
),
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/clients/{client_id}/delete",
|
||||||
|
client_delete,
|
||||||
|
"admin-client-delete",
|
||||||
|
),
|
||||||
Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"),
|
Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"),
|
||||||
Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"),
|
Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"),
|
||||||
Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"),
|
Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"),
|
||||||
|
|||||||
+74
-3
@@ -103,8 +103,11 @@ pub struct Translations {
|
|||||||
pub clients_media_link: &'static str,
|
pub clients_media_link: &'static str,
|
||||||
pub clients_add_title: &'static str,
|
pub clients_add_title: &'static str,
|
||||||
pub clients_add_button: &'static str,
|
pub clients_add_button: &'static str,
|
||||||
|
pub clients_delete: &'static str,
|
||||||
|
pub clients_delete_confirm: &'static str,
|
||||||
pub client_status_active: &'static str,
|
pub client_status_active: &'static str,
|
||||||
pub client_status_archived: &'static str,
|
pub client_status_archived: &'static str,
|
||||||
|
pub client_status_deleted: &'static str,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
pub users_title: &'static str,
|
pub users_title: &'static str,
|
||||||
@@ -134,6 +137,20 @@ pub struct Translations {
|
|||||||
pub settings_pricing_info: &'static str,
|
pub settings_pricing_info: &'static str,
|
||||||
pub settings_timezone: &'static str,
|
pub settings_timezone: &'static str,
|
||||||
pub settings_site_domain: &'static str,
|
pub settings_site_domain: &'static str,
|
||||||
|
pub settings_seo_keywords: &'static str,
|
||||||
|
pub settings_turnstile_site_key: &'static str,
|
||||||
|
pub settings_turnstile_secret_key: &'static str,
|
||||||
|
pub settings_oidc_issuer_url: &'static str,
|
||||||
|
pub settings_oidc_client_id: &'static str,
|
||||||
|
pub settings_oidc_client_secret: &'static str,
|
||||||
|
pub settings_oidc_allowed_groups: &'static str,
|
||||||
|
pub settings_auth_password_enabled: &'static str,
|
||||||
|
pub settings_auth_sso_enabled: &'static str,
|
||||||
|
pub settings_section_advanced: &'static str,
|
||||||
|
pub settings_section_notifications: &'static str,
|
||||||
|
pub settings_section_captcha: &'static str,
|
||||||
|
pub settings_section_oidc: &'static str,
|
||||||
|
pub settings_section_general: &'static str,
|
||||||
pub landing_contact_label: &'static str,
|
pub landing_contact_label: &'static str,
|
||||||
pub landing_pricing_title: &'static str,
|
pub landing_pricing_title: &'static str,
|
||||||
|
|
||||||
@@ -148,6 +165,11 @@ pub struct Translations {
|
|||||||
pub login_title: &'static str,
|
pub login_title: &'static str,
|
||||||
pub login_button: &'static str,
|
pub login_button: &'static str,
|
||||||
pub login_error: &'static str,
|
pub login_error: &'static str,
|
||||||
|
pub login_sso_button: &'static str,
|
||||||
|
pub login_sso_error: &'static str,
|
||||||
|
pub login_sso_error_group: &'static str,
|
||||||
|
pub login_sso_error_provider: &'static str,
|
||||||
|
pub login_sso_error_user_disabled: &'static str,
|
||||||
pub logout: &'static str,
|
pub logout: &'static str,
|
||||||
pub setup_title: &'static str,
|
pub setup_title: &'static str,
|
||||||
pub setup_description: &'static str,
|
pub setup_description: &'static str,
|
||||||
@@ -237,6 +259,7 @@ pub struct Translations {
|
|||||||
pub visit_status_scheduled: &'static str,
|
pub visit_status_scheduled: &'static str,
|
||||||
pub visit_status_completed: &'static str,
|
pub visit_status_completed: &'static str,
|
||||||
pub visit_status_cancelled: &'static str,
|
pub visit_status_cancelled: &'static str,
|
||||||
|
pub visit_status_deleted: &'static str,
|
||||||
pub schedule_mark_done: &'static str,
|
pub schedule_mark_done: &'static str,
|
||||||
pub schedule_cancel: &'static str,
|
pub schedule_cancel: &'static str,
|
||||||
pub schedule_edit_title: &'static str,
|
pub schedule_edit_title: &'static str,
|
||||||
@@ -317,8 +340,11 @@ static RU: Translations = Translations {
|
|||||||
clients_media_link: "Медиа",
|
clients_media_link: "Медиа",
|
||||||
clients_add_title: "Добавить клиента",
|
clients_add_title: "Добавить клиента",
|
||||||
clients_add_button: "Добавить",
|
clients_add_button: "Добавить",
|
||||||
|
clients_delete: "Удалить клиента",
|
||||||
|
clients_delete_confirm: "Точно удалить этого клиента?",
|
||||||
client_status_active: "Активный",
|
client_status_active: "Активный",
|
||||||
client_status_archived: "Архив",
|
client_status_archived: "Архив",
|
||||||
|
client_status_deleted: "Удалён",
|
||||||
|
|
||||||
users_title: "Администраторы",
|
users_title: "Администраторы",
|
||||||
users_login: "Логин",
|
users_login: "Логин",
|
||||||
@@ -346,6 +372,20 @@ static RU: Translations = Translations {
|
|||||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||||
settings_site_domain: "Домен сайта (например https://example.com)",
|
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||||
|
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||||
|
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
|
||||||
|
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
|
||||||
|
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
|
||||||
|
settings_oidc_client_id: "OIDC — Client ID",
|
||||||
|
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||||
|
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
|
||||||
|
settings_auth_password_enabled: "Вход по логину и паролю",
|
||||||
|
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
|
||||||
|
settings_section_advanced: "Расширенные настройки",
|
||||||
|
settings_section_notifications: "Уведомления",
|
||||||
|
settings_section_captcha: "Защита от ботов",
|
||||||
|
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||||
|
settings_section_general: "Сайт",
|
||||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||||
landing_pricing_title: "Стоимость",
|
landing_pricing_title: "Стоимость",
|
||||||
|
|
||||||
@@ -380,6 +420,11 @@ static RU: Translations = Translations {
|
|||||||
login_title: "Вход в систему",
|
login_title: "Вход в систему",
|
||||||
login_button: "Войти",
|
login_button: "Войти",
|
||||||
login_error: "Неверный логин или пароль.",
|
login_error: "Неверный логин или пароль.",
|
||||||
|
login_sso_button: "Войти через SSO",
|
||||||
|
login_sso_error: "Ошибка SSO-авторизации.",
|
||||||
|
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
|
||||||
|
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
|
||||||
|
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
|
||||||
logout: "Выйти",
|
logout: "Выйти",
|
||||||
setup_title: "Создание администратора",
|
setup_title: "Создание администратора",
|
||||||
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
|
||||||
@@ -398,7 +443,7 @@ static RU: Translations = Translations {
|
|||||||
schedule_new_title: "Запланировать визиты",
|
schedule_new_title: "Запланировать визиты",
|
||||||
schedule_client: "Клиент",
|
schedule_client: "Клиент",
|
||||||
schedule_admin: "Исполнитель",
|
schedule_admin: "Исполнитель",
|
||||||
schedule_default_time: "Время по умолчанию",
|
schedule_default_time: "Время",
|
||||||
schedule_time_start: "С",
|
schedule_time_start: "С",
|
||||||
schedule_time_end: "До",
|
schedule_time_end: "До",
|
||||||
schedule_pick_dates: "Добавить дату",
|
schedule_pick_dates: "Добавить дату",
|
||||||
@@ -415,6 +460,7 @@ static RU: Translations = Translations {
|
|||||||
visit_status_scheduled: "Запланирован",
|
visit_status_scheduled: "Запланирован",
|
||||||
visit_status_completed: "Выполнен",
|
visit_status_completed: "Выполнен",
|
||||||
visit_status_cancelled: "Отменён",
|
visit_status_cancelled: "Отменён",
|
||||||
|
visit_status_deleted: "Удалён",
|
||||||
schedule_mark_done: "Выполнен",
|
schedule_mark_done: "Выполнен",
|
||||||
schedule_cancel: "Отменить",
|
schedule_cancel: "Отменить",
|
||||||
schedule_edit_title: "Редактировать визит",
|
schedule_edit_title: "Редактировать визит",
|
||||||
@@ -425,7 +471,7 @@ static RU: Translations = Translations {
|
|||||||
schedule_delete_confirm: "Точно удалить этот визит?",
|
schedule_delete_confirm: "Точно удалить этот визит?",
|
||||||
|
|
||||||
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
||||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
|
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
|
||||||
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
||||||
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
||||||
landing_hero_cta: "Оставить заявку",
|
landing_hero_cta: "Оставить заявку",
|
||||||
@@ -519,8 +565,11 @@ static EN: Translations = Translations {
|
|||||||
clients_media_link: "Media",
|
clients_media_link: "Media",
|
||||||
clients_add_title: "Add Client",
|
clients_add_title: "Add Client",
|
||||||
clients_add_button: "Add",
|
clients_add_button: "Add",
|
||||||
|
clients_delete: "Delete client",
|
||||||
|
clients_delete_confirm: "Are you sure you want to delete this client?",
|
||||||
client_status_active: "Active",
|
client_status_active: "Active",
|
||||||
client_status_archived: "Archived",
|
client_status_archived: "Archived",
|
||||||
|
client_status_deleted: "Deleted",
|
||||||
|
|
||||||
users_title: "Administrators",
|
users_title: "Administrators",
|
||||||
users_login: "Login",
|
users_login: "Login",
|
||||||
@@ -548,6 +597,20 @@ static EN: Translations = Translations {
|
|||||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||||
settings_site_domain: "Site domain (e.g. https://example.com)",
|
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||||
|
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||||
|
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
|
||||||
|
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
|
||||||
|
settings_oidc_issuer_url: "OIDC — Issuer URL",
|
||||||
|
settings_oidc_client_id: "OIDC — Client ID",
|
||||||
|
settings_oidc_client_secret: "OIDC — Client Secret",
|
||||||
|
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
|
||||||
|
settings_auth_password_enabled: "Password login",
|
||||||
|
settings_auth_sso_enabled: "SSO login (OIDC)",
|
||||||
|
settings_section_advanced: "Advanced settings",
|
||||||
|
settings_section_notifications: "Notifications",
|
||||||
|
settings_section_captcha: "Bot protection",
|
||||||
|
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||||
|
settings_section_general: "Site",
|
||||||
landing_contact_label: "Or contact us directly",
|
landing_contact_label: "Or contact us directly",
|
||||||
landing_pricing_title: "Pricing",
|
landing_pricing_title: "Pricing",
|
||||||
|
|
||||||
@@ -582,6 +645,11 @@ static EN: Translations = Translations {
|
|||||||
login_title: "Sign In",
|
login_title: "Sign In",
|
||||||
login_button: "Sign In",
|
login_button: "Sign In",
|
||||||
login_error: "Invalid login or password.",
|
login_error: "Invalid login or password.",
|
||||||
|
login_sso_button: "Sign in with SSO",
|
||||||
|
login_sso_error: "SSO authentication failed.",
|
||||||
|
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
|
||||||
|
login_sso_error_provider: "Could not reach the authentication provider.",
|
||||||
|
login_sso_error_user_disabled: "Your account is disabled.",
|
||||||
logout: "Sign Out",
|
logout: "Sign Out",
|
||||||
setup_title: "Create Administrator",
|
setup_title: "Create Administrator",
|
||||||
setup_description: "There are no administrators yet. Create the first one to get started.",
|
setup_description: "There are no administrators yet. Create the first one to get started.",
|
||||||
@@ -600,7 +668,7 @@ static EN: Translations = Translations {
|
|||||||
schedule_new_title: "Plan Visits",
|
schedule_new_title: "Plan Visits",
|
||||||
schedule_client: "Client",
|
schedule_client: "Client",
|
||||||
schedule_admin: "Assigned to",
|
schedule_admin: "Assigned to",
|
||||||
schedule_default_time: "Default Time",
|
schedule_default_time: "Time",
|
||||||
schedule_time_start: "From",
|
schedule_time_start: "From",
|
||||||
schedule_time_end: "To",
|
schedule_time_end: "To",
|
||||||
schedule_pick_dates: "Add date",
|
schedule_pick_dates: "Add date",
|
||||||
@@ -617,6 +685,7 @@ static EN: Translations = Translations {
|
|||||||
visit_status_scheduled: "Scheduled",
|
visit_status_scheduled: "Scheduled",
|
||||||
visit_status_completed: "Completed",
|
visit_status_completed: "Completed",
|
||||||
visit_status_cancelled: "Cancelled",
|
visit_status_cancelled: "Cancelled",
|
||||||
|
visit_status_deleted: "Deleted",
|
||||||
schedule_mark_done: "Done",
|
schedule_mark_done: "Done",
|
||||||
schedule_cancel: "Cancel",
|
schedule_cancel: "Cancel",
|
||||||
schedule_edit_title: "Edit Visit",
|
schedule_edit_title: "Edit Visit",
|
||||||
@@ -703,6 +772,7 @@ impl Translations {
|
|||||||
"scheduled" => self.visit_status_scheduled,
|
"scheduled" => self.visit_status_scheduled,
|
||||||
"completed" => self.visit_status_completed,
|
"completed" => self.visit_status_completed,
|
||||||
"cancelled" => self.visit_status_cancelled,
|
"cancelled" => self.visit_status_cancelled,
|
||||||
|
"deleted" => self.visit_status_deleted,
|
||||||
_ => "?",
|
_ => "?",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -711,6 +781,7 @@ impl Translations {
|
|||||||
match status {
|
match status {
|
||||||
"active" => self.client_status_active,
|
"active" => self.client_status_active,
|
||||||
"archived" => self.client_status_archived,
|
"archived" => self.client_status_archived,
|
||||||
|
"deleted" => self.client_status_deleted,
|
||||||
_ => "?",
|
_ => "?",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -4,12 +4,15 @@ mod migrations;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
mod public;
|
mod public;
|
||||||
mod telegram;
|
mod telegram;
|
||||||
|
mod turnstile;
|
||||||
mod tz;
|
mod tz;
|
||||||
|
|
||||||
|
use tracing_subscriber;
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
||||||
SessionStoreTypeConfig,
|
SessionStoreConfig, SessionStoreTypeConfig,
|
||||||
};
|
};
|
||||||
use cot::db::migrations::SyncDynMigration;
|
use cot::db::migrations::SyncDynMigration;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
@@ -48,14 +51,32 @@ impl App for PublicApp {
|
|||||||
|
|
||||||
struct PettingProject;
|
struct PettingProject;
|
||||||
|
|
||||||
|
fn parse_bool_env(name: &str) -> Option<bool> {
|
||||||
|
let value = std::env::var(name).ok()?;
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"1" | "true" | "yes" | "on" => Some(true),
|
||||||
|
"0" | "false" | "no" | "off" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_enabled(config_name: &str) -> bool {
|
||||||
|
parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| {
|
||||||
|
matches!(
|
||||||
|
config_name,
|
||||||
|
"dev" | "development" | "debug" | "local" | "test"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Project for PettingProject {
|
impl Project for PettingProject {
|
||||||
fn cli_metadata(&self) -> CliMetadata {
|
fn cli_metadata(&self) -> CliMetadata {
|
||||||
cot::cli::metadata!()
|
cot::cli::metadata!()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
|
fn config(&self, config_name: &str) -> cot::Result<ProjectConfig> {
|
||||||
Ok(ProjectConfig::builder()
|
Ok(ProjectConfig::builder()
|
||||||
.debug(true)
|
.debug(debug_enabled(config_name))
|
||||||
.database(
|
.database(
|
||||||
DatabaseConfig::builder()
|
DatabaseConfig::builder()
|
||||||
.url("sqlite://db.sqlite3?mode=rwc")
|
.url("sqlite://db.sqlite3?mode=rwc")
|
||||||
@@ -66,6 +87,7 @@ impl Project for PettingProject {
|
|||||||
.session(
|
.session(
|
||||||
SessionMiddlewareConfig::builder()
|
SessionMiddlewareConfig::builder()
|
||||||
.secure(false)
|
.secure(false)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
.store(
|
.store(
|
||||||
SessionStoreConfig::builder()
|
SessionStoreConfig::builder()
|
||||||
.store_type(SessionStoreTypeConfig::Database)
|
.store_type(SessionStoreTypeConfig::Database)
|
||||||
@@ -97,5 +119,10 @@ impl Project for PettingProject {
|
|||||||
|
|
||||||
#[cot::main]
|
#[cot::main]
|
||||||
fn main() -> impl Project {
|
fn main() -> impl Project {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.try_init();
|
||||||
PettingProject
|
PettingProject
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -43,6 +43,7 @@ pub enum VisitStatus {
|
|||||||
Scheduled,
|
Scheduled,
|
||||||
Completed,
|
Completed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VisitStatus {
|
impl VisitStatus {
|
||||||
@@ -51,6 +52,7 @@ impl VisitStatus {
|
|||||||
Self::Scheduled => "scheduled",
|
Self::Scheduled => "scheduled",
|
||||||
Self::Completed => "completed",
|
Self::Completed => "completed",
|
||||||
Self::Cancelled => "cancelled",
|
Self::Cancelled => "cancelled",
|
||||||
|
Self::Deleted => "deleted",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +148,7 @@ pub struct Visit {
|
|||||||
pub public_notes: Option<String>,
|
pub public_notes: Option<String>,
|
||||||
/// Feedback text from client via portal.
|
/// Feedback text from client via portal.
|
||||||
pub client_feedback: Option<String>,
|
pub client_feedback: Option<String>,
|
||||||
/// scheduled | completed | cancelled
|
/// scheduled | completed | cancelled | deleted
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
pub updated_at: chrono::NaiveDateTime,
|
pub updated_at: chrono::NaiveDateTime,
|
||||||
|
|||||||
+85
-4
@@ -6,6 +6,7 @@ use cot::request::extractors::Path;
|
|||||||
use cot::response::{IntoResponse, Redirect, Response};
|
use cot::response::{IntoResponse, Redirect, Response};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use cot::db::query;
|
use cot::db::query;
|
||||||
|
|
||||||
@@ -71,9 +72,11 @@ struct LandingTemplate<'a> {
|
|||||||
lang: Lang,
|
lang: Lang,
|
||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
|
seo_keywords: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<Testimonial>,
|
||||||
site_domain: String,
|
site_domain: String,
|
||||||
review_count: usize,
|
review_count: usize,
|
||||||
|
turnstile_site_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -85,6 +88,33 @@ struct ThankYouTemplate<'a> {
|
|||||||
|
|
||||||
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||||
let lang = detect_lang(&request);
|
let lang = detect_lang(&request);
|
||||||
|
|
||||||
|
let ua = request
|
||||||
|
.headers()
|
||||||
|
.get("user-agent")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("-");
|
||||||
|
let referer = request
|
||||||
|
.headers()
|
||||||
|
.get("referer")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("-");
|
||||||
|
let ip = request
|
||||||
|
.headers()
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.unwrap_or("-");
|
||||||
|
|
||||||
|
info!(
|
||||||
|
target: "landing",
|
||||||
|
ip = ip,
|
||||||
|
lang = lang.code(),
|
||||||
|
referer = referer,
|
||||||
|
ua = ua,
|
||||||
|
"landing visit"
|
||||||
|
);
|
||||||
let key = "contact_info".to_string();
|
let key = "contact_info".to_string();
|
||||||
let contact_info = query!(Setting, $key == key)
|
let contact_info = query!(Setting, $key == key)
|
||||||
.get(&db)
|
.get(&db)
|
||||||
@@ -103,6 +133,13 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
.await?
|
.await?
|
||||||
.map(|s| s.value)
|
.map(|s| s.value)
|
||||||
.unwrap_or_else(|| "https://example.net".to_string());
|
.unwrap_or_else(|| "https://example.net".to_string());
|
||||||
|
let seo_key = "seo_keywords".to_string();
|
||||||
|
let seo_keywords = query!(Setting, $key == seo_key)
|
||||||
|
.get(&db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
let mut testimonials = Testimonial::objects().all(&db).await?;
|
let mut testimonials = Testimonial::objects().all(&db).await?;
|
||||||
testimonials.retain(|t| t.status == "active");
|
testimonials.retain(|t| t.status == "active");
|
||||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||||
@@ -112,9 +149,11 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
lang,
|
lang,
|
||||||
contact_info,
|
contact_info,
|
||||||
pricing_info,
|
pricing_info,
|
||||||
|
seo_keywords,
|
||||||
testimonials,
|
testimonials,
|
||||||
site_domain,
|
site_domain,
|
||||||
review_count,
|
review_count,
|
||||||
|
turnstile_site_key,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -125,6 +164,8 @@ struct LeadForm {
|
|||||||
name: String,
|
name: String,
|
||||||
phone: Option<String>,
|
phone: Option<String>,
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
|
#[serde(default, rename = "cf-turnstile-response")]
|
||||||
|
cf_turnstile_response: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
||||||
@@ -134,6 +175,10 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
let form: LeadForm =
|
let form: LeadForm =
|
||||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
|
||||||
|
return Redirect::new(format!("/?lang={}", lang.code())).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let mut lead = Lead {
|
let mut lead = Lead {
|
||||||
id: Auto::auto(),
|
id: Auto::auto(),
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -179,6 +224,7 @@ struct ClientPortalTemplate<'a> {
|
|||||||
upcoming: Vec<PortalVisit>,
|
upcoming: Vec<PortalVisit>,
|
||||||
past: Vec<PortalVisit>,
|
past: Vec<PortalVisit>,
|
||||||
feedback_sent: bool,
|
feedback_sent: bool,
|
||||||
|
turnstile_site_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn client_portal(
|
async fn client_portal(
|
||||||
@@ -194,7 +240,8 @@ async fn client_portal(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
Some(c) => c,
|
Some(c) if c.status != "deleted" => c,
|
||||||
|
Some(_) => return Html::new("404").into_response(),
|
||||||
None => return Html::new("404").into_response(),
|
None => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,7 +250,11 @@ async fn client_portal(
|
|||||||
let today = crate::tz::today_in_tz(tz);
|
let today = crate::tz::today_in_tz(tz);
|
||||||
|
|
||||||
let mut visits = Visit::objects().all(&db).await?;
|
let mut visits = Visit::objects().all(&db).await?;
|
||||||
visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled");
|
visits.retain(|v| {
|
||||||
|
v.client_id.primary_key().unwrap() == client_id
|
||||||
|
&& v.status != "cancelled"
|
||||||
|
&& v.status != "deleted"
|
||||||
|
});
|
||||||
visits.sort_by(|a, b| {
|
visits.sort_by(|a, b| {
|
||||||
a.visit_date
|
a.visit_date
|
||||||
.cmp(&b.visit_date)
|
.cmp(&b.visit_date)
|
||||||
@@ -250,6 +301,7 @@ async fn client_portal(
|
|||||||
}
|
}
|
||||||
past.reverse(); // newest first
|
past.reverse(); // newest first
|
||||||
|
|
||||||
|
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
|
||||||
let body = ClientPortalTemplate {
|
let body = ClientPortalTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
@@ -257,6 +309,7 @@ async fn client_portal(
|
|||||||
upcoming,
|
upcoming,
|
||||||
past,
|
past,
|
||||||
feedback_sent,
|
feedback_sent,
|
||||||
|
turnstile_site_key,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -265,6 +318,8 @@ async fn client_portal(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FeedbackForm {
|
struct FeedbackForm {
|
||||||
feedback: String,
|
feedback: String,
|
||||||
|
#[serde(default, rename = "cf-turnstile-response")]
|
||||||
|
cf_turnstile_response: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_feedback(
|
async fn submit_feedback(
|
||||||
@@ -277,7 +332,8 @@ async fn submit_feedback(
|
|||||||
// Verify token matches visit's client
|
// Verify token matches visit's client
|
||||||
let token_clone = token.clone();
|
let token_clone = token.clone();
|
||||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
Some(c) => c,
|
Some(c) if c.status != "deleted" => c,
|
||||||
|
Some(_) => return Html::new("404").into_response(),
|
||||||
None => return Html::new("404").into_response(),
|
None => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
let client_id = client.id.unwrap();
|
let client_id = client.id.unwrap();
|
||||||
@@ -286,7 +342,24 @@ async fn submit_feedback(
|
|||||||
let form: FeedbackForm =
|
let form: FeedbackForm =
|
||||||
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
serde_html_form::from_bytes(&bytes).map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
if !crate::turnstile::verify(&db, form.cf_turnstile_response.as_deref()).await? {
|
||||||
|
return Redirect::new(format!(
|
||||||
|
"/client/{}?lang={}",
|
||||||
|
token_clone,
|
||||||
|
lang.code()
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
if visit.status == "deleted" {
|
||||||
|
return Redirect::new(format!(
|
||||||
|
"/client/{}?lang={}",
|
||||||
|
token_clone,
|
||||||
|
lang.code()
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
if visit.client_id.primary_key().unwrap() == client_id {
|
if visit.client_id.primary_key().unwrap() == client_id {
|
||||||
visit.client_feedback = Some(form.feedback);
|
visit.client_feedback = Some(form.feedback);
|
||||||
visit.updated_at = now_utc();
|
visit.updated_at = now_utc();
|
||||||
@@ -310,7 +383,8 @@ async fn portal_media(
|
|||||||
) -> cot::Result<Response> {
|
) -> cot::Result<Response> {
|
||||||
// Verify token
|
// Verify token
|
||||||
let client = match query!(Client, $media_token == token).get(&db).await? {
|
let client = match query!(Client, $media_token == token).get(&db).await? {
|
||||||
Some(c) => c,
|
Some(c) if c.status != "deleted" => c,
|
||||||
|
Some(_) => return Html::new("404").into_response(),
|
||||||
None => return Html::new("404").into_response(),
|
None => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
let client_id = client.id.unwrap();
|
let client_id = client.id.unwrap();
|
||||||
@@ -319,6 +393,13 @@ async fn portal_media(
|
|||||||
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m,
|
||||||
_ => return Html::new("404").into_response(),
|
_ => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
|
if let Some(fk) = &media.visit_id {
|
||||||
|
let visit_id: i64 = fk.primary_key().unwrap();
|
||||||
|
match query!(Visit, $id == visit_id).get(&db).await? {
|
||||||
|
Some(v) if v.status != "deleted" => {}
|
||||||
|
_ => return Html::new("404").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match tokio::fs::read(&media.file_path).await {
|
match tokio::fs::read(&media.file_path).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use cot::db::{Database, query};
|
||||||
|
|
||||||
|
use crate::models::Setting;
|
||||||
|
|
||||||
|
/// Read `turnstile_site_key` from Settings. Returns empty string if not configured.
|
||||||
|
pub async fn get_site_key(db: &Database) -> cot::Result<String> {
|
||||||
|
let key = "turnstile_site_key".to_string();
|
||||||
|
Ok(query!(Setting, $key == key)
|
||||||
|
.get(db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a Turnstile token against Cloudflare.
|
||||||
|
/// Returns `true` if verification succeeds, or if no secret key is configured (passthrough).
|
||||||
|
pub async fn verify(db: &Database, token: Option<&str>) -> cot::Result<bool> {
|
||||||
|
let secret_key_name = "turnstile_secret_key".to_string();
|
||||||
|
let secret_key = query!(Setting, $key == secret_key_name)
|
||||||
|
.get(db)
|
||||||
|
.await?
|
||||||
|
.map(|s| s.value)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
let Some(secret) = secret_key else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = token.unwrap_or("");
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"secret": secret,
|
||||||
|
"response": token
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(match resp {
|
||||||
|
Ok(r) => r
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.map(|v| v["success"].as_bool() == Some(true))
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -76,11 +76,16 @@
|
|||||||
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
<form method="post" action="/admin/clients/{{ client_id }}/archive">
|
||||||
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
<button type="submit" class="button is-warning is-outlined is-fullwidth">{{ t.action_archive }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else if client_status == "archived" %}
|
||||||
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
<form method="post" action="/admin/clients/{{ client_id }}/activate">
|
||||||
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
<button type="submit" class="button is-success is-outlined is-fullwidth">{{ t.action_activate }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if client_status != "deleted" %}
|
||||||
|
<form method="post" action="/admin/clients/{{ client_id }}/delete" onsubmit="return confirm('{{ t.clients_delete_confirm }}');" style="margin-top:0.75rem;">
|
||||||
|
<button type="submit" class="button is-danger is-outlined is-fullwidth">{{ t.clients_delete }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -33,12 +33,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if lead.status == "new" || lead.status == "in_progress" %}
|
{% if lead.status == "new" || lead.status == "in_progress" %}
|
||||||
<div class="item-card-actions">
|
<div class="item-card-actions">
|
||||||
{% if lead.status == "new" %}
|
|
||||||
<form method="post" action="/admin/leads/{{ lead.id }}/status">
|
|
||||||
<input type="hidden" name="status" value="in_progress">
|
|
||||||
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
||||||
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
<title>{{ t.nav_title }} — {{ t.login_title }}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1/css/bulma.min.css">
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light; }
|
:root { color-scheme: light; }
|
||||||
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
body { background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; color: #333; }
|
||||||
@@ -32,6 +35,11 @@
|
|||||||
{% if let Some(err) = error.as_ref() %}
|
{% if let Some(err) = error.as_ref() %}
|
||||||
<div class="notification is-danger is-light">{{ err }}</div>
|
<div class="notification is-danger is-light">{{ err }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if auth_sso_enabled %}
|
||||||
|
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if auth_password_enabled %}
|
||||||
|
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
|
||||||
<form method="post" action="/admin/login/submit">
|
<form method="post" action="/admin/login/submit">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.users_login }}</label>
|
<label class="label">{{ t.users_login }}</label>
|
||||||
@@ -41,8 +49,12 @@
|
|||||||
<label class="label">{{ t.users_password }}</label>
|
<label class="label">{{ t.users_password }}</label>
|
||||||
<div class="control"><input class="input" type="password" name="password" required></div>
|
<div class="control"><input class="input" type="password" name="password" required></div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -14,12 +14,13 @@
|
|||||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
<form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_choose_files }}</label>
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -29,7 +30,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
<!-- Progress -->
|
||||||
|
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||||
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
|
<span id="uploadPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||||
|
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('uploadForm');
|
||||||
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var progress = document.getElementById('uploadProgress');
|
||||||
|
var bar = document.getElementById('uploadBar');
|
||||||
|
var percent = document.getElementById('uploadPercent');
|
||||||
|
var statusText = document.getElementById('uploadStatusText');
|
||||||
|
var submitBtn = document.getElementById('uploadSubmit');
|
||||||
|
|
||||||
|
filesInput.addEventListener('change', function() {
|
||||||
|
var n = this.files.length;
|
||||||
|
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!filesInput.files.length) return;
|
||||||
|
|
||||||
|
var data = new FormData(form);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
progress.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Загрузка...';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
percent.textContent = '0%';
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
|
if (!ev.lengthComputable) return;
|
||||||
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
percent.textContent = pct + '%';
|
||||||
|
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', function() {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
percent.textContent = '100%';
|
||||||
|
statusText.textContent = 'Готово!';
|
||||||
|
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', function() {
|
||||||
|
statusText.textContent = 'Ошибка соединения';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action);
|
||||||
|
xhr.send(data);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
||||||
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
||||||
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
||||||
|
.fc-event.ev-completed .fc-event-title,
|
||||||
|
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
|
||||||
|
.fc-event.ev-cancelled .fc-event-title,
|
||||||
|
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
|
||||||
.fc .fc-day-today { background: #eef2ff !important; }
|
.fc .fc-day-today { background: #eef2ff !important; }
|
||||||
.fc .fc-day.day-weekend { background: #faf5f0; }
|
.fc .fc-day.day-weekend { background: #faf5f0; }
|
||||||
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
||||||
@@ -45,8 +49,6 @@
|
|||||||
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
||||||
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
||||||
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
||||||
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
|
||||||
.visit-modal .actions form { margin:0; }
|
|
||||||
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -60,12 +62,8 @@
|
|||||||
<div id="vmTime"></div>
|
<div id="vmTime"></div>
|
||||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
|
<div id="vmStatus" style="margin-bottom:1rem;"></div>
|
||||||
<div class="actions" id="vmActions"></div>
|
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
|
||||||
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
|
|
||||||
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,6 +87,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||||
},
|
},
|
||||||
events: '/admin/schedule/events',
|
events: '/admin/schedule/events',
|
||||||
|
eventDidMount: function(info) {
|
||||||
|
var status = info.event.extendedProps.status;
|
||||||
|
if (status === 'completed') info.el.classList.add('ev-completed');
|
||||||
|
if (status === 'cancelled') info.el.classList.add('ev-cancelled');
|
||||||
|
},
|
||||||
eventClick: function(info) {
|
eventClick: function(info) {
|
||||||
info.jsEvent.preventDefault();
|
info.jsEvent.preventDefault();
|
||||||
const ev = info.event;
|
const ev = info.event;
|
||||||
@@ -102,12 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('vmNotes').textContent = p.notes || '';
|
document.getElementById('vmNotes').textContent = p.notes || '';
|
||||||
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
||||||
document.getElementById('vmStatus').innerHTML = badge;
|
document.getElementById('vmStatus').innerHTML = badge;
|
||||||
let actions = '';
|
|
||||||
if (p.status === 'scheduled') {
|
|
||||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
|
|
||||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
|
|
||||||
}
|
|
||||||
document.getElementById('vmActions').innerHTML = actions;
|
|
||||||
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
||||||
document.getElementById('visitModal').classList.add('is-open');
|
document.getElementById('visitModal').classList.add('is-open');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,15 +14,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_client }}</label>
|
<label class="label">{{ t.schedule_client }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<input class="input" type="text" value="{{ client.name }}{% if let Some(p) = client.phone.as_deref() %} ({{ p }}){% endif %}" readonly>
|
||||||
<select name="client_id" required>
|
|
||||||
{% for c in &clients %}
|
|
||||||
<option value="{{ c.id }}" {% if c.id.unwrap() == visit.client_id.primary_key().unwrap() %}selected{% endif %}>
|
|
||||||
{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,14 +62,17 @@
|
|||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_status }}</label>
|
<label class="label">{{ t.schedule_status }}</label>
|
||||||
<div class="control">
|
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
||||||
<div class="select is-fullwidth">
|
<div class="status-picker">
|
||||||
<select name="status">
|
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
||||||
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
|
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
||||||
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
|
</button>
|
||||||
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
|
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
||||||
</select>
|
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
||||||
</div>
|
</button>
|
||||||
|
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
|
||||||
|
<span class="status-btn-icon">✕</span>{{ t.visit_status_cancelled }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,6 +124,9 @@
|
|||||||
{% if let Some(cap) = m.caption.as_deref() %}
|
{% if let Some(cap) = m.caption.as_deref() %}
|
||||||
<div class="media-cap">{{ cap }}</div>
|
<div class="media-cap">{{ cap }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="visit-media-delete">
|
||||||
|
<button type="submit" form="visit-media-delete-{{ m.id.unwrap() }}" class="button is-small is-danger is-outlined">{{ t.media_delete }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +136,9 @@
|
|||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% for m in &media %}
|
||||||
|
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<hr style="margin:1rem 0;">
|
<hr style="margin:1rem 0;">
|
||||||
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
<form method="post" action="/admin/schedule/{{ visit.id }}/delete" onsubmit="return confirm('{{ t.schedule_delete_confirm }}');">
|
||||||
@@ -150,14 +151,15 @@
|
|||||||
<div class="upload-modal">
|
<div class="upload-modal">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||||
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_choose_files }}</label>
|
<label class="label">{{ t.media_choose_files }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.media_caption }}</label>
|
<label class="label">{{ t.media_caption }}</label>
|
||||||
@@ -165,12 +167,56 @@
|
|||||||
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||||
|
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||||
|
<span id="uploadPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||||
|
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.status-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.status-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.6rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #777;
|
||||||
|
transition: all 0.15s;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||||
|
.status-btn:hover { filter: brightness(0.95); }
|
||||||
|
|
||||||
|
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
||||||
|
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
||||||
|
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
||||||
|
|
||||||
|
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
||||||
|
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
||||||
|
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
||||||
|
|
||||||
.visit-media-grid {
|
.visit-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
@@ -206,6 +252,14 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.visit-media-delete {
|
||||||
|
padding: 0.25rem 0.4rem 0.4rem;
|
||||||
|
}
|
||||||
|
.visit-media-delete .button {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
min-height: 1.65rem;
|
||||||
|
}
|
||||||
.upload-modal-bg {
|
.upload-modal-bg {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -229,8 +283,91 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
// Status picker
|
||||||
if (e.target === this) this.classList.remove('is-open');
|
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
document.getElementById('statusInput').value = btn.dataset.value;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var modal = document.getElementById('uploadModal');
|
||||||
|
var form = document.getElementById('uploadForm');
|
||||||
|
var filesInput = document.getElementById('uploadFiles');
|
||||||
|
var fileCount = document.getElementById('fileCount');
|
||||||
|
var progress = document.getElementById('uploadProgress');
|
||||||
|
var bar = document.getElementById('uploadBar');
|
||||||
|
var percent = document.getElementById('uploadPercent');
|
||||||
|
var statusText = document.getElementById('uploadStatusText');
|
||||||
|
var submitBtn = document.getElementById('uploadSubmit');
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
if (submitBtn.disabled) return; // prevent close during upload
|
||||||
|
modal.classList.remove('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show selected file count
|
||||||
|
filesInput.addEventListener('change', function() {
|
||||||
|
var n = this.files.length;
|
||||||
|
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit via XHR for progress tracking
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!filesInput.files.length) return;
|
||||||
|
|
||||||
|
var data = new FormData(form);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// Show progress bar, disable submit
|
||||||
|
progress.style.display = 'block';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Загрузка...';
|
||||||
|
bar.style.width = '0%';
|
||||||
|
percent.textContent = '0%';
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', function(ev) {
|
||||||
|
if (!ev.lengthComputable) return;
|
||||||
|
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
percent.textContent = pct + '%';
|
||||||
|
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', function() {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
percent.textContent = '100%';
|
||||||
|
statusText.textContent = 'Готово!';
|
||||||
|
// Reload page to show uploaded media
|
||||||
|
setTimeout(function() { window.location.reload(); }, 300);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', function() {
|
||||||
|
statusText.textContent = 'Ошибка соединения';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = '{{ t.media_upload }}';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', form.action);
|
||||||
|
xhr.send(data);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+300
-117
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||||
|
|
||||||
<!-- Client -->
|
<!-- Client -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_client }}</label>
|
<label class="label">{{ t.schedule_client }}</label>
|
||||||
@@ -44,57 +45,40 @@
|
|||||||
<!-- Default time -->
|
<!-- Default time -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_default_time }}</label>
|
<label class="label">{{ t.schedule_default_time }}</label>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<div class="time-row">
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_start }}</span>
|
||||||
<input class="input" type="time" id="defaultStart" value="18:00">
|
<input class="input" type="time" id="defaultStart" value="18:00">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="time-sep">—</div>
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_end }}</span>
|
||||||
<input class="input" type="time" id="defaultEnd" value="19:00">
|
<input class="input" type="time" id="defaultEnd" value="19:00">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar -->
|
||||||
|
<div class="field">
|
||||||
|
<div class="cal-toolbar">
|
||||||
|
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
|
||||||
|
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add individual date -->
|
<div class="sched-cal">
|
||||||
<div class="field">
|
<div class="cal-nav">
|
||||||
<label class="label">{{ t.schedule_pick_dates }}</label>
|
<button type="button" id="calPrev">◀</button>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<span id="calTitle"></span>
|
||||||
<div class="column">
|
<button type="button" id="calNext">▶</button>
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="date" id="pickDate">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button type="button" class="button is-info" id="addDateBtn">+</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cal-grid" id="calGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date range fill -->
|
<!-- Selected days -->
|
||||||
<div class="field">
|
<div class="field" id="selectedSection" style="display:none;">
|
||||||
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
|
<label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<div id="daysList"></div>
|
||||||
<div class="column">
|
|
||||||
<input class="input" type="date" id="rangeFrom">
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<input class="input" type="date" id="rangeTo">
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button type="button" class="button is-info is-outlined" id="fillRangeBtn">{{ t.schedule_fill_range }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected days list -->
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.schedule_selected_days }}</label>
|
|
||||||
<div id="daysList">
|
|
||||||
<p class="has-text-grey is-size-7" id="noDaysMsg">{{ t.schedule_no_days }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
@@ -105,29 +89,114 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden days data -->
|
|
||||||
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.day-row {
|
/* Time row */
|
||||||
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0;
|
.time-row { display:flex; align-items:center; gap:0.5rem; }
|
||||||
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap;
|
.time-block { display:flex; flex-direction:column; flex:1; }
|
||||||
|
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
|
||||||
|
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
|
||||||
|
|
||||||
|
/* Calendar toolbar */
|
||||||
|
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Calendar container */
|
||||||
|
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
|
||||||
|
|
||||||
|
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
|
||||||
|
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
|
||||||
|
.cal-nav button:hover { background:#f0eeff; }
|
||||||
|
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
|
||||||
|
|
||||||
|
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
||||||
|
|
||||||
|
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
|
||||||
|
.cal-wday.is-weekend { color:#f0a0a0; }
|
||||||
|
|
||||||
|
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-user-select:none; }
|
||||||
|
.cal-day:hover { background:#f0eeff; }
|
||||||
|
.cal-day.is-empty { cursor:default; }
|
||||||
|
.cal-day.is-empty:hover { background:none; }
|
||||||
|
.cal-day.is-today { font-weight:700; color:#6c63ff; }
|
||||||
|
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
|
||||||
|
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
|
||||||
|
.cal-day.is-past { color:#ccc; }
|
||||||
|
|
||||||
|
/* Selected days list */
|
||||||
|
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
|
||||||
|
.day-row:last-child { border-bottom:none; }
|
||||||
|
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
|
||||||
|
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
|
||||||
|
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
|
||||||
|
.day-rm:hover { color:#e55; }
|
||||||
|
|
||||||
|
/* Time badge */
|
||||||
|
.time-badge-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ede9ff;
|
||||||
|
color: #5b52d6;
|
||||||
|
border: 1.5px solid #c4beff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 3.6rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.time-badge-wrap:focus-within {
|
||||||
|
border-color: #6c63ff;
|
||||||
|
background: #f0eeff;
|
||||||
|
}
|
||||||
|
.time-badge-label { pointer-events: none; z-index: 1; }
|
||||||
|
.time-badge-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px; /* prevent iOS zoom */
|
||||||
}
|
}
|
||||||
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; }
|
|
||||||
.day-row input[type="time"] { width: 7rem; padding: 0.2rem 0.4rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85rem; }
|
|
||||||
.day-row .remove-btn { color: #e55; cursor: pointer; font-size: 0.8rem; margin-left: auto; background: none; border: none; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const days = new Map(); // date string -> {start, end}
|
(function() {
|
||||||
const removeLabel = '{{ t.schedule_remove_day }}';
|
|
||||||
const weekdays = '{{ lang.code() }}' === 'ru'
|
const IS_RU = '{{ lang.code() }}' === 'ru';
|
||||||
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
|
const TZ = '{{ timezone }}';
|
||||||
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
const WDAYS = IS_RU ? ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] : ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
||||||
|
const MONTHS = IS_RU
|
||||||
|
? ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
|
||||||
|
: ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
const days = new Map(); // dateStr -> {start, end}
|
||||||
|
let viewYear, viewMonth;
|
||||||
|
let rangeMode = false;
|
||||||
|
let rangeStart = null; // dateStr of first tap in range mode
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr_ = tzDateStr(now);
|
||||||
|
viewYear = parseInt(todayStr_.slice(0, 4));
|
||||||
|
viewMonth = parseInt(todayStr_.slice(5, 7)) - 1; // 0-based
|
||||||
|
|
||||||
|
function isoDate(y, m, d) {
|
||||||
|
return y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaults() {
|
function getDefaults() {
|
||||||
return {
|
return {
|
||||||
@@ -136,111 +205,225 @@ function getDefaults() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDay(dateStr) {
|
function addDay(ds) {
|
||||||
if (!dateStr || days.has(dateStr)) return;
|
if (!ds || days.has(ds)) return;
|
||||||
const def = getDefaults();
|
const def = getDefaults();
|
||||||
days.set(dateStr, { start: def.start, end: def.end });
|
days.set(ds, { start: def.start, end: def.end });
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDay(dateStr) {
|
function toggleDay(ds) {
|
||||||
days.delete(dateStr);
|
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDays() {
|
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
|
||||||
|
function tzDateStr(d) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
}).formatToParts(d);
|
||||||
|
const p = {};
|
||||||
|
parts.forEach(function(x) { p[x.type] = x.value; });
|
||||||
|
return p.year + '-' + p.month + '-' + p.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRange(from, to) {
|
||||||
|
if (from > to) { let t = from; from = to; to = t; }
|
||||||
|
// Используем полдень чтобы избежать проблем с переходом суток при смене DST
|
||||||
|
let cur = new Date(from + 'T12:00:00');
|
||||||
|
const end = new Date(to + 'T12:00:00');
|
||||||
|
while (cur <= end) {
|
||||||
|
addDay(tzDateStr(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render calendar ──────────────────────────────────────
|
||||||
|
function renderCal() {
|
||||||
|
document.getElementById('calTitle').textContent = MONTHS[viewMonth] + ' ' + viewYear;
|
||||||
|
|
||||||
|
const grid = document.getElementById('calGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Weekday headers
|
||||||
|
WDAYS.forEach(function(wd, i) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cal-wday' + (i >= 5 ? ' is-weekend' : '');
|
||||||
|
cell.textContent = wd;
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
// First day of month (Mon=0 for our grid)
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
let startDow = first.getDay(); // 0=Sun
|
||||||
|
startDow = (startDow === 0) ? 6 : startDow - 1; // shift to Mon=0
|
||||||
|
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const todayStr = todayStr_;
|
||||||
|
|
||||||
|
// Empty cells before first day
|
||||||
|
for (let i = 0; i < startDow; i++) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'cal-day is-empty';
|
||||||
|
grid.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const ds = isoDate(viewYear, viewMonth, d);
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
let cls = 'cal-day';
|
||||||
|
if (ds === todayStr) cls += ' is-today';
|
||||||
|
if (days.has(ds)) cls += ' is-selected';
|
||||||
|
if (ds === rangeStart) cls += ' is-range-start';
|
||||||
|
cell.className = cls;
|
||||||
|
cell.textContent = d;
|
||||||
|
cell.dataset.date = ds;
|
||||||
|
cell.addEventListener('click', onDayClick);
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDayClick(e) {
|
||||||
|
const ds = e.currentTarget.dataset.date;
|
||||||
|
if (!ds) return;
|
||||||
|
if (rangeMode) {
|
||||||
|
if (days.has(ds)) {
|
||||||
|
days.delete(ds);
|
||||||
|
if (rangeStart === ds) rangeStart = null;
|
||||||
|
} else if (!rangeStart) {
|
||||||
|
rangeStart = ds;
|
||||||
|
} else {
|
||||||
|
fillRange(rangeStart, ds);
|
||||||
|
rangeStart = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggleDay(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render selected days list ────────────────────────────
|
||||||
|
function renderList() {
|
||||||
const list = document.getElementById('daysList');
|
const list = document.getElementById('daysList');
|
||||||
const msg = document.getElementById('noDaysMsg');
|
const section = document.getElementById('selectedSection');
|
||||||
|
const count = document.getElementById('selectedCount');
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
// Remove old day rows
|
list.innerHTML = '';
|
||||||
list.querySelectorAll('.day-row').forEach(el => el.remove());
|
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
if (days.size === 0) {
|
if (days.size === 0) {
|
||||||
msg.style.display = '';
|
section.style.display = 'none';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
document.getElementById('daysJson').value = '[]';
|
document.getElementById('daysJson').value = '[]';
|
||||||
|
resetBtn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.style.display = 'none';
|
resetBtn.style.display = '';
|
||||||
btn.disabled = false;
|
|
||||||
|
section.style.display = '';
|
||||||
|
btn.disabled = false;
|
||||||
|
count.textContent = days.size;
|
||||||
|
|
||||||
// Sort by date
|
|
||||||
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
|
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
|
||||||
|
|
||||||
sorted.forEach(([dateStr, times]) => {
|
sorted.forEach(function([ds, times]) {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(ds + 'T00:00:00');
|
||||||
const wd = weekdays[d.getDay()];
|
const dow = IS_RU
|
||||||
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
|
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d.getDay()]
|
||||||
|
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
||||||
|
const parts = ds.split('-');
|
||||||
|
const label = parts[2] + '.' + parts[1] + ' ' + dow; // DD.MM Вт
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'day-row';
|
row.className = 'day-row';
|
||||||
row.innerHTML = `
|
row.innerHTML =
|
||||||
<span class="day-date">${label}</span>
|
'<span class="day-date">' + label + '</span>' +
|
||||||
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
|
'<div class="day-times">' +
|
||||||
<span>—</span>
|
'<div class="time-badge-wrap">' +
|
||||||
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
|
'<span class="time-badge-label">' + times.start + '</span>' +
|
||||||
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
|
'<input type="time" class="time-badge-input" value="' + times.start + '" data-date="' + ds + '" data-field="start">' +
|
||||||
`;
|
'</div>' +
|
||||||
|
'<span class="time-sep">—</span>' +
|
||||||
|
'<div class="time-badge-wrap">' +
|
||||||
|
'<span class="time-badge-label">' + times.end + '</span>' +
|
||||||
|
'<input type="time" class="time-badge-input" value="' + times.end + '" data-date="' + ds + '" data-field="end">' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button type="button" class="day-rm" data-date="' + ds + '" title="Убрать">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update hidden JSON
|
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
|
||||||
updateJson();
|
|
||||||
|
|
||||||
// Bind events
|
|
||||||
list.querySelectorAll('input[type="time"]').forEach(inp => {
|
|
||||||
inp.addEventListener('change', function() {
|
inp.addEventListener('change', function() {
|
||||||
const dt = this.dataset.date;
|
const d = days.get(this.dataset.date);
|
||||||
const field = this.dataset.field;
|
if (d) {
|
||||||
if (days.has(dt)) {
|
d[this.dataset.field] = this.value;
|
||||||
days.get(dt)[field] = this.value;
|
this.previousElementSibling.textContent = this.value;
|
||||||
updateJson();
|
updateJson();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.remove-btn').forEach(btn => {
|
list.querySelectorAll('.day-rm').forEach(function(b) {
|
||||||
btn.addEventListener('click', function() {
|
b.addEventListener('click', function() {
|
||||||
removeDay(this.dataset.date);
|
days.delete(this.dataset.date);
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateJson() {
|
function updateJson() {
|
||||||
const arr = [...days.entries()].map(([date, t]) => ({
|
const arr = [...days.entries()].map(function([date, t]) {
|
||||||
date: date,
|
return { date: date, time_start: t.start, time_end: t.end };
|
||||||
time_start: t.start,
|
});
|
||||||
time_end: t.end
|
|
||||||
}));
|
|
||||||
document.getElementById('daysJson').value = JSON.stringify(arr);
|
document.getElementById('daysJson').value = JSON.stringify(arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addDateBtn').addEventListener('click', function() {
|
// ── Navigation ───────────────────────────────────────────
|
||||||
const v = document.getElementById('pickDate').value;
|
document.getElementById('calPrev').addEventListener('click', function() {
|
||||||
addDay(v);
|
viewMonth--;
|
||||||
document.getElementById('pickDate').value = '';
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||||
|
renderCal();
|
||||||
|
});
|
||||||
|
document.getElementById('calNext').addEventListener('click', function() {
|
||||||
|
viewMonth++;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||||
|
renderCal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('pickDate').addEventListener('keydown', function(e) {
|
// ── Range mode toggle ────────────────────────────────────
|
||||||
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
|
document.getElementById('rangeModeBtn').addEventListener('click', function() {
|
||||||
});
|
rangeMode = !rangeMode;
|
||||||
|
rangeStart = null;
|
||||||
document.getElementById('fillRangeBtn').addEventListener('click', function() {
|
if (rangeMode) {
|
||||||
const from = document.getElementById('rangeFrom').value;
|
this.classList.remove('is-outlined', 'is-info');
|
||||||
const to = document.getElementById('rangeTo').value;
|
this.classList.add('is-warning');
|
||||||
if (!from || !to || from > to) return;
|
this.textContent = '✕ Выбрать отдельные дни';
|
||||||
let cur = new Date(from + 'T00:00:00');
|
} else {
|
||||||
const end = new Date(to + 'T00:00:00');
|
this.classList.remove('is-warning');
|
||||||
while (cur <= end) {
|
this.classList.add('is-outlined', 'is-info');
|
||||||
const ds = cur.toISOString().slice(0, 10);
|
this.textContent = '⇔ Выбрать диапазон';
|
||||||
addDay(ds);
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
}
|
||||||
document.getElementById('rangeFrom').value = '';
|
renderCal();
|
||||||
document.getElementById('rangeTo').value = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default pick date to today
|
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||||
document.getElementById('pickDate').valueAsDate = new Date();
|
days.clear();
|
||||||
|
rangeStart = null;
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Default time change → update existing days ───────────
|
||||||
|
// (only updates days that still have the old default)
|
||||||
|
// kept simple: doesn't retroactively update already-added days
|
||||||
|
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,12 +14,8 @@
|
|||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/admin/settings/save">
|
<form method="post" action="/admin/settings/save">
|
||||||
<div class="field">
|
|
||||||
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.settings_contact_info }}</label>
|
<label class="label">{{ t.settings_contact_info }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -32,6 +28,23 @@
|
|||||||
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
|
||||||
|
style="resize:vertical;"
|
||||||
|
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||||
|
</div>
|
||||||
|
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style="margin-top:1.5rem;">
|
||||||
|
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
|
||||||
|
{{ t.settings_section_advanced }}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem;">
|
||||||
|
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.settings_site_domain }}</label>
|
<label class="label">{{ t.settings_site_domain }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -44,7 +57,116 @@
|
|||||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
|
||||||
|
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_telegram_bot_token }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_turnstile_site_key }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_oidc_client_id }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_oidc_client_secret }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
|
||||||
|
{{ t.settings_auth_password_enabled }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
|
||||||
|
{{ t.settings_auth_sso_enabled }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var COLORS = [
|
||||||
|
'rgba(124,108,255,0.18)',
|
||||||
|
'rgba(255,82,135,0.15)',
|
||||||
|
'rgba(255,140,38,0.18)',
|
||||||
|
'rgba(0,180,150,0.15)',
|
||||||
|
'rgba(77,166,255,0.18)',
|
||||||
|
'rgba(255,179,64,0.18)',
|
||||||
|
'rgba(176,108,255,0.16)',
|
||||||
|
'rgba(34,180,130,0.16)',
|
||||||
|
];
|
||||||
|
|
||||||
|
var ta = document.getElementById('seoKeywordsInput');
|
||||||
|
var preview = document.getElementById('seoPreview');
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var text = ta.value.trim();
|
||||||
|
if (!text) { preview.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
var parts = text.split(',');
|
||||||
|
var html = '';
|
||||||
|
parts.forEach(function(part, i) {
|
||||||
|
var word = part.trim();
|
||||||
|
if (word) {
|
||||||
|
var color = COLORS[i % COLORS.length];
|
||||||
|
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
|
||||||
|
}
|
||||||
|
if (i < parts.length - 1) {
|
||||||
|
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
preview.innerHTML = html;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener('input', render);
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
<title>{{ t.portal_title }} — {{ client.name }}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light; }
|
:root { color-scheme: light; }
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -166,6 +169,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
||||||
<textarea name="feedback" required>{{ fb }}</textarea>
|
<textarea name="feedback" required>{{ fb }}</textarea>
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
|
||||||
|
{% endif %}
|
||||||
<div style="display:flex;gap:0.4rem;">
|
<div style="display:flex;gap:0.4rem;">
|
||||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||||
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})">✕</button>
|
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})">✕</button>
|
||||||
@@ -174,6 +180,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
|
||||||
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
|
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
|
||||||
|
{% endif %}
|
||||||
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
<button type="submit">{{ t.portal_feedback_submit }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
||||||
<meta property="og:site_name" content="{{ t.nav_title }}">
|
<meta property="og:site_name" content="{{ t.nav_title }}">
|
||||||
|
|
||||||
|
{% if !seo_keywords.is_empty() %}
|
||||||
|
<meta name="keywords" content="{{ seo_keywords }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||||
@@ -46,6 +50,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Reset & Base ── */
|
/* ── Reset & Base ── */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -301,6 +309,10 @@
|
|||||||
border-top: 1px solid rgba(180,170,220,0.15);
|
border-top: 1px solid rgba(180,170,220,0.15);
|
||||||
background: rgba(255,255,255,0.3);
|
background: rgba(255,255,255,0.3);
|
||||||
}
|
}
|
||||||
|
.seo-keywords {
|
||||||
|
font-size: 0.72rem; color: #aaa; line-height: 2;
|
||||||
|
max-width: 800px; margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -456,6 +468,9 @@
|
|||||||
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
|
<input type="checkbox" id="consent" name="consent" required style="margin-top:0.2rem;width:auto;flex-shrink:0;">
|
||||||
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
|
<label for="consent" style="font-size:0.82rem;font-weight:400;color:#7a7599;cursor:pointer;display:inline;">{{ t.landing_form_consent }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% if !turnstile_site_key.is_empty() %}
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-bottom:1.25rem;"></div>
|
||||||
|
{% endif %}
|
||||||
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
<button type="submit" class="form-submit">{{ t.landing_form_submit }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% if !contact_info.is_empty() %}
|
{% if !contact_info.is_empty() %}
|
||||||
@@ -469,6 +484,9 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
{% if !seo_keywords.is_empty() %}
|
||||||
|
<p class="seo-keywords" id="seoKeywords">{{ seo_keywords }}</p>
|
||||||
|
{% endif %}
|
||||||
<p>{{ t.landing_footer_text }}</p>
|
<p>{{ t.landing_footer_text }}</p>
|
||||||
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user