All API through Rust proxy, fix unregistered files null uuid, People enhancements
This commit is contained in:
36
AGENTS.md
Normal file
36
AGENTS.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Frontend**: Vue 3 + TypeScript + Vite (port 5173)
|
||||||
|
- **Desktop**: Tauri 2 (Rust backend in `src-tauri/`)
|
||||||
|
- **Runtime**: Node.js + npm + Rust/Cargo all required
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
```
|
||||||
|
npm run dev # Vite dev server (port 5173)
|
||||||
|
npm run build # vue-tsc --noEmit && vite build (typecheck + bundle)
|
||||||
|
npm run preview # Preview production build
|
||||||
|
npm run tauri # Alias for `cargo tauri`
|
||||||
|
cargo tauri dev # Full Tauri desktop dev (builds frontend + opens native window)
|
||||||
|
cargo tauri build # Full native app build (outputs to src-tauri/target/release/bundle/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **All Core API calls go through Rust Tauri commands** — frontend NEVER contacts `localhost:3002` directly.
|
||||||
|
- **Rust proxies to external API**: `CORE_API = http://localhost:3002` (hardcoded in `src-tauri/src/main.rs`). This service must be running for the app to function.
|
||||||
|
- **API key**: Hardcoded in `src-tauri/src/main.rs` only (`API_KEY` constant).
|
||||||
|
- **Rust entrypoint**: `src-tauri/src/main.rs` — 10 Tauri commands:
|
||||||
|
- `search_llm_smart`, `get_files`, `get_people`, `get_faces`, `get_traces` (data APIs)
|
||||||
|
- `get_thumbnail`, `get_identity_profile` (return base64 image strings)
|
||||||
|
- `get_video_stream` (returns base64 video string)
|
||||||
|
- `update_identity_name`, `delete_identity` (identity management)
|
||||||
|
- **Frontend routes**: `/search`, `/library`, `/people` (redirect `/` → `/search`)
|
||||||
|
- **Frontend entry**: `src/main.ts` → `src/App.vue` → `src/router/index.ts`
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
- **`withGlobalTauri: true`** in `tauri.conf.json` — `window.__TAURI__` is available in the webview.
|
||||||
|
- **No linter, formatter, or tests configured** — do not invent them unless asked.
|
||||||
|
- **TypeScript is non-strict** (`strict: false` in `tsconfig.json`).
|
||||||
|
- **CI/CD**: Shell scripts only (`ci-cd.sh`, `local-ci-cd.sh`, `setup-gitea-ci.sh`). No GitHub Actions.
|
||||||
|
- **Full native build order**: `npm install` → `npm run build` → `cargo tauri build`
|
||||||
|
- **Rust deps**: sqlx (postgres), reqwest, tokio, tauri-plugin-shell, base64
|
||||||
@@ -16,6 +16,7 @@ serde_json = "1"
|
|||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "json"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "json"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct SearchResult {
|
struct SearchResult {
|
||||||
@@ -18,6 +19,7 @@ struct FileInfo {
|
|||||||
file_name: String,
|
file_name: String,
|
||||||
file_size: i64,
|
file_size: i64,
|
||||||
modified_time: String,
|
modified_time: String,
|
||||||
|
#[serde(rename = "isRegistered")]
|
||||||
is_registered: bool,
|
is_registered: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +86,15 @@ async fn search_llm_smart(query: String, limit: usize) -> Result<Vec<SearchResul
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GetFilesArgs {
|
||||||
|
#[serde(rename = "pageSize")]
|
||||||
|
page_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_files(page_size: usize) -> Result<Vec<FileInfo>, String> {
|
async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
|
||||||
|
let page_size = args.page_size;
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size);
|
let url = format!("{}/api/v1/files/scan?api_key={}&page_size={}", CORE_API, API_KEY, page_size);
|
||||||
|
|
||||||
@@ -97,12 +106,15 @@ async fn get_files(page_size: usize) -> Result<Vec<FileInfo>, String> {
|
|||||||
.unwrap_or(&vec![])
|
.unwrap_or(&vec![])
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|f| {
|
.filter_map(|f| {
|
||||||
|
let is_reg = f["is_registered"].as_bool().unwrap_or(false);
|
||||||
|
let file_uuid = f["file_uuid"].as_str()
|
||||||
|
.unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string();
|
||||||
Some(FileInfo {
|
Some(FileInfo {
|
||||||
file_uuid: f["file_uuid"].as_str()?.to_string(),
|
file_uuid,
|
||||||
file_name: f["file_name"].as_str()?.to_string(),
|
file_name: f["file_name"].as_str()?.to_string(),
|
||||||
file_size: f["file_size"].as_i64().unwrap_or(0),
|
file_size: f["file_size"].as_i64().unwrap_or(0),
|
||||||
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
|
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
|
||||||
is_registered: f["is_registered"].as_bool().unwrap_or(false),
|
is_registered: is_reg,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -112,8 +124,14 @@ async fn get_files(page_size: usize) -> Result<Vec<FileInfo>, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_people(page: usize, per_page: usize) -> Result<Vec<PersonInfo>, String> {
|
async fn get_people(page: usize, per_page: usize) -> Result<Vec<PersonInfo>, String> {
|
||||||
|
eprintln!("[get_people] page={} per_page={}", page, per_page);
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page={}", CORE_API, API_KEY, page, per_page);
|
let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page={}", CORE_API, API_KEY, page, per_page);
|
||||||
|
eprintln!("[get_people] url={}", url);
|
||||||
|
|
||||||
|
let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
||||||
|
eprintln!("[get_people] identities count: {}", json["identities"].as_array().map(|a| a.len()).unwrap_or(0));
|
||||||
|
|
||||||
let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
||||||
@@ -130,6 +148,7 @@ async fn get_people(page: usize, per_page: usize) -> Result<Vec<PersonInfo>, Str
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
eprintln!("[get_people] returning {} people", people.len());
|
||||||
Ok(people)
|
Ok(people)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +204,76 @@ async fn get_traces(uuid: String, per_page: usize) -> Result<Vec<TraceInfo>, Str
|
|||||||
Ok(traces)
|
Ok(traces)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
|
||||||
|
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||||
|
let bytes = reqwest::get(&url).await
|
||||||
|
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
||||||
|
.bytes().await
|
||||||
|
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
||||||
|
Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_video_stream(uuid: String, start_time: f64, end_time: f64) -> Result<String, String> {
|
||||||
|
let url = format!("{}/api/v1/file/{}/video?api_key={}&start_time={}&end_time={}", CORE_API, uuid, API_KEY, start_time, end_time);
|
||||||
|
let bytes = reqwest::get(&url).await
|
||||||
|
.map_err(|e| format!("Video request failed: {}", e))?
|
||||||
|
.bytes().await
|
||||||
|
.map_err(|e| format!("Video read failed: {}", e))?;
|
||||||
|
Ok(format!("data:video/mp4;base64,{}", STANDARD.encode(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILE_DIRS: &[&str] = &[
|
||||||
|
"/Users/accusys/momentry/output/identities",
|
||||||
|
"/Users/accusys/momentry/output_dev/identities",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_identity_profile(uuid: String) -> Result<String, String> {
|
||||||
|
let no_dash = uuid.replace('-', "");
|
||||||
|
for dir in PROFILE_DIRS {
|
||||||
|
let path = format!("{}/{}/profile.jpg", dir, no_dash);
|
||||||
|
if std::path::Path::new(&path).exists() {
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?;
|
||||||
|
return Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||||
|
}
|
||||||
|
let path = format!("{}/{}/profile.jpg", dir, uuid);
|
||||||
|
if std::path::Path::new(&path).exists() {
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?;
|
||||||
|
return Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("Profile image not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn update_identity_name(uuid: String, name: String) -> Result<(), String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
||||||
|
let resp = client.patch(&url)
|
||||||
|
.json(&serde_json::json!({"name": name}))
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("Update failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_identity(uuid: String) -> Result<(), String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
||||||
|
let resp = client.delete(&url)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
if !resp.status().is_success() && resp.status() != 204 {
|
||||||
|
return Err(format!("Delete failed: {}", resp.status()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
@@ -193,7 +282,12 @@ fn main() {
|
|||||||
get_files,
|
get_files,
|
||||||
get_people,
|
get_people,
|
||||||
get_faces,
|
get_faces,
|
||||||
get_traces
|
get_traces,
|
||||||
|
get_thumbnail,
|
||||||
|
get_video_stream,
|
||||||
|
get_identity_profile,
|
||||||
|
update_identity_name,
|
||||||
|
delete_identity
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -34,9 +34,11 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import './assets/wordpress-exact.css';
|
@import './assets/wordpress-exact.css';
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; font-size: 14px; }
|
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||||
#app { display: flex; min-height: 100vh; }
|
#app { display: flex; min-height: 100vh; }
|
||||||
.ms-side { width: 260px; background: #fff; border-right: 1px solid #e8eaed; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; }
|
.ms-side { width: 260px; background: #fff; border-right: 1px solid #e8eaed; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; }
|
||||||
.gs-logo { padding: 16px 20px; font-size: 16px; font-weight: 700; border-bottom: 1px solid #e8eaed; }
|
.gs-logo { padding: 16px 20px; font-size: 16px; font-weight: 700; border-bottom: 1px solid #e8eaed; }
|
||||||
@@ -44,7 +46,7 @@ body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont
|
|||||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 20px; color: #5f6368; text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; cursor: pointer; }
|
.gs-nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 20px; color: #5f6368; text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; cursor: pointer; }
|
||||||
.gs-nav-item:hover { background: #f1f3f4; color: #202124; }
|
.gs-nav-item:hover { background: #f1f3f4; color: #202124; }
|
||||||
.gs-nav-item.active { background: #e8f0fe; color: #1967d2; border-left-color: #1967d2; font-weight: 600; }
|
.gs-nav-item.active { background: #e8f0fe; color: #1967d2; border-left-color: #1967d2; font-weight: 600; }
|
||||||
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; }
|
||||||
.gs-divider { height: 1px; background: #e8eaed; margin: 4px 0; }
|
.gs-divider { height: 1px; background: #e8eaed; margin: 4px 0; }
|
||||||
.gs-footer { padding: 12px 20px; border-top: 1px solid #e8eaed; }
|
.gs-footer { padding: 12px 20px; border-top: 1px solid #e8eaed; }
|
||||||
.gs-theme-switcher { display: flex; gap: 6px; margin-bottom: 10px; }
|
.gs-theme-switcher { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;
|
||||||
|
|
||||||
img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}
|
img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}
|
||||||
/*# sourceURL=wp-img-auto-sizes-contain-inline-css */
|
/*# sourceURL=wp-img-auto-sizes-contain-inline-css */
|
||||||
@@ -822,7 +823,7 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
|
|||||||
.ms-app #lt-sidebar, .ms-app ~ #lt-sidebar { display: none !important; }
|
.ms-app #lt-sidebar, .ms-app ~ #lt-sidebar { display: none !important; }
|
||||||
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap');
|
500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
#msPplViewList, #msPplViewDetail {
|
#msPplViewList, #msPplViewDetail {
|
||||||
font-family: 'DM Sans', 'Noto Sans TC', -apple-system, sans-serif;
|
font-family: 'DM Sans', 'Noto Sans TC', -apple-system, sans-serif;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<button class="close-btn" @click="close">×</button>
|
<button class="close-btn" @click="close">×</button>
|
||||||
|
|
||||||
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
|
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
|
||||||
<source :src="videoUrl" type="video/mp4" />
|
<source :src="videoSrc" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<div class="video-info">
|
<div class="video-info">
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fileUuid: string
|
fileUuid: string
|
||||||
@@ -43,15 +44,8 @@ const visible = ref(true)
|
|||||||
const currentTime = ref(0)
|
const currentTime = ref(0)
|
||||||
const duration = ref(0)
|
const duration = ref(0)
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
|
const videoSrc = ref('')
|
||||||
const CORE_API = 'http://localhost:3002'
|
const videoLoading = ref(true)
|
||||||
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
|
|
||||||
|
|
||||||
const videoUrl = computed(() => {
|
|
||||||
const start = props.startTime ?? 0
|
|
||||||
const end = props.endTime ?? 99999
|
|
||||||
return `${CORE_API}/api/v1/file/${props.fileUuid}/video?api_key=${API_KEY}&start_time=${start}&end_time=${end}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasRange = computed(() => {
|
const hasRange = computed(() => {
|
||||||
return props.startTime !== undefined && props.endTime !== undefined
|
return props.startTime !== undefined && props.endTime !== undefined
|
||||||
@@ -141,8 +135,19 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', onKeydown)
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
try {
|
||||||
|
videoSrc.value = await invoke('get_video_stream', {
|
||||||
|
uuid: props.fileUuid,
|
||||||
|
startTime: props.startTime ?? 0,
|
||||||
|
endTime: props.endTime ?? 99999
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Video stream failed:', e)
|
||||||
|
} finally {
|
||||||
|
videoLoading.value = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.is_registered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.is_registered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
||||||
<div class="mp-thumb-wrap">
|
<div class="mp-thumb-wrap">
|
||||||
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
||||||
<img v-if="isPhoto(f) || isVideo(f)" class="lt-thumb" :src="getThumbUrl(f.file_uuid)" alt="" loading="lazy" @error="handleThumbError">
|
<img v-if="isPhoto(f) || isVideo(f)" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" loading="lazy" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
|
||||||
<div v-else class="mp-doc-thumb">
|
<div v-else class="mp-doc-thumb">
|
||||||
<span class="mp-doc-icon">📄</span>
|
<span class="mp-doc-icon">📄</span>
|
||||||
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||||
|
|
||||||
@@ -99,10 +99,12 @@ const selectedFiles = ref<string[]>([])
|
|||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||||
|
|
||||||
// Sort & Filter
|
const thumbnails = ref<Record<string, string>>({})
|
||||||
|
const thumbnailLoading = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const sortBy = ref('time_desc')
|
const sortBy = ref('time_desc')
|
||||||
const filterUnregistered = ref(false)
|
const filterUnregistered = ref(false)
|
||||||
const filterRegistered = ref(false)
|
const filterRegistered = ref(true)
|
||||||
const onlyVideos = ref(false)
|
const onlyVideos = ref(false)
|
||||||
const onlyPhotos = ref(false)
|
const onlyPhotos = ref(false)
|
||||||
const sizeMin = ref<number | null>(null)
|
const sizeMin = ref<number | null>(null)
|
||||||
@@ -110,9 +112,7 @@ const sizeMax = ref<number | null>(null)
|
|||||||
const durationMin = ref<number | null>(null)
|
const durationMin = ref<number | null>(null)
|
||||||
const durationMax = ref<number | null>(null)
|
const durationMax = ref<number | null>(null)
|
||||||
|
|
||||||
const CORE_API = 'http://localhost:3002'
|
// Sort & Filter
|
||||||
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
|
|
||||||
|
|
||||||
const sortedFilteredFiles = computed(() => {
|
const sortedFilteredFiles = computed(() => {
|
||||||
let result = [...files.value]
|
let result = [...files.value]
|
||||||
|
|
||||||
@@ -127,8 +127,8 @@ const sortedFilteredFiles = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Checkbox filters
|
// Checkbox filters
|
||||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.is_registered)
|
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
|
||||||
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.is_registered)
|
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
|
||||||
// 兩者都不勾選或都勾選:顯示全部
|
// 兩者都不勾選或都勾選:顯示全部
|
||||||
|
|
||||||
if (onlyVideos.value) result = result.filter(isVideo)
|
if (onlyVideos.value) result = result.filter(isVideo)
|
||||||
@@ -172,8 +172,8 @@ async function loadFiles() {
|
|||||||
statusText.value = 'Loading...'
|
statusText.value = 'Loading...'
|
||||||
try {
|
try {
|
||||||
console.log('Calling get_files...')
|
console.log('Calling get_files...')
|
||||||
files.value = await invoke('get_files', { page_size: 500 })
|
files.value = await invoke('get_files', { args: { pageSize: 500 } })
|
||||||
console.log('Files loaded:', files.value.length)
|
console.log('Files loaded:', files.value.length); console.log('First file:', JSON.stringify(files.value[0]))
|
||||||
if (files.value.length > 0) {
|
if (files.value.length > 0) {
|
||||||
console.log('First file:', files.value[0])
|
console.log('First file:', files.value[0])
|
||||||
console.log('is_registered sample:', files.value.slice(0,5).map((f:any) => ({ name: f.file_name?.slice(0,20), is_registered: f.is_registered, type: typeof f.is_registered })))
|
console.log('is_registered sample:', files.value.slice(0,5).map((f:any) => ({ name: f.file_name?.slice(0,20), is_registered: f.is_registered, type: typeof f.is_registered })))
|
||||||
@@ -209,8 +209,16 @@ function formatDate(date: string) {
|
|||||||
return new Date(date).toISOString().slice(0, 10)
|
return new Date(date).toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThumbUrl(uuid: string) {
|
async function loadThumbnail(uuid: string) {
|
||||||
return `${CORE_API}/api/v1/file/${uuid}/thumbnail?api_key=${API_KEY}&frame=30`
|
if (thumbnails.value[uuid] || thumbnailLoading.value.has(uuid)) return
|
||||||
|
thumbnailLoading.value.add(uuid)
|
||||||
|
try {
|
||||||
|
thumbnails.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Thumbnail load failed:', e)
|
||||||
|
} finally {
|
||||||
|
thumbnailLoading.value.delete(uuid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThumbError(e: Event) {
|
function handleThumbError(e: Event) {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="people-view">
|
<div class="people-view">
|
||||||
<h1>People</h1>
|
<div class="toolbar">
|
||||||
<input v-model="search" class="search-input" placeholder="Search people..." />
|
<h1>People</h1>
|
||||||
|
<input v-model="search" class="search-input" placeholder="Search people..." />
|
||||||
|
</div>
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div v-else class="grid">
|
<div v-else class="grid">
|
||||||
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="person-card" @click="selectPerson(p)">
|
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="person-card" @click="selectPerson(p)">
|
||||||
<div class="avatar">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
|
<img v-if="profiles[p.identity_uuid]" class="avatar-img" :src="profiles[p.identity_uuid]" alt="" loading="lazy" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||||
|
<div v-else class="avatar" @vue:mounted="loadProfile(p.identity_uuid)">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
|
||||||
<p class="name">{{ p.name }}</p>
|
<p class="name">{{ p.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,8 +16,19 @@
|
|||||||
<div class="detail-box">
|
<div class="detail-box">
|
||||||
<button class="close-btn" @click="selected = null">×</button>
|
<button class="close-btn" @click="selected = null">×</button>
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
|
<img v-if="selectedProfile" class="detail-avatar-img" :src="selectedProfile" alt="">
|
||||||
<div><h2>{{ selected.name }}</h2><p class="uuid">{{ selected.identity_uuid }}</p></div>
|
<div v-else class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
|
||||||
|
<div class="detail-info">
|
||||||
|
<div class="name-row">
|
||||||
|
<input v-if="editingName" v-model="editName" class="name-input" @keyup.enter="saveName" @blur="saveName" />
|
||||||
|
<h2 v-else @click="startEditName">{{ selected.name }}</h2>
|
||||||
|
<button class="edit-btn" @click="startEditName" title="Edit name">✎</button>
|
||||||
|
</div>
|
||||||
|
<p class="uuid">{{ selected.identity_uuid }}</p>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="action-btn delete" @click="confirmDelete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
<div class="section"><h3>Faces ({{ faces.length }})</h3>
|
<div class="section"><h3>Faces ({{ faces.length }})</h3>
|
||||||
@@ -64,6 +78,11 @@ const traces = ref<any[]>([])
|
|||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||||
|
|
||||||
|
const profiles = ref<Record<string, string>>({})
|
||||||
|
const selectedProfile = ref<string>('')
|
||||||
|
const editingName = ref(false)
|
||||||
|
const editName = ref('')
|
||||||
|
|
||||||
const filteredPeople = computed(() => {
|
const filteredPeople = computed(() => {
|
||||||
if (!search.value) return people.value
|
if (!search.value) return people.value
|
||||||
const s = search.value.toLowerCase()
|
const s = search.value.toLowerCase()
|
||||||
@@ -71,9 +90,13 @@ const filteredPeople = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
console.log('PeopleView mounted')
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
|
console.log('Calling get_people...')
|
||||||
|
const result: any = await invoke('get_people', { page: 1, per_page: 1000 })
|
||||||
|
console.log('get_people result:', result)
|
||||||
people.value = Array.isArray(result) ? result : []
|
people.value = Array.isArray(result) ? result : []
|
||||||
|
console.log('People count:', people.value.length)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load people:', e)
|
console.error('Failed to load people:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,11 +104,21 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadProfile(uuid: string) {
|
||||||
|
if (profiles.value[uuid]) return
|
||||||
|
try {
|
||||||
|
profiles.value[uuid] = await invoke('get_identity_profile', { uuid })
|
||||||
|
} catch {
|
||||||
|
// keep default avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function selectPerson(p: any) {
|
async function selectPerson(p: any) {
|
||||||
selected.value = p
|
selected.value = p
|
||||||
|
selectedProfile.value = profiles.value[p.identity_uuid] || ''
|
||||||
try {
|
try {
|
||||||
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, perPage: 100 })
|
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, per_page: 100 })
|
||||||
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, perPage: 100 })
|
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, per_page: 100 })
|
||||||
faces.value = Array.isArray(fResult) ? fResult : []
|
faces.value = Array.isArray(fResult) ? fResult : []
|
||||||
traces.value = Array.isArray(tResult) ? tResult : []
|
traces.value = Array.isArray(tResult) ? tResult : []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -93,6 +126,35 @@ async function selectPerson(p: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEditName() {
|
||||||
|
editingName.value = true
|
||||||
|
editName.value = selected.value.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
editingName.value = false
|
||||||
|
if (editName.value === selected.value.name) return
|
||||||
|
try {
|
||||||
|
await invoke('update_identity_name', { uuid: selected.value.identity_uuid, name: editName.value })
|
||||||
|
selected.value.name = editName.value
|
||||||
|
const idx = people.value.findIndex((p: any) => p.identity_uuid === selected.value.identity_uuid)
|
||||||
|
if (idx >= 0) people.value[idx].name = editName.value
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update name:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!confirm(`Delete "${selected.value.name}"?`)) return
|
||||||
|
try {
|
||||||
|
await invoke('delete_identity', { uuid: selected.value.identity_uuid })
|
||||||
|
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||||
|
selected.value = null
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(sec: number): string {
|
function formatTime(sec: number): string {
|
||||||
const m = Math.floor(sec / 60)
|
const m = Math.floor(sec / 60)
|
||||||
const s = Math.floor(sec % 60)
|
const s = Math.floor(sec % 60)
|
||||||
@@ -112,21 +174,32 @@ function playTrace(t: any) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.people-view { max-width: 1200px; }
|
.people-view { max-width: 1200px; }
|
||||||
h1 { margin-bottom: 20px; }
|
.toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
|
||||||
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; margin-bottom: 20px; }
|
h1 { margin: 0; }
|
||||||
|
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; }
|
||||||
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 16px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 16px; }
|
||||||
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; }
|
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; }
|
||||||
.person-card:hover { border-color: #4f46e5; }
|
.person-card:hover { border-color: #4f46e5; }
|
||||||
.avatar { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; }
|
.avatar, .avatar-img { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; object-fit: cover; }
|
||||||
.name { font-size: 0.75rem; color: #374151; }
|
.name { font-size: 0.75rem; color: #374151; }
|
||||||
.detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
.detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
.detail-box { background: #fff; border-radius: 20px; padding: 32px; width: min(700px, 90vw); max-height: 80vh; overflow-y: auto; position: relative; }
|
.detail-box { background: #fff; border-radius: 20px; padding: 32px; width: min(700px, 90vw); max-height: 80vh; overflow-y: auto; position: relative; }
|
||||||
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border-radius: 50%; background: #f3f4f6; border: none; font-size: 1.2rem; cursor: pointer; }
|
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border-radius: 50%; background: #f3f4f6; border: none; font-size: 1.2rem; cursor: pointer; }
|
||||||
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
|
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
|
||||||
.detail-avatar { width: 80px; height: 80px; border-radius: 20px; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; }
|
.detail-avatar, .detail-avatar-img { width: 80px; height: 80px; border-radius: 20px; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; object-fit: cover; flex-shrink: 0; }
|
||||||
.detail-header h2 { margin-bottom: 4px; }
|
.detail-info { flex: 1; }
|
||||||
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; }
|
.name-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.name-row h2 { margin: 0; cursor: pointer; }
|
||||||
|
.name-row h2:hover { text-decoration: underline; }
|
||||||
|
.name-input { font-size: 1.25rem; font-weight: 700; border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; width: 200px; }
|
||||||
|
.edit-btn { background: none; border: none; cursor: pointer; font-size: 1rem; color: #6b7280; padding: 4px; }
|
||||||
|
.edit-btn:hover { color: #4f46e5; }
|
||||||
|
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
|
||||||
|
.detail-actions { display: flex; gap: 8px; }
|
||||||
|
.action-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 0.85rem; }
|
||||||
|
.action-btn.delete { color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.action-btn.delete:hover { background: #fef2f2; }
|
||||||
.section { margin-bottom: 24px; }
|
.section { margin-bottom: 24px; }
|
||||||
.section h3 { margin-bottom: 12px; font-size: 1rem; }
|
.section h3 { margin-bottom: 12px; font-size: 1rem; }
|
||||||
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user