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"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use serde::Serialize;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchResult {
|
||||
@@ -18,6 +19,7 @@ struct FileInfo {
|
||||
file_name: String,
|
||||
file_size: i64,
|
||||
modified_time: String,
|
||||
#[serde(rename = "isRegistered")]
|
||||
is_registered: bool,
|
||||
}
|
||||
|
||||
@@ -84,8 +86,15 @@ async fn search_llm_smart(query: String, limit: usize) -> Result<Vec<SearchResul
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GetFilesArgs {
|
||||
#[serde(rename = "pageSize")]
|
||||
page_size: usize,
|
||||
}
|
||||
|
||||
#[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 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![])
|
||||
.iter()
|
||||
.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 {
|
||||
file_uuid: f["file_uuid"].as_str()?.to_string(),
|
||||
file_uuid,
|
||||
file_name: f["file_name"].as_str()?.to_string(),
|
||||
file_size: f["file_size"].as_i64().unwrap_or(0),
|
||||
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();
|
||||
@@ -112,8 +124,14 @@ async fn get_files(page_size: usize) -> Result<Vec<FileInfo>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
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 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 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();
|
||||
|
||||
eprintln!("[get_people] returning {} people", people.len());
|
||||
Ok(people)
|
||||
}
|
||||
|
||||
@@ -185,6 +204,76 @@ async fn get_traces(uuid: String, per_page: usize) -> Result<Vec<TraceInfo>, Str
|
||||
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() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
@@ -193,7 +282,12 @@ fn main() {
|
||||
get_files,
|
||||
get_people,
|
||||
get_faces,
|
||||
get_traces
|
||||
get_traces,
|
||||
get_thumbnail,
|
||||
get_video_stream,
|
||||
get_identity_profile,
|
||||
update_identity_name,
|
||||
delete_identity
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -34,9 +34,11 @@
|
||||
|
||||
<style>
|
||||
@import './assets/wordpress-exact.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
* { 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; }
|
||||
.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; }
|
||||
@@ -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:hover { background: #f1f3f4; color: #202124; }
|
||||
.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-footer { padding: 12px 20px; border-top: 1px solid #e8eaed; }
|
||||
.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}
|
||||
/*# 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; }
|
||||
|
||||
|
||||
@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 {
|
||||
font-family: 'DM Sans', 'Noto Sans TC', -apple-system, sans-serif;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
|
||||
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
|
||||
<source :src="videoUrl" type="video/mp4" />
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div class="video-info">
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const props = defineProps<{
|
||||
fileUuid: string
|
||||
@@ -43,15 +44,8 @@ const visible = ref(true)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const CORE_API = 'http://localhost:3002'
|
||||
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 videoSrc = ref('')
|
||||
const videoLoading = ref(true)
|
||||
|
||||
const hasRange = computed(() => {
|
||||
return props.startTime !== undefined && props.endTime !== undefined
|
||||
@@ -141,8 +135,19 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
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(() => {
|
||||
|
||||
@@ -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 class="mp-thumb-wrap">
|
||||
<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">
|
||||
<span class="mp-doc-icon">📄</span>
|
||||
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||
@@ -85,7 +85,7 @@
|
||||
</template>
|
||||
|
||||
<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 VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
@@ -99,10 +99,12 @@ const selectedFiles = ref<string[]>([])
|
||||
const playing = ref(false)
|
||||
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 filterUnregistered = ref(false)
|
||||
const filterRegistered = ref(false)
|
||||
const filterRegistered = ref(true)
|
||||
const onlyVideos = ref(false)
|
||||
const onlyPhotos = ref(false)
|
||||
const sizeMin = ref<number | null>(null)
|
||||
@@ -110,9 +112,7 @@ const sizeMax = ref<number | null>(null)
|
||||
const durationMin = ref<number | null>(null)
|
||||
const durationMax = ref<number | null>(null)
|
||||
|
||||
const CORE_API = 'http://localhost:3002'
|
||||
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
|
||||
|
||||
// Sort & Filter
|
||||
const sortedFilteredFiles = computed(() => {
|
||||
let result = [...files.value]
|
||||
|
||||
@@ -127,8 +127,8 @@ const sortedFilteredFiles = computed(() => {
|
||||
}
|
||||
|
||||
// Checkbox filters
|
||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.is_registered)
|
||||
else if (filterRegistered.value && !filterUnregistered.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.isRegistered)
|
||||
// 兩者都不勾選或都勾選:顯示全部
|
||||
|
||||
if (onlyVideos.value) result = result.filter(isVideo)
|
||||
@@ -172,8 +172,8 @@ async function loadFiles() {
|
||||
statusText.value = 'Loading...'
|
||||
try {
|
||||
console.log('Calling get_files...')
|
||||
files.value = await invoke('get_files', { page_size: 500 })
|
||||
console.log('Files loaded:', files.value.length)
|
||||
files.value = await invoke('get_files', { args: { pageSize: 500 } })
|
||||
console.log('Files loaded:', files.value.length); console.log('First file:', JSON.stringify(files.value[0]))
|
||||
if (files.value.length > 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 })))
|
||||
@@ -209,8 +209,16 @@ function formatDate(date: string) {
|
||||
return new Date(date).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function getThumbUrl(uuid: string) {
|
||||
return `${CORE_API}/api/v1/file/${uuid}/thumbnail?api_key=${API_KEY}&frame=30`
|
||||
async function loadThumbnail(uuid: string) {
|
||||
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) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<h1>People</h1>
|
||||
<input v-model="search" class="search-input" placeholder="Search people..." />
|
||||
<div class="toolbar">
|
||||
<h1>People</h1>
|
||||
<input v-model="search" class="search-input" placeholder="Search people..." />
|
||||
</div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else class="grid">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,8 +16,19 @@
|
||||
<div class="detail-box">
|
||||
<button class="close-btn" @click="selected = null">×</button>
|
||||
<div class="detail-header">
|
||||
<div class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
|
||||
<div><h2>{{ selected.name }}</h2><p class="uuid">{{ selected.identity_uuid }}</p></div>
|
||||
<img v-if="selectedProfile" class="detail-avatar-img" :src="selectedProfile" alt="">
|
||||
<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 class="detail-content">
|
||||
<div class="section"><h3>Faces ({{ faces.length }})</h3>
|
||||
@@ -64,6 +78,11 @@ const traces = ref<any[]>([])
|
||||
const playing = ref(false)
|
||||
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(() => {
|
||||
if (!search.value) return people.value
|
||||
const s = search.value.toLowerCase()
|
||||
@@ -71,9 +90,13 @@ const filteredPeople = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('PeopleView mounted')
|
||||
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 : []
|
||||
console.log('People count:', people.value.length)
|
||||
} catch (e) {
|
||||
console.error('Failed to load people:', e)
|
||||
} 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) {
|
||||
selected.value = p
|
||||
selectedProfile.value = profiles.value[p.identity_uuid] || ''
|
||||
try {
|
||||
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, perPage: 100 })
|
||||
const tResult: any = await invoke('get_traces', { 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, per_page: 100 })
|
||||
faces.value = Array.isArray(fResult) ? fResult : []
|
||||
traces.value = Array.isArray(tResult) ? tResult : []
|
||||
} 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 {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
@@ -112,21 +174,32 @@ function playTrace(t: any) {
|
||||
|
||||
<style scoped>
|
||||
.people-view { max-width: 1200px; }
|
||||
h1 { margin-bottom: 20px; }
|
||||
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; margin-bottom: 20px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 20px; 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; }
|
||||
.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: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; }
|
||||
.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; }
|
||||
.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-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-header h2 { margin-bottom: 4px; }
|
||||
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; }
|
||||
.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-info { flex: 1; }
|
||||
.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 h3 { margin-bottom: 12px; font-size: 1rem; }
|
||||
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user