pinakes/crates/pinakes-ui/src/components/settings.rs
NotAShelf adaab9de21
pinakes-ui: add book management component and reading progress display
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I877f0856ac5392266a9ba4f607a8d73c6a6a6964
2026-03-08 00:43:32 +03:00

571 lines
27 KiB
Rust

use dioxus::prelude::*;
use crate::client::ConfigResponse;
#[component]
pub fn Settings(
config: ConfigResponse,
on_add_root: EventHandler<String>,
on_remove_root: EventHandler<String>,
on_toggle_watch: EventHandler<bool>,
on_update_poll_interval: EventHandler<u64>,
on_update_ignore_patterns: EventHandler<Vec<String>>,
#[props(default)] on_update_ui_config: Option<
EventHandler<serde_json::Value>,
>,
) -> Element {
let mut new_root = use_signal(String::new);
let mut editing_poll = use_signal(|| false);
let mut poll_input = use_signal(String::new);
let mut poll_error = use_signal(|| Option::<String>::None);
let mut editing_patterns = use_signal(|| false);
let mut patterns_input = use_signal(String::new);
let writable = config.config_writable;
let watch_enabled = config.scanning.watch;
let host_port = format!("{}:{}", config.server.host, config.server.port);
let db_path = config.database_path.clone().unwrap_or_default();
let root_count = config.roots.len();
rsx! {
div { class: "settings-layout",
// Configuration source
div { class: "settings-card",
div { class: "settings-card-header",
h3 { class: "settings-card-title", "Configuration Source" }
if writable {
span { class: "badge badge-success", "Writable" }
} else {
span { class: "badge badge-warning", "Read-only" }
}
}
div { class: "settings-card-body",
if let Some(ref path) = config.config_path {
div { class: "info-row",
label { class: "form-label", "Config Path" }
span { class: "info-value mono", "{path}" }
}
}
if !writable {
div { class: "settings-notice settings-notice-warning",
"Configuration is read-only. Changes cannot be persisted to disk. "
"To enable editing, ensure the config file exists and is writable by the server process."
}
}
}
}
// Server health
div { class: "settings-card",
div { class: "settings-card-header",
h3 { class: "settings-card-title", "Server Info" }
}
div { class: "settings-card-body",
div { class: "info-row",
div { class: "form-label-row",
label { class: "form-label", "Backend" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"The storage backend used by the server (SQLite or PostgreSQL)."
}
}
}
span { class: "info-value badge badge-neutral", "{config.backend}" }
}
div { class: "info-row",
div { class: "form-label-row",
label { class: "form-label", "Server Address" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"The address and port the server is listening on."
}
}
}
span { class: "info-value mono", "{host_port}" }
}
div { class: "info-row",
div { class: "form-label-row",
label { class: "form-label", "Database Path" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"File path to the SQLite database, or connection info for PostgreSQL."
}
}
}
span { class: "info-value mono", "{db_path}" }
}
}
}
// Root directories
div { class: "settings-card",
div { class: "settings-card-header",
div { class: "form-label-row",
h3 { class: "settings-card-title", "Root Directories" }
span { class: "badge badge-neutral", "{root_count}" }
}
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Directories that Pinakes scans for media files. Only existing directories can be added."
}
}
}
div { class: "settings-card-body",
if config.roots.is_empty() {
p { class: "text-muted", "No root directories configured." }
} else {
div { class: "root-list",
for root in config.roots.iter() {
div { class: "root-item", key: "{root}",
span { class: "mono root-path", "{root}" }
button {
class: "btn btn-danger btn-sm",
disabled: !writable,
onclick: {
let root = root.clone();
move |_| {
if writable {
on_remove_root.call(root.clone());
}
}
},
"Remove"
}
}
}
}
}
div { class: "form-row",
input {
r#type: "text",
placeholder: "/path/to/root...",
value: "{new_root}",
disabled: !writable,
oninput: move |e| new_root.set(e.value()),
onkeypress: move |e: KeyboardEvent| {
if writable && e.key() == Key::Enter {
let path = new_root.read().clone();
if !path.is_empty() {
on_add_root.call(path);
new_root.set(String::new());
}
}
},
}
button {
class: "btn btn-secondary",
disabled: !writable,
onclick: move |_| {
if writable {
let mut new_root = new_root;
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await {
new_root.set(handle.path().to_string_lossy().to_string());
}
});
}
},
"Browse..."
}
button {
class: "btn btn-primary",
disabled: !writable,
onclick: move |_| {
if writable {
let path = new_root.read().clone();
if !path.is_empty() {
on_add_root.call(path);
new_root.set(String::new());
}
}
},
"Add Root"
}
}
}
}
// Scanning
div { class: "settings-card",
div { class: "settings-card-header",
h3 { class: "settings-card-title", "Scanning" }
}
div { class: "settings-card-body",
// File watching toggle
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "File Watching" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events."
}
}
}
div {
class: if writable { "toggle" } else { "toggle toggle-disabled" },
onclick: move |_| {
if writable {
on_toggle_watch.call(!watch_enabled);
}
},
div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" }
}
}
}
// Poll interval
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Poll Interval" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback."
}
}
}
if *editing_poll.read() {
div { class: "settings-inline-edit",
input {
r#type: "number",
min: "1",
value: "{poll_input}",
class: "input-sm",
oninput: move |e| {
poll_input.set(e.value());
// Clear error on new input
poll_error.set(None);
},
onkeypress: move |e: KeyboardEvent| {
if e.key() == Key::Enter {
let raw = poll_input.read().clone();
match raw.parse::<u64>() {
Ok(secs) if secs > 0 => {
on_update_poll_interval.call(secs);
editing_poll.set(false);
poll_error.set(None);
}
_ => {
poll_error
.set(Some("Enter a positive integer (seconds).".to_string()));
}
}
}
},
}
span { class: "input-suffix", "seconds" }
button {
class: "btn btn-primary btn-sm",
onclick: move |_| {
let raw = poll_input.read().clone();
match raw.parse::<u64>() {
Ok(secs) if secs > 0 => {
on_update_poll_interval.call(secs);
editing_poll.set(false);
poll_error.set(None);
}
_ => {
poll_error.set(Some("Enter a positive integer (seconds).".to_string()));
}
}
},
"Save"
}
button {
class: "btn btn-ghost btn-sm",
onclick: move |_| {
editing_poll.set(false);
poll_error.set(None);
},
"Cancel"
}
}
if let Some(ref err) = *poll_error.read() {
p { class: "field-error", "{err}" }
}
} else {
div { class: "flex-row",
span { class: "info-value", "{config.scanning.poll_interval_secs}s" }
button {
class: "btn btn-ghost btn-sm",
disabled: !writable,
onclick: {
let current = config.scanning.poll_interval_secs;
move |_| {
if writable {
poll_input.set(current.to_string());
poll_error.set(None);
editing_poll.set(true);
}
}
},
"Edit"
}
}
}
}
// Ignore patterns
div { class: "settings-field",
div { class: "settings-field-header",
div { class: "form-label-row",
label { class: "form-label", "Ignore Patterns" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Glob patterns for files and directories to skip during scanning. One pattern per line."
}
}
}
if *editing_patterns.read() {
div { class: "flex-row",
button {
class: "btn btn-primary btn-sm",
onclick: move |_| {
let input = patterns_input.read().clone();
let patterns: Vec<String> = input
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
on_update_ignore_patterns.call(patterns);
editing_patterns.set(false);
},
"Save"
}
button {
class: "btn btn-ghost btn-sm",
onclick: move |_| editing_patterns.set(false),
"Cancel"
}
}
} else {
button {
class: "btn btn-ghost btn-sm",
disabled: !writable,
onclick: {
let patterns = config.scanning.ignore_patterns.clone();
move |_| {
if writable {
patterns_input.set(patterns.join("\n"));
editing_patterns.set(true);
}
}
},
"Edit"
}
}
}
if *editing_patterns.read() {
div { class: "settings-patterns-edit",
textarea {
value: "{patterns_input}",
oninput: move |e| patterns_input.set(e.value()),
rows: "8",
class: "patterns-textarea",
placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**",
}
p { class: "text-muted text-sm",
"Enter one glob pattern per line. Empty lines are ignored."
}
}
} else {
if config.scanning.ignore_patterns.is_empty() {
p { class: "text-muted text-sm", "No ignore patterns configured." }
} else {
div { class: "patterns-list",
for pattern in config.scanning.ignore_patterns.iter() {
span { class: "pattern-chip mono", "{pattern}" }
}
}
}
}
}
}
}
// UI preferences
div { class: "settings-card",
div { class: "settings-card-header",
h3 { class: "settings-card-title", "UI Preferences" }
}
div { class: "settings-card-body",
// Theme
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Theme" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text", "Choose between dark and light themes." }
}
}
select {
value: "{config.ui.theme}",
onchange: {
let handler = on_update_ui_config;
move |e: Event<FormData>| {
if let Some(ref h) = handler {
h.call(serde_json::json!({ "theme" : e.value() }));
}
}
},
option { value: "dark", "Dark" }
option { value: "light", "Light" }
option { value: "system", "System" }
}
}
// Default view
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Default View" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"The view shown when the application starts."
}
}
}
select {
value: "{config.ui.default_view}",
onchange: {
let handler = on_update_ui_config;
move |e: Event<FormData>| {
if let Some(ref h) = handler {
h.call(serde_json::json!({ "default_view" : e.value() }));
}
}
},
option { value: "library", "Library" }
option { value: "search", "Search" }
}
}
// Default page size
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Default Page Size" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Number of items shown per page by default."
}
}
}
select {
value: "{config.ui.default_page_size}",
onchange: {
let handler = on_update_ui_config;
move |e: Event<FormData>| {
if let Some(ref h) = handler && let Ok(size) = e.value().parse::<usize>() {
h.call(serde_json::json!({ "default_page_size" : size }));
}
}
},
option { value: "24", "24" }
option { value: "48", "48" }
option { value: "96", "96" }
option { value: "200", "200" }
}
}
// Default view mode
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Default View Mode" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Whether to show items in a grid or table layout."
}
}
}
select {
value: "{config.ui.default_view_mode}",
onchange: {
let handler = on_update_ui_config;
move |e: Event<FormData>| {
if let Some(ref h) = handler {
h.call(serde_json::json!({ "default_view_mode" : e.value() }));
}
}
},
option { value: "grid", "Grid" }
option { value: "table", "Table" }
}
}
// Auto-play media
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Auto-play Media" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Automatically start playback when opening audio or video."
}
}
}
{
let autoplay = config.ui.auto_play_media;
let handler = on_update_ui_config;
rsx! {
div {
class: "toggle",
onclick: move |_| {
if let Some(ref h) = handler {
h.call(serde_json::json!({ "auto_play_media" : ! autoplay }));
}
},
div { class: if autoplay { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" }
}
}
}
}
}
// Show thumbnails
div { class: "settings-field",
div { class: "form-label-row",
label { class: "form-label", "Show Thumbnails" }
span { class: "tooltip-trigger",
"?"
span { class: "tooltip-text",
"Display thumbnail previews in library and search views."
}
}
}
{
let show_thumbs = config.ui.show_thumbnails;
let handler = on_update_ui_config;
rsx! {
div {
class: "toggle",
onclick: move |_| {
if let Some(ref h) = handler {
h.call(serde_json::json!({ "show_thumbnails" : ! show_thumbs }));
}
},
div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" },
div { class: "toggle-thumb" }
}
}
}
}
}
}
}
}
}
}