initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
This commit is contained in:
commit
6a73d11c4b
124 changed files with 34856 additions and 0 deletions
545
crates/pinakes-ui/src/components/settings.rs
Normal file
545
crates/pinakes-ui/src/components/settings.rs
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
// 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue