various: simplify code; work on security and performance

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
raf 2026-02-02 17:32:11 +03:00
commit c4adc4e3e0
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
75 changed files with 12921 additions and 358 deletions

View file

@ -48,7 +48,7 @@ pub fn App() -> Element {
let base_url =
std::env::var("PINAKES_SERVER_URL").unwrap_or_else(|_| "http://localhost:3000".into());
let api_key = std::env::var("PINAKES_API_KEY").ok();
let client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref()));
let mut client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref()));
let server_url = use_signal(|| base_url.clone());
let mut current_view = use_signal(|| View::Library);
@ -103,10 +103,13 @@ pub fn App() -> Element {
// Auth state
let mut auth_required = use_signal(|| false);
let mut current_user = use_signal(|| Option::<UserInfoResponse>::None);
let _login_error = use_signal(|| Option::<String>::None);
let _login_loading = use_signal(|| false);
let mut login_error = use_signal(|| Option::<String>::None);
let mut login_loading = use_signal(|| false);
let mut auto_play_media = use_signal(|| false);
// Import state for UI feedback
let mut import_in_progress = use_signal(|| false);
// Check auth on startup
let client_auth = client.read().clone();
use_effect(move || {
@ -117,10 +120,16 @@ pub fn App() -> Element {
current_user.set(Some(user));
auth_required.set(false);
}
Err(_) => {
// Check if server has accounts enabled by trying login endpoint
// If we get a 401 on /auth/me, accounts may be enabled
auth_required.set(false); // Will be set to true if needed
Err(e) => {
// Check if this is an auth error (401) vs network error
let err_str = e.to_string();
if err_str.contains("401")
|| err_str.contains("unauthorized")
|| err_str.contains("Unauthorized")
{
auth_required.set(true);
}
// For network errors, don't require auth (server offline state handles this)
}
}
// Load UI config
@ -255,6 +264,33 @@ pub fn App() -> Element {
}
};
// Login handler for auth flow
let on_login_submit = {
move |(username, password): (String, String)| {
let login_client = client.read().clone();
spawn(async move {
login_loading.set(true);
login_error.set(None);
match login_client.login(&username, &password).await {
Ok(resp) => {
// Update the signal with a new client that has the token set
client.write().set_token(&resp.token);
current_user.set(Some(UserInfoResponse {
username: resp.username,
role: resp.role,
}));
auth_required.set(false);
}
Err(e) => {
login_error.set(Some(format!("Login failed: {e}")));
}
}
login_loading.set(false);
});
}
};
let view_title = use_memo(move || current_view.read().title());
let _total_pages = use_memo(move || {
let ps = *media_page_size.read();
@ -265,8 +301,15 @@ pub fn App() -> Element {
rsx! {
style { {styles::CSS} }
// Phase 7.1: Keyboard shortcuts
div { class: "app",
if *auth_required.read() {
crate::components::login::Login {
on_login: on_login_submit,
error: login_error.read().clone(),
loading: *login_loading.read(),
}
} else {
// Phase 7.1: Keyboard shortcuts
div { class: "app",
tabindex: "0",
onkeydown: {
move |evt: KeyboardEvent| {
@ -316,7 +359,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{25a6}" }
"Library"
span { class: "nav-item-text", "Library" }
// Phase 7.2: Badge
span { class: "nav-badge", "{media_total_count}" }
}
@ -324,7 +367,7 @@ pub fn App() -> Element {
class: if *current_view.read() == View::Search { "nav-item active" } else { "nav-item" },
onclick: move |_| current_view.set(View::Search),
span { class: "nav-icon", "\u{2315}" }
"Search"
span { class: "nav-item-text", "Search" }
}
button {
class: if *current_view.read() == View::Import { "nav-item active" } else { "nav-item" },
@ -341,7 +384,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2912}" }
"Import"
span { class: "nav-item-text", "Import" }
}
}
@ -357,7 +400,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2605}" }
"Tags"
span { class: "nav-item-text", "Tags" }
// Phase 7.2: Badge
span { class: "nav-badge", "{tags_list.read().len()}" }
}
@ -373,7 +416,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2630}" }
"Collections"
span { class: "nav-item-text", "Collections" }
// Phase 7.2: Badge
span { class: "nav-badge", "{collections_list.read().len()}" }
}
@ -391,7 +434,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2637}" }
"Audit"
span { class: "nav-item-text", "Audit" }
}
button {
class: if *current_view.read() == View::Duplicates { "nav-item active" } else { "nav-item" },
@ -408,7 +451,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2261}" }
"Duplicates"
span { class: "nav-item-text", "Duplicates" }
}
button {
class: if *current_view.read() == View::Settings { "nav-item active" } else { "nav-item" },
@ -425,7 +468,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2699}" }
"Settings"
span { class: "nav-item-text", "Settings" }
}
button {
class: if *current_view.read() == View::Database { "nav-item active" } else { "nav-item" },
@ -442,7 +485,7 @@ pub fn App() -> Element {
}
},
span { class: "nav-icon", "\u{2750}" }
"Database"
span { class: "nav-item-text", "Database" }
}
}
@ -1142,6 +1185,7 @@ pub fn App() -> Element {
tags: tags_list.read().clone(),
collections: collections_list.read().clone(),
scan_progress: scan_progress.read().clone(),
is_importing: *import_in_progress.read(),
on_import_file: {
let client = client.read().clone();
let refresh_media = refresh_media.clone();
@ -1150,6 +1194,7 @@ pub fn App() -> Element {
let client = client.clone();
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
import_in_progress.set(true);
spawn(async move {
if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() {
match client.import_file(&path).await {
@ -1179,6 +1224,7 @@ pub fn App() -> Element {
Err(e) => show_toast(format!("Import failed: {e}"), true),
}
}
import_in_progress.set(false);
});
}
},
@ -1190,8 +1236,8 @@ pub fn App() -> Element {
let client = client.clone();
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
import_in_progress.set(true);
spawn(async move {
show_toast("Importing directory...".into(), false);
match client.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()).await {
Ok(resp) => {
show_toast(
@ -1208,6 +1254,7 @@ pub fn App() -> Element {
}
Err(e) => show_toast(format!("Directory import failed: {e}"), true),
}
import_in_progress.set(false);
});
}
},
@ -1218,8 +1265,8 @@ pub fn App() -> Element {
move |_| {
let client = client.clone();
let refresh_media = refresh_media.clone();
import_in_progress.set(true);
spawn(async move {
show_toast("Scanning...".into(), false);
match client.trigger_scan().await {
Ok(_results) => {
// Poll scan status until done
@ -1242,6 +1289,7 @@ pub fn App() -> Element {
}
Err(e) => show_toast(format!("Scan failed: {e}"), true),
}
import_in_progress.set(false);
});
}
},
@ -1253,8 +1301,9 @@ pub fn App() -> Element {
let client = client.clone();
let refresh_media = refresh_media.clone();
let refresh_tags = refresh_tags.clone();
let file_count = paths.len();
import_in_progress.set(true);
spawn(async move {
show_toast(format!("Importing {} files...", paths.len()), false);
match client.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref()).await {
Ok(resp) => {
show_toast(
@ -1269,8 +1318,9 @@ pub fn App() -> Element {
preview_files.set(Vec::new());
preview_total_size.set(0);
}
Err(e) => show_toast(format!("Batch import failed: {e}"), true),
Err(e) => show_toast(format!("Batch import failed ({file_count} files): {e}"), true),
}
import_in_progress.set(false);
});
}
},
@ -1556,6 +1606,7 @@ pub fn App() -> Element {
}
}
}
} // end else (auth not required)
// Phase 1.4: Toast queue - show up to 3 stacked from bottom
div { class: "toast-container",

View file

@ -22,6 +22,7 @@ pub fn Import(
preview_files: Vec<DirectoryPreviewFile>,
preview_total_size: u64,
scan_progress: Option<ScanStatusResponse>,
#[props(default = false)] is_importing: bool,
) -> Element {
let mut import_mode = use_signal(|| 0usize);
let mut file_path = use_signal(String::new);
@ -44,6 +45,19 @@ pub fn Import(
let current_mode = *import_mode.read();
rsx! {
// Import status panel (shown when import is in progress)
if is_importing {
div { class: "import-status-panel",
div { class: "import-status-header",
div { class: "status-dot checking" }
span { "Import in progress..." }
}
div { class: "progress-bar",
div { class: "progress-fill indeterminate" }
}
}
}
// Tab bar
div { class: "import-tabs",
button {
@ -114,6 +128,7 @@ pub fn Import(
}
button {
class: "btn btn-primary",
disabled: is_importing,
onclick: {
let mut file_path = file_path;
let mut selected_tags = selected_tags;
@ -133,7 +148,7 @@ pub fn Import(
}
}
},
"Import"
if is_importing { "Importing..." } else { "Import" }
}
}
}
@ -494,7 +509,7 @@ pub fn Import(
rsx! {
button {
class: "btn btn-primary",
disabled: !has_selected,
disabled: !has_selected || is_importing,
onclick: {
let mut selected_file_paths = selected_file_paths;
let mut selected_tags = selected_tags;
@ -514,7 +529,9 @@ pub fn Import(
}
}
},
if has_selected {
if is_importing {
"Importing..."
} else if has_selected {
"Import Selected ({sel_count})"
} else {
"Import Selected"
@ -526,6 +543,7 @@ pub fn Import(
// Import entire directory
button {
class: "btn btn-secondary",
disabled: is_importing,
onclick: {
let mut dir_path = dir_path;
let mut selected_tags = selected_tags;
@ -547,7 +565,7 @@ pub fn Import(
}
}
},
"Import Entire Directory"
if is_importing { "Importing..." } else { "Import Entire Directory" }
}
}
}
@ -569,8 +587,9 @@ pub fn Import(
div { class: "mb-16", style: "text-align: center;",
button {
class: "btn btn-primary",
disabled: is_importing,
onclick: move |_| on_scan.call(()),
"Scan All Roots"
if is_importing { "Scanning..." } else { "Scan All Roots" }
}
}

View file

@ -41,14 +41,20 @@ impl Default for PlayQueue {
}
impl PlayQueue {
/// Check if the queue is empty.
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Get the current item in the queue.
#[allow(dead_code)]
pub fn current(&self) -> Option<&QueueItem> {
self.items.get(self.current_index)
}
/// Advance to the next item based on repeat mode.
#[allow(dead_code)]
pub fn next(&mut self) -> Option<&QueueItem> {
if self.items.is_empty() {
return None;
@ -70,6 +76,8 @@ impl PlayQueue {
}
}
/// Go to the previous item based on repeat mode.
#[allow(dead_code)]
pub fn previous(&mut self) -> Option<&QueueItem> {
if self.items.is_empty() {
return None;
@ -82,10 +90,14 @@ impl PlayQueue {
self.items.get(self.current_index)
}
/// Add an item to the queue.
#[allow(dead_code)]
pub fn add(&mut self, item: QueueItem) {
self.items.push(item);
}
/// Remove an item from the queue by index.
#[allow(dead_code)]
pub fn remove(&mut self, index: usize) {
if index < self.items.len() {
self.items.remove(index);
@ -95,11 +107,15 @@ impl PlayQueue {
}
}
/// Clear all items from the queue.
#[allow(dead_code)]
pub fn clear(&mut self) {
self.items.clear();
self.current_index = 0;
}
/// Toggle between repeat modes: Off -> All -> One -> Off.
#[allow(dead_code)]
pub fn toggle_repeat(&mut self) {
self.repeat = match self.repeat {
RepeatMode::Off => RepeatMode::All,
@ -108,6 +124,8 @@ impl PlayQueue {
};
}
/// Toggle shuffle mode on/off.
#[allow(dead_code)]
pub fn toggle_shuffle(&mut self) {
self.shuffle = !self.shuffle;
}

View file

@ -81,6 +81,15 @@ body {
.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; }
.sidebar.collapsed .nav-icon { width: auto; margin: 0; }
/* Nav item text - hide when collapsed */
.nav-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar.collapsed .nav-item-text { display: none; }
.sidebar-toggle {
background: none;
border: none;
@ -1550,6 +1559,34 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
transition: width 0.3s ease;
}
.progress-fill.indeterminate {
width: 30%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
/* ── Import status panel ── */
.import-status-panel {
background: var(--bg-2);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
}
.import-status-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-0);
}
/* ── Tag confirmation ── */
.tag-confirm-delete {
display: inline-flex;
@ -2336,14 +2373,26 @@ ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); }
align-items: center;
gap: 6px;
font-size: 12px;
flex-wrap: wrap;
overflow: hidden;
min-width: 0;
}
.user-name {
font-weight: 500;
color: var(--text-0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 90px;
flex-shrink: 1;
}
/* Hide user details in collapsed sidebar, show only logout icon */
.sidebar.collapsed .user-info .user-name,
.sidebar.collapsed .user-info .role-badge { display: none; }
.sidebar.collapsed .user-info .btn { padding: 6px; }
.role-badge {
display: inline-block;
padding: 1px 6px;