add settings page

This commit is contained in:
mat 2024-06-29 04:09:31 -05:00
parent cd2827b9fc
commit 6da140f34b
11 changed files with 584 additions and 272 deletions

124
Cargo.lock generated
View File

@ -205,6 +205,30 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
]
[[package]]
name = "axum-extra"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733"
dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-util",
"http 1.0.0",
"http-body 1.0.0",
"http-body-util",
"mime",
"pin-project-lite",
"serde",
"tower",
"tower-layer",
"tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -354,6 +378,17 @@ dependencies = [
"unicode-width", "unicode-width",
] ]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -421,6 +456,15 @@ dependencies = [
"syn 2.0.52", "syn 2.0.52",
] ]
[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.17" version = "0.99.17"
@ -743,6 +787,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.26.0" version = "0.26.0"
@ -1083,6 +1136,7 @@ dependencies = [
"ammonia", "ammonia",
"async-stream", "async-stream",
"axum", "axum",
"axum-extra",
"base64 0.22.0", "base64 0.22.0",
"bytes", "bytes",
"chrono", "chrono",
@ -1090,6 +1144,7 @@ dependencies = [
"eyre", "eyre",
"fend-core", "fend-core",
"futures", "futures",
"html-escape",
"maud", "maud",
"numbat", "numbat",
"rand", "rand",
@ -1101,6 +1156,8 @@ dependencies = [
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml", "toml",
"tower",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
@ -1160,6 +1217,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-format" name = "num-format"
version = "0.4.4" version = "0.4.4"
@ -1425,6 +1488,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -2095,6 +2164,37 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "time"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"
@ -2219,6 +2319,23 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
]
[[package]]
name = "tower-http"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.4.2",
"bytes",
"http 1.0.0",
"http-body 1.0.0",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
] ]
[[package]] [[package]]
@ -2239,6 +2356,7 @@ version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -2373,6 +2491,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.0" version = "0.1.0"

View File

@ -9,13 +9,8 @@ build = "src/build.rs"
[dependencies] [dependencies]
ammonia = "3.3.0" ammonia = "3.3.0"
async-stream = "0.3.5" async-stream = "0.3.5"
axum = { version = "0.7.4", default-features = false, features = [ axum = { version = "0.7.4", default-features = false, features = ["tokio", "http1", "http2", "query", "json", "form"] }
"tokio", axum-extra = { version = "0.9.3", features = ["cookie"] }
"http1",
"http2",
"query",
"json",
] }
base64 = "0.22.0" base64 = "0.22.0"
bytes = "1.5.0" bytes = "1.5.0"
chrono = "0.4.35" chrono = "0.4.35"
@ -23,6 +18,7 @@ chrono-tz = { version = "0.8.6", features = ["case-insensitive"] }
eyre = "0.6.12" eyre = "0.6.12"
fend-core = "1.4.5" fend-core = "1.4.5"
futures = "0.3.30" futures = "0.3.30"
html-escape = "0.2.13"
maud = "0.26.0" maud = "0.26.0"
numbat = "1.11.0" numbat = "1.11.0"
rand = "0.8.5" rand = "0.8.5"
@ -40,6 +36,8 @@ serde_json = { version = "1.0.114", features = ["preserve_order"] }
tokio = { version = "1.36.0", features = ["rt", "macros"] } tokio = { version = "1.36.0", features = ["rt", "macros"] }
tokio-stream = "0.1.15" tokio-stream = "0.1.15"
toml = { version = "0.8.12", default-features = false, features = ["parse"] } toml = { version = "0.8.12", default-features = false, features = ["parse"] }
tower = "0.4.13"
tower-http = "0.5.2"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
url = "2.5.0" url = "2.5.0"

View File

@ -1,18 +1,25 @@
use std::{collections::HashMap, fs, net::SocketAddr, path::Path, sync::LazyLock}; use std::{
collections::HashMap,
fs,
net::SocketAddr,
path::Path,
sync::{Arc, LazyLock},
};
use serde::Deserialize; use serde::Deserialize;
use tracing::info; use tracing::info;
use crate::engines::Engine; use crate::engines::Engine;
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub bind: SocketAddr, pub bind: SocketAddr,
/// Whether the JSON API should be accessible. /// Whether the JSON API should be accessible.
pub api: bool, pub api: bool,
pub ui: UiConfig, pub ui: UiConfig,
pub image_search: ImageSearchConfig, pub image_search: ImageSearchConfig,
pub engines: EnginesConfig, // wrapped in an arc to make Config cheaper to clone
pub engines: Arc<EnginesConfig>,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -31,23 +38,31 @@ impl Config {
self.ui.overlay(partial.ui.unwrap_or_default()); self.ui.overlay(partial.ui.unwrap_or_default());
self.image_search self.image_search
.overlay(partial.image_search.unwrap_or_default()); .overlay(partial.image_search.unwrap_or_default());
self.engines.overlay(partial.engines.unwrap_or_default()); if let Some(partial_engines) = partial.engines {
let mut engines = self.engines.as_ref().clone();
engines.overlay(partial_engines);
self.engines = Arc::new(engines);
}
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct UiConfig { pub struct UiConfig {
pub show_engine_list_separator: bool, pub show_engine_list_separator: bool,
pub show_version_info: bool, pub show_version_info: bool,
/// Settings are always accessible anyways, this just controls whether the
/// link to them in the index page is visible.
pub show_settings_link: bool,
pub site_name: String, pub site_name: String,
pub stylesheet_url: Option<String>, pub stylesheet_url: String,
pub stylesheet_str: Option<String>, pub stylesheet_str: String,
} }
#[derive(Deserialize, Debug, Default)] #[derive(Deserialize, Debug, Default)]
pub struct PartialUiConfig { pub struct PartialUiConfig {
pub show_engine_list_separator: Option<bool>, pub show_engine_list_separator: Option<bool>,
pub show_version_info: Option<bool>, pub show_version_info: Option<bool>,
pub show_settings_link: Option<bool>,
pub site_name: Option<String>, pub site_name: Option<String>,
pub stylesheet_url: Option<String>, pub stylesheet_url: Option<String>,
pub stylesheet_str: Option<String>, pub stylesheet_str: Option<String>,
@ -59,13 +74,20 @@ impl UiConfig {
.show_engine_list_separator .show_engine_list_separator
.unwrap_or(self.show_engine_list_separator); .unwrap_or(self.show_engine_list_separator);
self.show_version_info = partial.show_version_info.unwrap_or(self.show_version_info); self.show_version_info = partial.show_version_info.unwrap_or(self.show_version_info);
self.show_settings_link = partial
.show_settings_link
.unwrap_or(self.show_settings_link);
self.site_name = partial.site_name.unwrap_or(self.site_name.clone()); self.site_name = partial.site_name.unwrap_or(self.site_name.clone());
self.stylesheet_url = partial.stylesheet_url.or(self.stylesheet_url.clone()); self.stylesheet_url = partial
self.stylesheet_str = partial.stylesheet_str.or(self.stylesheet_str.clone()); .stylesheet_url
.unwrap_or(self.stylesheet_url.clone());
self.stylesheet_str = partial
.stylesheet_str
.unwrap_or(self.stylesheet_str.clone());
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct ImageSearchConfig { pub struct ImageSearchConfig {
pub enabled: bool, pub enabled: bool,
pub show_engines: bool, pub show_engines: bool,
@ -87,7 +109,7 @@ impl ImageSearchConfig {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct ImageProxyConfig { pub struct ImageProxyConfig {
/// Whether we should proxy remote images through our server. This is mostly /// Whether we should proxy remote images through our server. This is mostly
/// a privacy feature. /// a privacy feature.
@ -109,7 +131,7 @@ impl ImageProxyConfig {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct EnginesConfig { pub struct EnginesConfig {
pub map: HashMap<Engine, EngineConfig>, pub map: HashMap<Engine, EngineConfig>,
} }
@ -152,7 +174,7 @@ impl EnginesConfig {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct EngineConfig { pub struct EngineConfig {
pub enabled: bool, pub enabled: bool,
/// The priority of this engine relative to the other engines. /// The priority of this engine relative to the other engines.
@ -206,8 +228,9 @@ impl Default for Config {
show_engine_list_separator: false, show_engine_list_separator: false,
show_version_info: false, show_version_info: false,
site_name: "metasearch".to_string(), site_name: "metasearch".to_string(),
stylesheet_url: None, show_settings_link: true,
stylesheet_str: None, stylesheet_url: "".to_string(),
stylesheet_str: "".to_string(),
}, },
image_search: ImageSearchConfig { image_search: ImageSearchConfig {
enabled: false, enabled: false,
@ -217,7 +240,7 @@ impl Default for Config {
max_download_size: 10_000_000, max_download_size: 10_000_000,
}, },
}, },
engines: EnginesConfig::default(), engines: Arc::new(EnginesConfig::default()),
} }
} }
} }

View File

@ -1,165 +1,203 @@
const searchInputEl = document.getElementById("search-input"); const searchInputEl = document.getElementById("search-input");
// add an element with search suggestions after the search input if (searchInputEl) {
const suggestionsEl = document.createElement("div"); // add an element with search suggestions after the search input
suggestionsEl.id = "search-input-suggestions"; const suggestionsEl = document.createElement("div");
suggestionsEl.style.visibility = "hidden"; suggestionsEl.id = "search-input-suggestions";
searchInputEl.insertAdjacentElement("afterend", suggestionsEl); suggestionsEl.style.visibility = "hidden";
searchInputEl.insertAdjacentElement("afterend", suggestionsEl);
let lastValue = ""; let lastValue = "";
let nextQueryId = 0; let nextQueryId = 0;
let lastLoadedQueryId = -1; let lastLoadedQueryId = -1;
async function updateSuggestions() { async function updateSuggestions() {
const value = searchInputEl.value; const value = searchInputEl.value;
if (value === "") { if (value === "") {
suggestionsEl.style.visibility = "hidden"; suggestionsEl.style.visibility = "hidden";
nextQueryId++;
lastLoadedQueryId = nextQueryId;
return;
}
if (value === lastValue) {
suggestionsEl.style.visibility = "visible";
return;
}
lastValue = value;
const thisQueryId = nextQueryId;
nextQueryId++; nextQueryId++;
lastLoadedQueryId = nextQueryId;
return; const res = await fetch(
`/autocomplete?q=${encodeURIComponent(value)}`
).then((res) => res.json());
const options = res[1];
// this makes sure we don't load suggestions out of order
if (thisQueryId < lastLoadedQueryId) {
return;
}
lastLoadedQueryId = thisQueryId;
renderSuggestions(options);
} }
if (value === lastValue) { function renderSuggestions(options) {
if (options.length === 0) {
suggestionsEl.style.visibility = "hidden";
return;
}
suggestionsEl.style.visibility = "visible"; suggestionsEl.style.visibility = "visible";
return; suggestionsEl.innerHTML = "";
} options.forEach((option) => {
lastValue = value; const optionEl = document.createElement("div");
optionEl.textContent = option;
optionEl.className = "search-input-suggestion";
suggestionsEl.appendChild(optionEl);
const thisQueryId = nextQueryId; optionEl.addEventListener("mousedown", () => {
nextQueryId++; searchInputEl.value = option;
searchInputEl.focus();
const res = await fetch(`/autocomplete?q=${encodeURIComponent(value)}`).then( searchInputEl.form.submit();
(res) => res.json() });
);
const options = res[1];
// this makes sure we don't load suggestions out of order
if (thisQueryId < lastLoadedQueryId) {
return;
}
lastLoadedQueryId = thisQueryId;
renderSuggestions(options);
}
function renderSuggestions(options) {
if (options.length === 0) {
suggestionsEl.style.visibility = "hidden";
return;
}
suggestionsEl.style.visibility = "visible";
suggestionsEl.innerHTML = "";
options.forEach((option) => {
const optionEl = document.createElement("div");
optionEl.textContent = option;
optionEl.className = "search-input-suggestion";
suggestionsEl.appendChild(optionEl);
optionEl.addEventListener("mousedown", () => {
searchInputEl.value = option;
searchInputEl.focus();
searchInputEl.form.submit();
}); });
}
let focusedSuggestionIndex = -1;
let focusedSuggestionEl = null;
function clearFocusedSuggestion() {
if (focusedSuggestionEl) {
focusedSuggestionEl.classList.remove("focused");
focusedSuggestionEl = null;
focusedSuggestionIndex = -1;
}
}
function focusSelectionIndex(index) {
clearFocusedSuggestion();
focusedSuggestionIndex = index;
focusedSuggestionEl = suggestionsEl.children[focusedSuggestionIndex];
focusedSuggestionEl.classList.add("focused");
searchInputEl.value = focusedSuggestionEl.textContent;
}
document.addEventListener("keydown", (e) => {
// if it's focused then use different keybinds
if (searchInputEl.matches(":focus")) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (focusedSuggestionIndex === -1) {
focusSelectionIndex(0);
} else if (focusedSuggestionIndex < suggestionsEl.children.length - 1) {
focusSelectionIndex(focusedSuggestionIndex + 1);
} else {
focusSelectionIndex(0);
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (focusedSuggestionIndex === -1) {
focusSelectionIndex(suggestionsEl.children.length - 1);
} else if (focusedSuggestionIndex > 0) {
focusSelectionIndex(focusedSuggestionIndex - 1);
} else {
focusSelectionIndex(suggestionsEl.children.length - 1);
}
}
return;
}
// if the currently selected element is not the search bar and is contenteditable, don't do anything
const focusedEl = document.querySelector(":focus");
if (
focusedEl &&
(focusedEl.tagName.toLowerCase() == "input" ||
focusedEl.tagName.toLowerCase() == "textarea" ||
focusedEl.getAttribute("contenteditable") !== null)
)
return;
// if the user starts typing but they don't have focus on the input, focus it
// no modifier keys
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return;
}
// must be a letter or number
if (e.key.match(/^[a-z0-9]$/i)) {
searchInputEl.focus();
}
// right arrow key focuses it at the end
else if (e.key === "ArrowRight") {
searchInputEl.focus();
searchInputEl.setSelectionRange(
searchInputEl.value.length,
searchInputEl.value.length
);
}
// left arrow key focuses it at the beginning
else if (e.key === "ArrowLeft") {
searchInputEl.focus();
searchInputEl.setSelectionRange(0, 0);
}
// backspace key focuses it at the end
else if (e.key === "Backspace") {
searchInputEl.focus();
searchInputEl.setSelectionRange(
searchInputEl.value.length,
searchInputEl.value.length
);
}
});
// update the input suggestions on input
searchInputEl.addEventListener("input", () => {
clearFocusedSuggestion();
updateSuggestions();
});
// and when they click suggestions
searchInputEl.addEventListener("click", updateSuggestions);
// on unfocus hide the suggestions
searchInputEl.addEventListener("blur", (e) => {
suggestionsEl.style.visibility = "hidden";
}); });
} }
let focusedSuggestionIndex = -1; const customCssEl = document.getElementById("custom-css");
let focusedSuggestionEl = null; if (customCssEl) {
// tab to indent
function clearFocusedSuggestion() { // https://stackoverflow.com/a/6637396
if (focusedSuggestionEl) { customCssEl.addEventListener("keydown", (e) => {
focusedSuggestionEl.classList.remove("focused"); if (e.key == "Tab") {
focusedSuggestionEl = null;
focusedSuggestionIndex = -1;
}
}
function focusSelectionIndex(index) {
clearFocusedSuggestion();
focusedSuggestionIndex = index;
focusedSuggestionEl = suggestionsEl.children[focusedSuggestionIndex];
focusedSuggestionEl.classList.add("focused");
searchInputEl.value = focusedSuggestionEl.textContent;
}
document.addEventListener("keydown", (e) => {
// if it's focused then use different keybinds
if (searchInputEl.matches(":focus")) {
if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
if (focusedSuggestionIndex === -1) { var start = customCssEl.selectionStart;
focusSelectionIndex(0); var end = customCssEl.selectionEnd;
} else if (focusedSuggestionIndex < suggestionsEl.children.length - 1) { customCssEl.value =
focusSelectionIndex(focusedSuggestionIndex + 1); customCssEl.value.substring(0, start) +
} else { "\t" +
focusSelectionIndex(0); customCssEl.value.substring(end);
} customCssEl.selectionStart = customCssEl.selectionEnd = start + 1;
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (focusedSuggestionIndex === -1) {
focusSelectionIndex(suggestionsEl.children.length - 1);
} else if (focusedSuggestionIndex > 0) {
focusSelectionIndex(focusedSuggestionIndex - 1);
} else {
focusSelectionIndex(suggestionsEl.children.length - 1);
}
} }
});
return; // ctrl+enter anywhere on the page to submit
} const saveEl = document.getElementById("save-settings-button");
document.addEventListener("keydown", (e) => {
if (e.key == "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
console.log("click");
saveEl.click();
}
});
// if the currently selected element is not the search bar and is contenteditable, don't do anything // save whether the details are open or not
const focusedEl = document.querySelector(":focus"); const customCssDetailsEl = document.getElementById("custom-css-details");
if ( const customCssDetailsOpen = localStorage.getItem("custom-css-details-open");
focusedEl && if (customCssDetailsOpen === "true") customCssDetailsEl.open = true;
(focusedEl.tagName.toLowerCase() == "input" || customCssDetailsEl.addEventListener("toggle", () => {
focusedEl.tagName.toLowerCase() == "textarea" || localStorage.setItem("custom-css-details-open", customCssDetailsEl.open);
focusedEl.getAttribute("contenteditable") !== null) });
) }
return;
// if the user starts typing but they don't have focus on the input, focus it
// no modifier keys
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return;
}
// must be a letter or number
if (e.key.match(/^[a-z0-9]$/i)) {
searchInputEl.focus();
}
// right arrow key focuses it at the end
else if (e.key === "ArrowRight") {
searchInputEl.focus();
searchInputEl.setSelectionRange(
searchInputEl.value.length,
searchInputEl.value.length
);
}
// left arrow key focuses it at the beginning
else if (e.key === "ArrowLeft") {
searchInputEl.focus();
searchInputEl.setSelectionRange(0, 0);
}
// backspace key focuses it at the end
else if (e.key === "Backspace") {
searchInputEl.focus();
searchInputEl.setSelectionRange(
searchInputEl.value.length,
searchInputEl.value.length
);
}
});
// update the input suggestions on input
searchInputEl.addEventListener("input", () => {
clearFocusedSuggestion();
updateSuggestions();
});
// and when they click suggestions
searchInputEl.addEventListener("click", updateSuggestions);
// on unfocus hide the suggestions
searchInputEl.addEventListener("blur", (e) => {
suggestionsEl.style.visibility = "hidden";
});

View File

@ -40,12 +40,19 @@ body {
line-height: 1.2; line-height: 1.2;
height: 100%; height: 100%;
} }
.settings-link {
position: absolute;
top: 1em;
right: 1em;
}
.version-info { .version-info {
position: absolute; position: absolute;
bottom: 16px; bottom: 1em;
right: 16px; right: 1em;
} }
.results-container {
.main-container {
/* enough space for the infobox */ /* enough space for the infobox */
max-width: 73.5rem; max-width: 73.5rem;
margin: 0 auto; margin: 0 auto;
@ -62,17 +69,19 @@ main {
/* image search uses 100% width */ /* image search uses 100% width */
max-width: 100%; max-width: 100%;
} }
.results-container.search-images { .main-container.search-images {
max-width: none; max-width: none;
} }
@media screen and (max-width: 74rem) { @media screen and (max-width: 74rem) {
/* small screens */ /* small screens */
.results-container { .main-container {
margin: 0 auto; margin: 0 auto;
max-width: 40rem; max-width: 40rem;
} }
} }
input { input,
textarea,
select {
font-family: monospace; font-family: monospace;
background-color: var(--bg-2); background-color: var(--bg-2);
color: var(--fg-1); color: var(--fg-1);
@ -107,7 +116,7 @@ blockquote {
} }
/* index page */ /* index page */
.main-container { .main-container.index-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; min-height: 100%;
@ -122,6 +131,22 @@ h1 {
margin-top: 0; margin-top: 0;
} }
/* settings page */
.settings-page .back-to-index-button {
bottom: 0.5em;
position: relative;
}
.settings-form select {
display: block;
}
#save-settings-button {
margin-top: 1em;
display: block;
}
#custom-css {
tab-size: 2;
}
/* header */ /* header */
.search-form { .search-form {
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@ -1,18 +1,13 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use axum::{ use axum::{extract::Query, http::StatusCode, response::IntoResponse, Extension, Json};
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use tracing::error; use tracing::error;
use crate::{config::Config, engines}; use crate::{config::Config, engines};
pub async fn route( pub async fn route(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
State(config): State<Arc<Config>>, Extension(config): Extension<Config>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let query = params let query = params
.get("q") .get("q")

View File

@ -1,9 +1,10 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use axum::{ use axum::{
extract::{Query, State}, extract::Query,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Extension,
}; };
use tracing::error; use tracing::error;
@ -11,7 +12,7 @@ use crate::{config::Config, engines};
pub async fn route( pub async fn route(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
State(config): State<Arc<Config>>, Extension(config): Extension<Config>,
) -> Response { ) -> Response {
let image_search_config = &config.image_search; let image_search_config = &config.image_search;
let proxy_config = &image_search_config.proxy; let proxy_config = &image_search_config.proxy;

View File

@ -1,64 +1,49 @@
use std::sync::Arc; use axum::{http::header, response::IntoResponse, Extension};
use axum::{extract::State, http::header, response::IntoResponse};
use maud::{html, PreEscaped, DOCTYPE}; use maud::{html, PreEscaped, DOCTYPE};
use crate::config::Config; use crate::{config::Config, web::head_html};
const BASE_COMMIT_URL: &str = "https://github.com/mat-1/metasearch2/commit/"; const BASE_COMMIT_URL: &str = "https://github.com/mat-1/metasearch2/commit/";
const VERSION: &str = std::env!("CARGO_PKG_VERSION"); const VERSION: &str = std::env!("CARGO_PKG_VERSION");
const COMMIT_HASH: &str = std::env!("GIT_HASH"); const COMMIT_HASH: &str = std::env!("GIT_HASH");
const COMMIT_HASH_SHORT: &str = std::env!("GIT_HASH_SHORT"); const COMMIT_HASH_SHORT: &str = std::env!("GIT_HASH_SHORT");
pub async fn index(State(config): State<Arc<Config>>) -> impl IntoResponse { pub async fn get(Extension(config): Extension<Config>) -> impl IntoResponse {
let mut html = String::new(); let html = html! {
html.push_str( (PreEscaped("<!-- source code: https://github.com/mat-1/metasearch2 -->\n"))
&html! { (DOCTYPE)
(PreEscaped("<!-- source code: https://github.com/mat-1/metasearch2 -->\n")) html lang="en" {
(DOCTYPE) {(head_html(None, &config))}
html lang="en" { body {
head { @if config.ui.show_settings_link {
meta charset="UTF-8"; a.settings-link href="/settings" { "Settings" }
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { {(config.ui.site_name)} }
link rel="stylesheet" href="/style.css";
@if let Some(stylesheet_url) = &config.ui.stylesheet_url {
link rel="stylesheet" href=(stylesheet_url);
}
@if let Some(stylesheet_str) = &config.ui.stylesheet_str {
link rel="stylesheet" href=(stylesheet_str);
}
script src="/script.js" defer {}
link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml";
} }
body { div.main-container.index-page {
div."main-container" { h1 { {(config.ui.site_name)} }
h1 { {(config.ui.site_name)} } form.search-form action="/search" method="get" {
form."search-form" action="/search" method="get" { input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off";
input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off"; input type="submit" value="Search";
input type="submit" value="Search";
}
} }
@if config.ui.show_version_info { }
span."version-info" { @if config.ui.show_version_info {
@if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" { span.version-info {
"Version " @if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" {
(VERSION) "Version "
} else { (VERSION)
"Version " } else {
(VERSION) "Version "
" (" (VERSION)
a href=(format!("{BASE_COMMIT_URL}{COMMIT_HASH}")) { (COMMIT_HASH_SHORT) } " ("
")" a href=(format!("{BASE_COMMIT_URL}{COMMIT_HASH}")) { (COMMIT_HASH_SHORT) }
} ")"
} }
} }
} }
} }
} }
.into_string(), }
); .into_string();
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html) ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html)
} }

View File

@ -1,16 +1,22 @@
pub mod autocomplete; mod autocomplete;
mod image_proxy; mod image_proxy;
pub mod index; mod index;
pub mod opensearch; mod opensearch;
pub mod search; mod search;
mod settings;
use std::{convert::Infallible, net::SocketAddr, sync::Arc}; use std::{convert::Infallible, net::SocketAddr, sync::Arc};
use axum::{ use axum::{
http::header, extract::{Request, State},
routing::{get, MethodRouter}, http::{header, StatusCode},
middleware::{self, Next},
response::Response,
routing::{get, post, MethodRouter},
Router, Router,
}; };
use axum_extra::extract::CookieJar;
use maud::{html, Markup, PreEscaped};
use tracing::info; use tracing::info;
use crate::config::Config; use crate::config::Config;
@ -18,6 +24,8 @@ use crate::config::Config;
pub async fn run(config: Config) { pub async fn run(config: Config) {
let bind_addr = config.bind; let bind_addr = config.bind;
let config = Arc::new(config);
fn static_route<S>( fn static_route<S>(
content: &'static str, content: &'static str,
content_type: &'static str, content_type: &'static str,
@ -30,7 +38,10 @@ pub async fn run(config: Config) {
} }
let app = Router::new() let app = Router::new()
.route("/", get(index::index)) .route("/", get(index::get))
.route("/search", get(search::get))
.route("/settings", get(settings::get))
.route("/settings", post(settings::post))
.route( .route(
"/style.css", "/style.css",
static_route(include_str!("assets/style.css"), "text/css; charset=utf-8"), static_route(include_str!("assets/style.css"), "text/css; charset=utf-8"),
@ -57,10 +68,13 @@ pub async fn run(config: Config) {
), ),
) )
.route("/opensearch.xml", get(opensearch::route)) .route("/opensearch.xml", get(opensearch::route))
.route("/search", get(search::route))
.route("/autocomplete", get(autocomplete::route)) .route("/autocomplete", get(autocomplete::route))
.route("/image-proxy", get(image_proxy::route)) .route("/image-proxy", get(image_proxy::route))
.with_state(Arc::new(config)); .layer(middleware::from_fn_with_state(
config.clone(),
config_middleware,
))
.with_state(config);
info!("Listening on http://{bind_addr}"); info!("Listening on http://{bind_addr}");
@ -72,3 +86,52 @@ pub async fn run(config: Config) {
.await .await
.unwrap(); .unwrap();
} }
async fn config_middleware(
State(config): State<Arc<Config>>,
cookies: CookieJar,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let mut config = config.clone().as_ref().clone();
fn set_from_cookie(config: &mut String, cookies: &CookieJar, name: &str) {
if let Some(cookie) = cookies.get(name) {
let value = cookie.value();
*config = value.to_string();
}
}
set_from_cookie(&mut config.ui.stylesheet_url, &cookies, "stylesheet-url");
set_from_cookie(&mut config.ui.stylesheet_str, &cookies, "stylesheet-str");
// modify the state
req.extensions_mut().insert(config);
Ok(next.run(req).await)
}
pub fn head_html(title: Option<&str>, config: &Config) -> Markup {
html! {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title {
@if let Some(title) = title {
{ (title) }
{ " - " }
}
{(config.ui.site_name)}
}
link rel="stylesheet" href="/style.css";
@if !config.ui.stylesheet_url.is_empty() {
link rel="stylesheet" href=(config.ui.stylesheet_url);
}
@if !config.ui.stylesheet_str.is_empty() {
style { (PreEscaped(html_escape::encode_style(&config.ui.stylesheet_str))) }
}
script src="/script.js" defer {}
link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml";
}
}
}

View File

@ -1,15 +1,15 @@
mod all; mod all;
mod images; mod images;
use std::{collections::HashMap, net::SocketAddr, str::FromStr, sync::Arc}; use std::{collections::HashMap, net::SocketAddr, str::FromStr};
use async_stream::stream; use async_stream::stream;
use axum::{ use axum::{
body::Body, body::Body,
extract::{ConnectInfo, Query, State}, extract::{ConnectInfo, Query},
http::{header, HeaderMap, StatusCode}, http::{header, HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
Json, Extension, Json,
}; };
use bytes::Bytes; use bytes::Bytes;
use maud::{html, PreEscaped, DOCTYPE}; use maud::{html, PreEscaped, DOCTYPE};
@ -20,29 +20,10 @@ use crate::{
self, Engine, EngineProgressUpdate, ProgressUpdateData, ResponseForTab, SearchQuery, self, Engine, EngineProgressUpdate, ProgressUpdateData, ResponseForTab, SearchQuery,
SearchTab, SearchTab,
}, },
web::head_html,
}; };
fn render_beginning_of_html(search: &SearchQuery) -> String { fn render_beginning_of_html(search: &SearchQuery) -> String {
let head_html = html! {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title {
(search.query)
" - "
(search.config.ui.site_name)
}
link rel="stylesheet" href="/style.css";
@if let Some(stylesheet_url) = &search.config.ui.stylesheet_url {
link rel="stylesheet" href=(stylesheet_url);
}
@if let Some(stylesheet_str) = &search.config.ui.stylesheet_str {
link rel="stylesheet" href=(stylesheet_str);
}
script src="/script.js" defer {}
link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml";
}
};
let form_html = html! { let form_html = html! {
form."search-form" action="/search" method="get" { form."search-form" action="/search" method="get" {
input #"search-input" type="text" name="q" placeholder="Search" value=(search.query) autofocus onfocus="this.select()" autocomplete="off"; input #"search-input" type="text" name="q" placeholder="Search" value=(search.query) autofocus onfocus="this.select()" autocomplete="off";
@ -62,9 +43,9 @@ fn render_beginning_of_html(search: &SearchQuery) -> String {
html! { html! {
(DOCTYPE) (DOCTYPE)
html lang="en"; html lang="en";
(head_html) {(head_html(Some(&search.query), &search.config))}
body; body;
div.results-container.{"search-" (search.tab.to_string())}; div.main-container.{"search-" (search.tab.to_string())};
main; main;
(form_html) (form_html)
div.progress-updates; div.progress-updates;
@ -131,9 +112,9 @@ pub fn render_engine_list(engines: &[engines::Engine], config: &Config) -> PreEs
} }
} }
pub async fn route( pub async fn get(
Query(params): Query<HashMap<String, String>>, Query(params): Query<HashMap<String, String>>,
State(config): State<Arc<Config>>, Extension(config): Extension<Config>,
headers: HeaderMap, headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>, ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> axum::response::Response { ) -> axum::response::Response {
@ -182,7 +163,7 @@ pub async fn route(
|| addr.ip().to_string(), || addr.ip().to_string(),
|ip| ip.to_str().unwrap_or_default().to_string(), |ip| ip.to_str().unwrap_or_default().to_string(),
), ),
config: config.clone(), config: config.clone().into(),
}; };
let trying_to_use_api = let trying_to_use_api =

79
src/web/settings.rs Normal file
View File

@ -0,0 +1,79 @@
use axum::{ http::{header, StatusCode}, response::IntoResponse, Extension, Form};
use axum_extra::extract::{cookie::Cookie, CookieJar};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use serde::Deserialize;
use crate::{config::Config, web::head_html};
pub async fn get(
Extension(config): Extension<Config>,
) -> impl IntoResponse {
let theme_option = |value: &str, name: &str| -> Markup {
let selected = config.ui.stylesheet_url == value;
html! {
option value=(value) selected[selected] {
{ (name) }
}
}
};
let html = html! {
(PreEscaped("<!-- source code: https://github.com/mat-1/metasearch2 -->\n"))
(DOCTYPE)
html lang="en" {
{(head_html(Some("settings"), &config))}
body {
div.main-container.settings-page {
main {
a.back-to-index-button href="/" { "Back" }
h1 { "Settings" }
form.settings-form method="post" {
label for="theme" { "Theme" }
select name="stylesheet-url" selected=(config.ui.stylesheet_url) {
{ (theme_option("", "Ayu Dark")) }
{ (theme_option("/themes/catppuccin-mocha.css", "Catppuccin Mocha")) }
}
br;
// custom css textarea
details #custom-css-details {
summary { "Custom CSS" }
textarea name="stylesheet-str" id="custom-css" rows="10" cols="50" {
{ (config.ui.stylesheet_str) }
}
}
input #save-settings-button type="submit" value="Save";
}
}
}
}
}
}
.into_string();
( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html)
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Settings {
stylesheet_url: String,
stylesheet_str: String,
}
pub async fn post(
mut jar: CookieJar,
Form(settings): Form<Settings>,
) -> impl IntoResponse {
jar = jar.add(Cookie::new("stylesheet-url", settings.stylesheet_url));
jar = jar.add(Cookie::new("stylesheet-str", settings.stylesheet_str));
(
StatusCode::FOUND,
[(header::LOCATION, "/settings")],
jar
)
}