Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I877f0856ac5392266a9ba4f607a8d73c6a6a6964
571 lines
27 KiB
Rust
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" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|