Initial commit: Momentry Studio v0.1.0

This commit is contained in:
2026-06-13 17:49:02 +08:00
commit 79e0a862d4
14019 changed files with 1129062 additions and 0 deletions

292
src/views/LibraryView.vue Normal file
View File

@@ -0,0 +1,292 @@
<template>
<div id="ms-files-page">
<section class="mp-panel is-active">
<div class="mp-toolbar">
<div class="mp-toolbar-left">
<button class="mp-btn mp-btn-primary" type="button" @click="addToPeople">Register</button>
<button class="mp-btn mp-btn-icon" type="button" title="Refresh" @click="loadFiles">
<span class="mp-refresh-icon">⟳</span>
</button>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="all" v-model="displayFilter"> All
</label>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="video" v-model="displayFilter"> All Videos
</label>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="photo" v-model="displayFilter"> All Photos
</label>
</div>
<div class="mp-toolbar-right">
<div class="mp-search-wrap">
<input type="text" class="mp-search" v-model="filterText" placeholder="Search files / docs / videos">
</div>
<button class="mp-filter-btn" type="button" @click="showFilter = !showFilter">☰</button>
</div>
</div>
<!-- Filter Popup -->
<div class="mp-filter-pop" :class="{ show: showFilter }">
<div class="mp-filter-title">排序方式</div>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_desc" v-model="sortBy" checked> 依時間・由近到遠</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_asc" v-model="sortBy"> 依時間・由遠到近</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_desc" v-model="sortBy"> 依大小・由大到小</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_asc" v-model="sortBy"> 依大小・由小到大</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_asc" v-model="sortBy"> 依檔名・由 A 到 Z</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_desc" v-model="sortBy"> 依檔名・由 Z 到 A</label>
<div class="mp-filter-title">過濾方式</div>
<label class="mp-check-row"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
<label class="mp-check-row"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
<label class="mp-check-row"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
<label class="mp-check-row"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
<div class="mp-filter-title">大小</div>
<div class="mp-range-row">
<input type="number" class="mp-filter-number" v-model.number="sizeMin" min="0" placeholder="最小 MB">
<span>—</span>
<input type="number" class="mp-filter-number" v-model.number="sizeMax" min="0" placeholder="最大 MB">
</div>
<div class="mp-filter-title">影片時長</div>
<div class="mp-range-row">
<input type="number" class="mp-filter-number" v-model.number="durationMin" min="0" placeholder="最小分鐘">
<span>—</span>
<input type="number" class="mp-filter-number" v-model.number="durationMax" min="0" placeholder="最大分鐘">
</div>
<button type="button" class="mp-filter-reset" @click="resetFilters">清除篩選</button>
</div>
<div class="mp-status" :class="{ show: loading || statusText }">{{ statusText }}</div>
<div class="mp-grid">
<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">
<div v-else class="mp-doc-thumb">
<span class="mp-doc-icon">📄</span>
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
</div>
<button v-if="isVideo(f)" class="mp-play-btn" @click.stop="playVideo(f)">▶</button>
<div class="mp-complete-mark" v-if="f.is_registered">✓</div>
</div>
<div class="mp-meta">
<div class="mp-name">{{ f.file_name }}</div>
<div class="mp-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
</div>
</div>
</div>
</section>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const files = ref<any[]>([])
const loading = ref(false)
const statusText = ref('')
const filterText = ref('')
const displayFilter = ref('all')
const showFilter = ref(false)
const selectedFiles = ref<string[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
// Sort & Filter
const sortBy = ref('time_desc')
const filterUnregistered = ref(false)
const filterRegistered = ref(false)
const onlyVideos = ref(false)
const onlyPhotos = ref(false)
const sizeMin = ref<number | null>(null)
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'
const sortedFilteredFiles = computed(() => {
let result = [...files.value]
// Display filter
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
// Text search
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
// 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 (onlyVideos.value) result = result.filter(isVideo)
if (onlyPhotos.value) result = result.filter(isPhoto)
// Size filter
if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Duration filter (estimated from file size for now)
if (durationMin.value || durationMax.value) {
result = result.filter((f: any) => {
if (!isVideo(f)) return !durationMin.value && !durationMax.value
const estDuration = (f.file_size || 0) / 50000000 // rough estimate
if (durationMin.value && estDuration < durationMin.value * 60) return false
if (durationMax.value && estDuration > durationMax.value * 60) return false
return true
})
}
// Sort
result.sort((a: any, b: any) => {
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
onMounted(() => loadFiles())
async function loadFiles() {
loading.value = true
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)
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 })))
}
statusText.value = `${files.value.length} files`
} catch (e: any) {
console.error('Failed to load files:', e)
statusText.value = 'Failed: ' + (e.message || e)
} finally {
loading.value = false
}
}
function isVideo(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['mp4', 'mov', 'mkv', 'avi', 'webm'].includes(ext)
}
function isPhoto(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)
}
function getFileExt(name: string) {
return (name || '').split('.').pop().toUpperCase()
}
function formatSize(bytes: number) {
return (bytes / 1048576).toFixed(1) + ' MB'
}
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`
}
function handleThumbError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
function toggleSelect(f: any) {
const idx = selectedFiles.value.indexOf(f.file_uuid)
if (idx >= 0) selectedFiles.value.splice(idx, 1)
else selectedFiles.value.push(f.file_uuid)
}
function playVideo(f: any) {
currentVideo.value = { fileUuid: f.file_uuid, startTime: 0, endTime: 99999, title: f.file_name }
playing.value = true
}
function addToPeople() {
if (!selectedFiles.value.length) { alert('Please select files first') }
alert(`Register ${selectedFiles.value.length} file(s)`)
}
function resetFilters() {
sortBy.value = 'time_desc'
filterUnregistered.value = false
filterRegistered.value = false
onlyVideos.value = false
onlyPhotos.value = false
sizeMin.value = null
sizeMax.value = null
durationMin.value = null
durationMax.value = null
displayFilter.value = 'all'
filterText.value = ''
}
</script>
<style scoped>
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: #202124; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
#ms-files-page * { box-sizing: border-box; }
.mp-panel { display: block; position: relative; overflow: visible; }
.mp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
.mp-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.mp-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.mp-btn { border: 1px solid #d8dce3; background: #fff; color: #202124; border-radius: 18px; font-size: 14px; padding: 10px 18px; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.04); font-family: inherit; }
.mp-btn-primary { font-weight: 600; background: #1967d2; color: #fff; border-color: #1967d2; }
.mp-btn-icon { width: 40px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,.08); }
.mp-refresh-icon { font-size: 22px; line-height: 1; font-weight: 500; transform: translateY(-1px); }
.mp-filter-btn { width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid #d8dce3; background: #fff; cursor: pointer; font-size: 16px; }
.mp-radio-row, .mp-check-row { font-size: 13px; color: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
.mp-search-wrap { width: 240px; }
.mp-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
.mp-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
.mp-status.show { display: block; }
.mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
.mp-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
.mp-doc-icon { font-size: 38px; line-height: 1; }
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
.mp-badge-type { position: absolute; top: 8px; left: 8px; font-size: 10px; line-height: 1; padding: 4px 6px; border-radius: 999px; background: rgba(17,24,39,.75); color: #fff; letter-spacing: .3px; }
.mp-complete-mark { position: absolute; right: 10px; bottom: 10px; width: 28px; height: 28px; border-radius: 50%; background: rgba(17,24,39,.78); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; z-index: 9; }
.mp-file-card.is-selected .mp-thumb-wrap { border-color: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
.mp-file-card.is-selected .mp-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
.mp-meta { padding-top: 6px; }
.mp-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
.mp-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mp-play-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 42px; height: 42px; border-radius: 50%; border: none; background: rgba(0,0,0,.58); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 8; }
.mp-filter-pop { position: absolute; top: 64px; right: 0; width: 250px; background: #fff; border: 1px solid #eceff3; border-radius: 24px; box-shadow: 0 16px 36px rgba(0,0,0,.08); padding: 18px 18px 16px; z-index: 999; display: none; max-height: 560px; overflow-y: auto; }
.mp-filter-pop.show { display: block; }
.mp-filter-title { font-size: 15px; font-weight: 700; color: #202124; margin: 10px 0 10px; }
.mp-filter-title:first-child { margin-top: 0; }
.mp-range-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; }
.mp-filter-number { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
.mp-filter-reset { width: 100%; height: 34px; margin-top: 8px; border: 1px solid #d8dce3; background: #fff; color: #5f6368; border-radius: 12px; font-size: 13px; cursor: pointer; font-family: inherit; }
.mp-filter-reset:hover { background: #f6f7f9; }
</style>