All API through Rust proxy, fix unregistered files null uuid, People enhancements

This commit is contained in:
2026-06-14 11:05:23 +08:00
parent a700cc983e
commit 3ba2120c8e
8 changed files with 265 additions and 45 deletions

36
AGENTS.md Normal file
View 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

View File

@@ -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 = [] }

View File

@@ -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");

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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) {

View File

@@ -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; }