various: simplify code; work on security and performance
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
parent
016841b200
commit
c4adc4e3e0
75 changed files with 12921 additions and 358 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue