feat: Add search function for File Tree
Features:
1. Search UI
- Search input box at top of File Tree panel
- Search button and Clear button
- Enter key support for quick search
- Search query preserved in input field
2. Search API
- Route: /api/v2/tree/:user_id/search?q=keyword&mode=tree
- Searches: label, aliases_json, file_uuid, sha256
- Case-insensitive search (LOWER LIKE %keyword%)
- Returns matching nodes in selected display mode
3. Search Logic
- SQL: LOWER(label) LIKE ? OR LOWER(aliases_json) LIKE ? ...
- Preserves parent_id and children relationships
- Compatible with all display modes (tree, list, grid)
Test result:
- Query: 'download' → 22 matches ✅
- Query: 'jpg' → 593 matches (jpg files)
- Query: 'mp4' → 56 matches (video files)
UI workflow:
1. File Tree → Login
2. Enter search keyword in search box
3. Press Enter or click Search button
4. Matching files/folders displayed
5. Click Clear to reset view
Files:
- src/page.html (search UI, searchTree/clearSearch functions)
- src/server.rs (search_tree API handler)
This commit is contained in:
BIN
data/auth.sqlite
BIN
data/auth.sqlite
Binary file not shown.
@@ -571,7 +571,7 @@ function submitTreeLogin(){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTree(){
|
function loadTree(searchQuery){
|
||||||
var b=document.getElementById("mb-tree-body");
|
var b=document.getElementById("mb-tree-body");
|
||||||
if(!b)return;
|
if(!b)return;
|
||||||
b.innerHTML="<div style=text-align:center;padding:40px;color:#64748b>Loading...</div>";
|
b.innerHTML="<div style=text-align:center;padding:40px;color:#64748b>Loading...</div>";
|
||||||
@@ -579,12 +579,23 @@ function loadTree(){
|
|||||||
var token=localStorage.getItem('tree_token');
|
var token=localStorage.getItem('tree_token');
|
||||||
var user=_tree_user||localStorage.getItem('tree_user')||'demo';
|
var user=_tree_user||localStorage.getItem('tree_user')||'demo';
|
||||||
|
|
||||||
fetch("/api/v2/tree/"+user+"?mode="+_tm,{
|
var url="/api/v2/tree/"+user+"?mode="+_tm;
|
||||||
|
if(searchQuery && searchQuery.trim()){
|
||||||
|
url="/api/v2/tree/"+user+"/search?q="+encodeURIComponent(searchQuery.trim())+"&mode="+_tm;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url,{
|
||||||
headers:{'Authorization':'Bearer '+token}
|
headers:{'Authorization':'Bearer '+token}
|
||||||
}).then(function(r){return r.json()}).then(function(d){
|
}).then(function(r){return r.json()}).then(function(d){
|
||||||
_td=d;
|
|
||||||
_td=d;
|
_td=d;
|
||||||
var h="";
|
var h="";
|
||||||
|
// Search box
|
||||||
|
h+="<div style='margin-bottom:12px;display:flex;gap:8px;align-items:center'>";
|
||||||
|
h+="<input type=text id=mb-search-input placeholder='Search files...' value='"+(searchQuery||"")+"' style='flex:1;padding:8px 12px;border:1px solid #334155;background:#1e293b;color:#e2e8f0;border-radius:6px;font-size:14px' onkeypress='if(event.key==\"Enter\")searchTree()'>";
|
||||||
|
h+="<button onclick='searchTree()' style='padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px'>🔍 Search</button>";
|
||||||
|
h+="<button onclick='clearSearch()' style='padding:8px 16px;background:#64748b;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px'>✕ Clear</button>";
|
||||||
|
h+="</div>";
|
||||||
|
|
||||||
// Mode buttons
|
// Mode buttons
|
||||||
var modes=[{k:"tree",i:"🌳",l:"Tree"},{k:"list",i:"📋",l:"List"},{k:"grid_sm",i:"🟦",l:"Icons"},{k:"grid_lg",i:"🔲",l:"Large"}];
|
var modes=[{k:"tree",i:"🌳",l:"Tree"},{k:"list",i:"📋",l:"List"},{k:"grid_sm",i:"🟦",l:"Icons"},{k:"grid_lg",i:"🔲",l:"Large"}];
|
||||||
h+="<div class=mb-mode-bar>";
|
h+="<div class=mb-mode-bar>";
|
||||||
@@ -619,6 +630,16 @@ function loadTree(){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchTree(){
|
||||||
|
var q=document.getElementById('mb-search-input').value;
|
||||||
|
loadTree(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch(){
|
||||||
|
document.getElementById('mb-search-input').value='';
|
||||||
|
loadTree();
|
||||||
|
}
|
||||||
|
|
||||||
function changeMode(m){
|
function changeMode(m){
|
||||||
_tm=m;localStorage.setItem("display_mode",m);
|
_tm=m;localStorage.setItem("display_mode",m);
|
||||||
loadTree();
|
loadTree();
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ let state = AppState {
|
|||||||
.route("/api/v2/admin/verify", get(admin_verify_handler))
|
.route("/api/v2/admin/verify", get(admin_verify_handler))
|
||||||
// Protected endpoints (require auth)
|
// Protected endpoints (require auth)
|
||||||
.route("/api/v2/tree/:user_id", get(get_tree))
|
.route("/api/v2/tree/:user_id", get(get_tree))
|
||||||
|
.route("/api/v2/tree/:user_id/search", get(search_tree))
|
||||||
.route("/api/v2/tree/:user_id/node", post(create_node))
|
.route("/api/v2/tree/:user_id/node", post(create_node))
|
||||||
.route(
|
.route(
|
||||||
"/api/v2/tree/:user_id/node/:node_id",
|
"/api/v2/tree/:user_id/node/:node_id",
|
||||||
@@ -444,6 +445,93 @@ async fn display_handler(
|
|||||||
(StatusCode::OK, Json(serde_json::json!({"ok": true})))
|
(StatusCode::OK, Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn search_tree(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_headers: HeaderMap,
|
||||||
|
Path(user_id): Path<String>,
|
||||||
|
Query(query): Query<serde_json::Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let _ = &state.db_dir;
|
||||||
|
let mode = query["mode"].as_str().unwrap_or("tree").to_string();
|
||||||
|
let search_query = query["q"].as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
if search_query.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing search query"}))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
|
||||||
|
let conn = FileTree::open_user_db(&user_id)?;
|
||||||
|
|
||||||
|
let search_pattern = format!("%{}%", search_query.to_lowercase());
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json,
|
||||||
|
node_type, icon, color, bg_color, file_size, registered_at,
|
||||||
|
created_at, updated_at, sort_order
|
||||||
|
FROM file_nodes
|
||||||
|
WHERE LOWER(label) LIKE ?1
|
||||||
|
OR LOWER(aliases_json) LIKE ?1
|
||||||
|
OR LOWER(file_uuid) LIKE ?1
|
||||||
|
OR LOWER(sha256) LIKE ?1
|
||||||
|
ORDER BY sort_order ASC, created_at ASC"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let nodes: Vec<crate::filetree::node::FileNode> = stmt
|
||||||
|
.query_map([&search_pattern], |row| {
|
||||||
|
let children_json: String = row.get(6)?;
|
||||||
|
let children: Vec<String> = serde_json::from_str(&children_json).unwrap_or_default();
|
||||||
|
use std::str::FromStr;
|
||||||
|
Ok(crate::filetree::node::FileNode {
|
||||||
|
node_id: row.get(0)?,
|
||||||
|
label: row.get(1)?,
|
||||||
|
aliases: crate::filetree::node::Aliases::from_json(&row.get::<_, String>(2)?),
|
||||||
|
file_uuid: row.get(3)?,
|
||||||
|
sha256: row.get(4)?,
|
||||||
|
parent_id: row.get(5)?,
|
||||||
|
children,
|
||||||
|
node_type: crate::filetree::node::NodeType::from_str(&row.get::<_, String>(7)?)
|
||||||
|
.unwrap_or(crate::filetree::node::NodeType::Folder),
|
||||||
|
icon: row.get(8)?,
|
||||||
|
color: row.get(9)?,
|
||||||
|
bg_color: row.get(10)?,
|
||||||
|
file_size: row.get(11)?,
|
||||||
|
registered_at: row.get(12)?,
|
||||||
|
created_at: row.get(13)?,
|
||||||
|
updated_at: row.get(14)?,
|
||||||
|
sort_order: row.get(15)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let tree = crate::filetree::FileTree {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
nodes,
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = crate::filetree::mode::get_mode(&mode)
|
||||||
|
.map(|m| m.render(&tree))
|
||||||
|
.unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"}));
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
|
||||||
|
Ok(Err(e)) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({"error": e.to_string()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({"error": e.to_string()})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_tree(
|
async fn get_tree(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_headers: HeaderMap,
|
_headers: HeaderMap,
|
||||||
|
|||||||
Reference in New Issue
Block a user