Files
momentry_studio/src/views/ClientView.vue
Momentry Studio 5951aca086 feat: frame-based positioning and mark system foundation
Core Changes:
- Fix SearchView to use start_frame/end_frame directly (no time*fps conversion)
- Add hard_delete support to delete_trace API
- VideoPlayer: Main timeline + Mark system foundation
- Proxy: Add local routes for auth, media, identity-matches, cluster-results
- Add .gitignore to exclude build artifacts and dependencies

Design Documents:
- Multi-track Mark system design (.opencode/plans/)
- Video editing positioning standards research

Files Modified:
- src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames)
- src/views/PeopleView.vue: batchDeleteGroups with hard_delete
- src/api/index.ts: delete_trace with hard_delete body
- src/components/VideoPlayer.vue: Timeline + Mark UI
- src-tauri/src/proxy.rs: New local routes
- AGENTS.md: Update documentation
2026-07-24 20:19:47 +08:00

1308 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div id="ms-view-client" class="client-layout">
<div class="client-header">
<div class="header-top-row">
<h1>File Manager</h1>
<div class="header-buttons">
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
<button @click="showCreateFolderDialog">New Folder</button>
<button @click="showUploadDialog">Upload</button>
<button @click="confirmDelete" :disabled="!selectedFile">Delete</button>
<button @click="showMoveDialog" :disabled="!selectedFile">Move</button>
<button @click="refreshFiles">Refresh</button>
</div>
</div>
<div class="header-path-row">Path: {{ currentPath }}</div>
<div class="client-search">
<input v-model="searchQuery" type="text" placeholder="Search files..." @input="onSearch" />
</div>
</div>
<div class="client-content">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<table v-else class="file-table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">Name <span v-if="sortField==='name'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th @click="sortBy('size')" class="sortable">Size <span v-if="sortField==='size'">{{ sortAsc ? '▲' : '▼' }}</span></th>
<th>Type</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr v-if="pagedFiles.length === 0">
<td colspan="4" class="empty-folder">Empty folder</td>
</tr>
<tr
v-for="f in pagedFiles"
:key="f.name"
@click="selectFile(f)"
@dblclick="openFile(f)"
:draggable="!f.isDir"
@dragstart="onDragStart($event, f)"
@dragover.prevent="f.isDir ? onDragOver($event, f.name) : null"
@dragleave="f.isDir ? onDragLeave($event) : null"
@drop="f.isDir ? onDrop($event, f.name) : null"
:class="{ selected: selectedFile?.name === f.name, 'drag-over': dragOverTarget === f.name && f.isDir }"
>
<td>
<span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span>
{{ f.name }}
<span v-if="!f.isDir && currentPath === '/' && (!f.tags || f.tags.length === 0)" class="badge badge-untagged">No folder</span>
<span v-else-if="f.folder" class="badge badge-folder">{{ f.folder }}</span>
<button class="btn-rename" @click.stop="showRenameDialog(f)" title="Rename">✎</button>
</td>
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
<td>{{ f.isDir ? 'Folder' : getFileType(f.name) }}</td>
<td>{{ f.modified || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="client-pagination" v-if="filteredFiles.length > 0">
<span>Per page:</span>
<select v-model.number="pageSize" @change="currentPage = 0; applyFilters()">
<option :value="5">5</option>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="filteredFiles.length">All</option>
</select>
<button @click="goPage(-1)" :disabled="currentPage === 0">‹ Prev</button>
<span>Page {{ currentPage + 1 }} of {{ Math.max(1, totalPages) }} ({{ filteredFiles.length }} files)</span>
<button @click="goPage(1)" :disabled="currentPage >= totalPages - 1">Next ›</button>
</div>
<!-- Create Folder Dialog -->
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
<div class="dialog" @click.stop>
<h3>Create New Folder</h3>
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
<div class="dialog-buttons">
<button @click="createFolder">Create</button>
<button @click="hideCreateFolderDialog">Cancel</button>
</div>
</div>
</div>
<!-- Delete Confirm Dialog -->
<div v-if="showDeleteConfirm" class="dialog-overlay" @click="cancelDelete">
<div class="dialog" @click.stop>
<h3>Delete "{{ deleteTarget }}"</h3>
<p v-if="selectedFile?.isDir && selectedFile.fileCount">
This folder contains <strong>{{ selectedFile.fileCount }}</strong> file(s). They will be permanently deleted.
</p>
<p v-else>Are you sure you want to delete this item?</p>
<div class="dialog-buttons">
<button @click="doDelete" class="danger-btn">Delete</button>
<button @click="cancelDelete">Cancel</button>
</div>
</div>
</div>
<!-- Rename Dialog -->
<div v-if="showRename" class="dialog-overlay" @click="hideRenameDialog">
<div class="dialog" @click.stop>
<h3>Rename "{{ renameOldName }}"</h3>
<input v-model="renameNewName" type="text" placeholder="New name" @keyup.enter="doRename" autofocus />
<div class="dialog-buttons">
<button @click="doRename">Rename</button>
<button @click="hideRenameDialog">Cancel</button>
</div>
</div>
</div>
<!-- File Preview Dialog -->
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
<div class="dialog preview-dialog" @click.stop>
<h3>File Preview: {{ selectedFile?.name }}</h3>
<div v-if="previewLoading" class="loading">Loading preview...</div>
<div v-else-if="previewContent" class="preview-content">
<img v-if="isImage" :src="previewContent" alt="preview" />
<video v-else-if="isVideo" controls style="max-width:100%; max-height:60vh">
<source :src="previewContent" :type="'video/' + previewExt">
</video>
<audio v-else-if="isAudio" controls style="width:100%">
<source :src="previewContent" :type="'audio/' + previewExt">
</audio>
<iframe v-else-if="isPdf" :src="previewContent + '#view=FitH'" style="width:100%; height:70vh; border:none;"></iframe>
<pre v-else>{{ previewContent }}</pre>
</div>
<div class="dialog-buttons">
<button @click="downloadFile">Download</button>
<button @click="hidePreview">Close</button>
</div>
</div>
</div>
<!-- Upload Dialog -->
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
<div class="dialog upload-dialog" @click.stop>
<h3>Upload File{{ uploadFiles.length > 1 ? 's' : '' }}</h3>
<input ref="fileInput" type="file" multiple @change="selectUploadFile" />
<div v-if="uploadFiles.length > 0" class="upload-info">
<p v-if="!uploading">{{ uploadFiles.length }} file(s) selected</p>
<div v-if="uploading" class="upload-multi-progress">
<p>Uploading {{ uploadFileIndex + 1 }} / {{ uploadFileCount }}: {{ uploadFiles[uploadFileIndex]?.name }}</p>
<progress :value="uploadProgress" max="100"></progress>
<span>{{ uploadProgress }}%</span>
</div>
<ul v-else class="upload-file-list">
<li v-for="f in uploadFiles" :key="f.name">{{ f.name }} ({{ formatSize(f.size) }})</li>
</ul>
</div>
<div class="dialog-buttons">
<button @click="uploadFileToServer" :disabled="uploadFiles.length === 0 || uploading">
{{ uploading ? 'Uploading...' : 'Upload' }}
</button>
<button @click="hideUploadDialog">Cancel</button>
</div>
</div>
</div>
<!-- Move Dialog -->
<div v-if="showMove" class="dialog-overlay" @click="showMove = false">
<div class="dialog" @click.stop>
<h3>{{ selectedFile?.isDir ? 'Move Folder' : 'Move File' }} "{{ selectedFile?.name }}"</h3>
<select v-model="moveTarget" class="move-select">
<option value="" disabled>Select a folder...</option>
<option v-for="folder in availableFolders" :key="folder.name" :value="folder.name">
{{ folder.label }}
</option>
</select>
<div class="dialog-buttons">
<button @click="doMove" :disabled="!moveTarget">Move</button>
<button @click="showMove = false">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const currentPath = ref('/')
const files = ref<any[]>([])
const filteredFiles = ref<any[]>([])
const selectedFile = ref<any>(null)
const loading = ref(false)
const error = ref('')
const showCreateFolder = ref(false)
const newFolderName = ref('')
const showPreview = ref(false)
const previewContent = ref('')
const searchQuery = ref('')
const currentPage = ref(0)
const pageSize = ref(10)
const sortField = ref<string>('name')
const sortAsc = ref(true)
const previewLoading = ref(false)
const isImage = ref(false)
const isVideo = ref(false)
const isAudio = ref(false)
const isPdf = ref(false)
const previewExt = ref('')
const showUpload = ref(false)
const uploadFiles = ref<File[]>([])
const uploadProgress = ref(0)
const uploadFileIndex = ref(0)
const uploadFileCount = ref(0)
const uploading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref('')
const showMove = ref(false)
const moveTarget = ref('')
const moveFolders = ref<any[]>([])
const dragOverTarget = ref<string | null>(null)
const dragFile = ref<any>(null)
const showRename = ref(false)
const renameOldName = ref('')
const renameNewName = ref('')
const renameIsDir = ref(false)
const availableFolders = computed(() => {
const selectedName = selectedFile.value?.name || ''
return moveFolders.value
.filter((f: any) => {
const parts = f.name.split('/')
const leaf = parts[parts.length - 1]
return leaf !== selectedName && f.name !== ''
})
.map((f: any) => {
const parts = f.name.split('/')
const depth = parts.length - 1
const indent = '\u00A0\u00A0\u00A0'.repeat(depth)
const display = depth === 0 ? parts[0] : indent + '\u2514\u2500 ' + parts[parts.length - 1]
return { ...f, label: display }
})
})
// MarkBase server URL (configurable via env or default)
function detectMarkbaseUrl(): string {
const stored = (window as any).__MARKBASE_URL__ || localStorage.getItem('markbase_url')
if (stored) return stored
const hostname = window.location.hostname
if (hostname === 'localhost' || hostname === '127.0.0.1') return 'http://localhost:11438'
return window.location.origin
}
const MARKBASE_URL = detectMarkbaseUrl()
const API_BASE = MARKBASE_URL + '/api/v2'
function getUsername(): string {
return localStorage.getItem('username') || 'demo'
}
function cleanFolderName(name: string): string {
return name.startsWith('/') ? name.substring(1) : name
}
// Pagination & sorting
const totalPages = computed(() => Math.ceil(filteredFiles.value.length / pageSize.value))
const pagedFiles = computed(() => {
const start = currentPage.value * pageSize.value
return filteredFiles.value.slice(start, start + pageSize.value)
})
function sortBy(field: string) {
if (sortField.value === field) { sortAsc.value = !sortAsc.value }
else { sortField.value = field; sortAsc.value = true }
applyFilters()
}
function onSearch() {
currentPage.value = 0
applyFilters()
}
function applyFilters() {
let result = [...files.value]
// Search filter
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(f => f.name.toLowerCase().includes(q))
}
// Sort
result.sort((a, b) => {
let va: any, vb: any
if (sortField.value === 'name') { va = a.name.toLowerCase(); vb = b.name.toLowerCase() }
else if (sortField.value === 'size') { va = a.size; vb = b.size }
else { va = a.name; vb = b.name }
if (va < vb) return sortAsc.value ? -1 : 1
if (va > vb) return sortAsc.value ? 1 : -1
return 0
})
filteredFiles.value = result
}
function goPage(delta: number) {
const newPage = currentPage.value + delta
if (newPage >= 0 && newPage < totalPages.value) { currentPage.value = newPage }
}
function getFileType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || ''
return ext ? ext.toUpperCase() : 'File'
}
// Load files from current directory
async function loadFiles() {
loading.value = true
error.value = ''
selectedFile.value = null
try {
const username = getUsername()
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
// Fetch folders and files independently
let folders: any[] = []
let filesData: any[] = []
try {
let foldersUrl = `${API_BASE}/myfiles/${username}/folders`
if (folderParam) foldersUrl += `?parent=${encodeURIComponent(folderParam)}`
const foldersRes = await fetch(foldersUrl)
if (foldersRes.ok) folders = await foldersRes.json()
} catch (e) {
console.warn('Failed to load folders:', e)
}
try {
const filesUrl = folderParam
? `${API_BASE}/myfiles/${username}/files?folder=${encodeURIComponent(folderParam)}`
: `${API_BASE}/myfiles/${username}/files`
const filesRes = await fetch(filesUrl)
if (filesRes.ok) filesData = await filesRes.json()
else filesData = []
} catch (e) {
console.warn('Failed to load files:', e)
}
// Build flat file list: folders first, then files
const result: any[] = []
if (Array.isArray(folders)) {
folders.forEach((folder: any) => {
let folderName = folder.name || ''
if (folderName.startsWith('/')) folderName = folderName.substring(1)
result.push({
name: folderName,
isDir: true,
size: 0,
modified: '-',
uuid: folder.id || null,
tags: [],
fileCount: folder.file_count || 0,
})
})
}
if (Array.isArray(filesData)) {
filesData.forEach((file: any) => {
const tags: string[] = file.tags || []
result.push({
name: file.name || file.filename,
isDir: false,
size: file.size || 0,
modified: file.modified || '-',
uuid: null,
tags: tags,
folder: file.folder || '',
})
})
}
files.value = result
currentPage.value = 0
applyFilters()
} catch (e: any) {
error.value = 'Failed to load files: ' + e.message
files.value = []
filteredFiles.value = []
} finally {
loading.value = false
}
}
// Navigate to parent directory
function goUp() {
if (currentPath.value === '/') return
const parts = currentPath.value.split('/').filter(p => p)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
// Select file
function selectFile(f: any) {
selectedFile.value = f
}
// Open file (double-click)
function openFile(f: any) {
if (f.isDir) {
// Navigate into directory
currentPath.value = currentPath.value === '/'
? '/' + f.name
: currentPath.value + '/' + f.name
loadFiles()
} else {
// Preview file
showFilePreview(f)
}
}
// Show create folder dialog
function showCreateFolderDialog() {
newFolderName.value = ''
showCreateFolder.value = true
}
// Hide create folder dialog
function hideCreateFolderDialog() {
showCreateFolder.value = false
newFolderName.value = ''
}
// Create new folder
async function createFolder() {
if (!newFolderName.value.trim()) {
alert('Please enter folder name')
return
}
const folderName = newFolderName.value
const currentFolder = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
try {
let url = `${API_BASE}/myfiles/${getUsername()}/folders`
if (currentFolder) url += `?folder=${encodeURIComponent(currentFolder)}`
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: folderName,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
hideCreateFolderDialog()
loadFiles()
} catch (e: any) {
alert('Failed to create folder: ' + e.message)
}
}
// Delete selected file/folder
function confirmDelete() {
if (!selectedFile.value) return
deleteTarget.value = selectedFile.value.name
showDeleteConfirm.value = true
}
async function doDelete() {
showDeleteConfirm.value = false
if (!deleteTarget.value) return
const itemName = deleteTarget.value
deleteTarget.value = ''
const username = getUsername()
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
try {
if (selectedFile.value?.isDir) {
const response = await fetch(`${API_BASE}/myfiles/${username}/folders/${encodeURIComponent(itemName)}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
} else {
let url = `${API_BASE}/myfiles/${username}/files/${encodeURIComponent(itemName)}`
if (folderParam) url += `?folder=${encodeURIComponent(folderParam)}`
const response = await fetch(url, { method: 'DELETE' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
}
selectedFile.value = null
loadFiles()
} catch (e: any) {
alert('Failed to delete: ' + e.message)
}
}
function cancelDelete() {
showDeleteConfirm.value = false
deleteTarget.value = ''
}
// Refresh file list
function refreshFiles() {
loadFiles()
}
// Show move dialog
// Flatten tree into flat list with paths
function flattenTree(nodes: any[], prefix: string = ''): any[] {
let result: any[] = []
for (const n of nodes) {
const path = prefix ? `${prefix}/${n.name}` : n.name
result.push({ name: path, isDir: true })
if (n.children && n.children.length > 0) {
result = result.concat(flattenTree(n.children, path))
}
}
return result
}
async function showMoveDialog() {
moveTarget.value = ''
try {
const res = await fetch(`${API_BASE}/myfiles/${getUsername()}/folders/tree`)
if (res.ok) {
const tree = await res.json()
moveFolders.value = flattenTree(tree)
}
} catch (e) {
moveFolders.value = []
}
showMove.value = true
}
// Move file or folder to another folder
async function doMove() {
if (!selectedFile.value || !moveTarget.value) return
const name = selectedFile.value.name
const fromTag = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
showMove.value = false
try {
if (selectedFile.value.isDir) {
// Move folder: rename directory on disk
const res = await fetch(`${API_BASE}/myfiles/${getUsername()}/move-folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: name, to_folder: moveTarget.value }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} else {
// Move file: rename on disk + update tags
const res = await fetch(`${API_BASE}/myfiles/${getUsername()}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: name, from_tag: fromTag, to_tag: moveTarget.value }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
}
selectedFile.value = null
loadFiles()
} catch (e: any) {
alert('Failed to move: ' + e.message)
}
}
// Drag & drop handlers
function onDragStart(event: DragEvent, f: any) {
if (!f.isDir) {
dragFile.value = f
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', f.name)
}
}
function onDragOver(event: DragEvent, folderName: string) {
event.preventDefault()
event.dataTransfer!.dropEffect = 'move'
dragOverTarget.value = folderName
}
function onDragLeave(event: DragEvent) {
dragOverTarget.value = null
}
async function onDrop(event: DragEvent, targetFolder: string) {
event.preventDefault()
dragOverTarget.value = null
if (!dragFile.value) return
const file = dragFile.value
const fromTag = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
const toTag = targetFolder.startsWith('/') ? targetFolder.substring(1) : targetFolder
try {
const res = await fetch(`${API_BASE}/myfiles/${getUsername()}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: file.name, from_tag: fromTag, to_tag: toTag }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
loadFiles()
} catch (e: any) {
alert('Failed to move file: ' + e.message)
}
dragFile.value = null
}
// Rename functions
function showRenameDialog(f: any) {
renameOldName.value = f.name
renameNewName.value = f.name
renameIsDir.value = f.isDir
showRename.value = true
setTimeout(() => {
const input = document.querySelector('.dialog input[type="text"]') as HTMLInputElement
if (input) { input.focus(); input.select() }
}, 50)
}
function hideRenameDialog() {
showRename.value = false
renameOldName.value = ''
renameNewName.value = ''
}
async function doRename() {
if (!renameNewName.value.trim() || renameNewName.value === renameOldName.value) {
hideRenameDialog()
return
}
const username = getUsername()
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
try {
let url: string, body: any
if (renameIsDir.value) {
url = `${API_BASE}/myfiles/${username}/rename-folder`
body = { folder: renameOldName.value, new_name: renameNewName.value }
} else {
url = `${API_BASE}/myfiles/${username}/rename-file`
body = { file: renameOldName.value, folder: folderParam, new_name: renameNewName.value }
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(err || `HTTP ${res.status}`)
}
hideRenameDialog()
loadFiles()
} catch (e: any) {
alert('Rename failed: ' + e.message)
}
}
// Show file preview
async function showFilePreview(f: any) {
showPreview.value = true
previewLoading.value = true
previewContent.value = ''
isImage.value = false
isVideo.value = false
isAudio.value = false
isPdf.value = false
previewExt.value = ''
try {
// Check file type by extension
const ext = f.name.split('.').pop()?.toLowerCase() || ''
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'webm']
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'm4a']
const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'py', 'rs', 'go', 'java']
const pdfExtensions = ['pdf']
if (imageExtensions.includes(ext)) {
isImage.value = true
const username = getUsername()
const folder_q = f.folder ? `?folder=${encodeURIComponent(f.folder)}` : ''
const previewUrl = `${API_BASE}/myfiles/${username}/preview/${encodeURIComponent(f.name)}${folder_q}`
const response = await fetch(previewUrl)
if (response.ok) {
const blob = await response.blob()
previewContent.value = URL.createObjectURL(blob)
} else {
previewContent.value = previewUrl
}
} else if (videoExtensions.includes(ext)) {
// Video preview
isVideo.value = true
isImage.value = false
isAudio.value = false
previewExt.value = ext
const username = getUsername()
const folder_q = f.folder ? `?folder=${encodeURIComponent(f.folder)}` : ''
previewContent.value = `${API_BASE}/myfiles/${username}/preview/${encodeURIComponent(f.name)}${folder_q}`
} else if (audioExtensions.includes(ext)) {
// Audio preview
isAudio.value = true
isImage.value = false
isVideo.value = false
previewExt.value = ext
const username = getUsername()
const folder_q = f.folder ? `?folder=${encodeURIComponent(f.folder)}` : ''
previewContent.value = `${API_BASE}/myfiles/${username}/preview/${encodeURIComponent(f.name)}${folder_q}`
} else if (textExtensions.includes(ext)) {
// Text preview - fetch and display content
const username = getUsername()
const folder_q = f.folder ? `?folder=${encodeURIComponent(f.folder)}` : ''
const response = await fetch(`${API_BASE}/myfiles/${username}/preview/${encodeURIComponent(f.name)}${folder_q}`)
if (response.ok) {
const text = await response.text()
// Limit preview size
if (text.length > 10000) {
previewContent.value = text.substring(0, 10000) + '\n\n... (truncated, file too large)'
} else {
previewContent.value = text
}
} else {
previewContent.value = 'Failed to load file content'
}
} else if (pdfExtensions.includes(ext)) {
isPdf.value = true
const username = getUsername()
const folder_q = f.folder ? `?folder=${encodeURIComponent(f.folder)}` : ''
previewContent.value = `${API_BASE}/myfiles/${username}/preview/${encodeURIComponent(f.name)}${folder_q}`
} else {
// Unknown file type - show download link
previewContent.value = `File type: ${ext}\n\nPreview not available for this file type.\nClick Download button to download the file.`
}
} catch (e: any) {
previewContent.value = 'Failed to load preview: ' + e.message
} finally {
previewLoading.value = false
}
}
// Hide preview dialog
function hidePreview() {
showPreview.value = false
previewContent.value = ''
isImage.value = false
isVideo.value = false
isAudio.value = false
isPdf.value = false
previewExt.value = ''
}
// Show upload dialog
function showUploadDialog() {
uploadFiles.value = []
uploadProgress.value = 0
uploadFileIndex.value = 0
uploadFileCount.value = 0
uploading.value = false
showUpload.value = true
}
// Hide upload dialog
function hideUploadDialog() {
showUpload.value = false
uploadFiles.value = []
uploadProgress.value = 0
uploadFileIndex.value = 0
uploadFileCount.value = 0
uploading.value = false
}
// Select file(s) for upload
function selectUploadFile(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
uploadFiles.value = Array.from(target.files)
uploadProgress.value = 0
uploadFileIndex.value = 0
uploadFileCount.value = uploadFiles.value.length
}
}
// Upload files sequentially to server
async function uploadFileToServer() {
if (uploadFiles.value.length === 0) {
alert('No files selected')
return
}
uploading.value = true
uploadFileCount.value = uploadFiles.value.length
uploadFileIndex.value = 0
const currentFolder = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
let successCount = 0
let errorCount = 0
for (let i = 0; i < uploadFiles.value.length; i++) {
uploadFileIndex.value = i
uploadProgress.value = 0
const file = uploadFiles.value[i]
const formData = new FormData()
if (currentFolder) formData.append('folder', currentFolder)
formData.append('file', file)
const result = await new Promise<boolean>((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
}
})
xhr.addEventListener('load', async () => {
if (xhr.status === 200 || xhr.status === 201) {
// Tag file with folder name if inside a subdirectory
if (currentFolder) {
try {
const resp = JSON.parse(xhr.responseText)
const filename = resp.filename
if (filename) {
await fetch(`${API_BASE}/myfiles/${getUsername()}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: filename, tag: currentFolder }),
})
}
} catch (e) {
console.warn('Failed to tag file:', e)
}
}
resolve(true)
} else {
resolve(false)
}
})
xhr.addEventListener('error', () => {
resolve(false)
})
xhr.open('POST', `${API_BASE}/upload-unlimited/${getUsername()}`)
xhr.send(formData)
})
if (result) successCount++
else errorCount++
}
uploading.value = false
hideUploadDialog()
loadFiles()
if (errorCount === 0) {
alert(`Upload complete: ${successCount} file(s) uploaded!`)
} else {
alert(`Upload finished: ${successCount} succeeded, ${errorCount} failed.`)
}
}
// Download file
function downloadFile() {
if (!selectedFile.value) {
alert('No file selected')
return
}
if (selectedFile.value.downloadUrl) {
window.open(selectedFile.value.downloadUrl, '_blank')
} else if (selectedFile.value.uuid) {
const downloadUrl = `${API_BASE}/files/${getUsername()}/${selectedFile.value.uuid}/stream`
window.open(downloadUrl, '_blank')
} else if (selectedFile.value.name) {
const folder_q = selectedFile.value.folder ? `?folder=${encodeURIComponent(selectedFile.value.folder)}` : ''
const downloadUrl = `${API_BASE}/myfiles/${getUsername()}/preview/${encodeURIComponent(selectedFile.value.name)}${folder_q}`
window.open(downloadUrl, '_blank')
} else {
alert('Download URL not available')
}
}
// Format file size
function formatSize(n: number): string {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB'
}
// Initialize on mount
onMounted(() => {
loadFiles()
})
</script>
<style scoped>
#ms-view-client {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
color: var(--text-primary);
}
.client-layout {
display: flex;
flex-direction: column;
min-height: calc(100vh - 60px);
}
.client-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--card-background);
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
.client-header h1 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
}
.header-top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-buttons {
display: flex;
gap: 8px;
}
.header-buttons button {
padding: 6px 14px;
border: 1px solid var(--border-color);
background: var(--card-background);
color: var(--text-primary);
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
font-size: 13px;
}
.header-buttons button:hover:not(:disabled) {
background: rgba(var(--primary-color-rgb), 0.1);
}
.header-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.header-path-row {
margin-top: 8px;
padding: 8px 12px;
background: var(--hover-background);
border-radius: 5px;
font-weight: bold;
color: var(--primary-color);
font-size: 13px;
word-break: break-all;
}
.client-search {
margin-top: 10px;
}
.client-search input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 5px;
font-size: 14px;
background: var(--card-background);
color: var(--text-primary);
}
.client-content {
flex: 1;
overflow: auto;
margin-top: 10px;
min-height: 400px;
}
.file-table th.sortable {
cursor: pointer;
user-select: none;
}
.file-table th.sortable:hover {
background: rgba(var(--primary-color-rgb), 0.1);
}
.client-pagination {
display: flex;
gap: 12px;
align-items: center;
margin-top: 12px;
padding: 10px 0;
font-size: 13px;
color: var(--text-secondary);
}
.client-pagination select {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-background);
color: var(--text-primary);
cursor: pointer;
}
.client-pagination button {
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-background);
color: var(--text-primary);
cursor: pointer;
}
.client-pagination button:disabled {
opacity: 0.4;
cursor: default;
}
.client-pagination button:hover:not(:disabled) {
background: rgba(var(--primary-color-rgb), 0.1);
}
.client-content {
background: var(--card-background);
border-radius: 5px;
overflow: hidden;
}
.loading, .error {
padding: 40px;
text-align: center;
color: var(--text-secondary);
}
.error {
color: var(--danger-color);
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th, .file-table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
text-align: left;
color: var(--text-primary);
}
.file-table th {
background: var(--hover-background);
font-weight: bold;
color: var(--text-primary);
}
.file-table tr.selected {
background: rgba(var(--primary-color-rgb), 0.1);
}
.file-table tr.drag-over {
background: rgba(var(--warning-color), 0.2);
outline: 2px dashed var(--warning-color);
}
.file-table tr[draggable="true"] {
cursor: grab;
}
.file-table tr:hover {
background: var(--hover-background);
cursor: pointer;
}
.empty-folder {
text-align: center;
color: var(--muted-text);
padding: 40px;
}
.folder-icon, .file-icon {
margin-right: 8px;
}
.badge {
display: inline-block;
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
margin-left: 6px;
vertical-align: middle;
font-weight: 500;
}
.badge-untagged {
background: rgba(var(--warning-color), 0.3);
color: var(--warning-color);
}
.badge-folder {
background: rgba(var(--primary-color), 0.2);
color: var(--primary-color);
}
.btn-rename {
display: inline-block;
margin-left: 4px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
border-radius: 3px;
}
.btn-rename:hover {
background: var(--hover-background);
color: var(--text-primary);
}
/* Dialog styles */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: var(--card-background);
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 400px;
color: var(--text-primary);
}
.dialog h3 {
margin: 0 0 15px;
color: var(--text-primary);
}
.dialog input,
.dialog select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
background: var(--card-background);
color: var(--text-primary);
}
.dialog select {
background: var(--card-background);
cursor: pointer;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-buttons button {
padding: 8px 20px;
border: 1px solid var(--border-color);
border-radius: 5px;
cursor: pointer;
background: var(--card-background);
color: var(--text-primary);
}
.dialog-buttons button:first-child {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.danger-btn { background: var(--danger-color); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; }
.danger-btn:hover { background: var(--danger-hover); }
.preview-dialog {
max-width: 800px;
max-height: 80vh;
}
.preview-content {
max-height: 60vh;
overflow: auto;
margin-top: 15px;
}
.preview-content img {
max-width: 100%;
max-height: 60vh;
}
.preview-content video,
.preview-content audio {
width: 100%;
border-radius: 8px;
background: #000;
}
.preview-content pre {
white-space: pre-wrap;
font-size: 14px;
background: var(--hover-background);
padding: 10px;
border-radius: 5px;
color: var(--text-primary);
}
.upload-dialog {
max-width: 500px;
}
.upload-info {
margin-top: 15px;
padding: 10px;
background: var(--hover-background);
border-radius: 5px;
}
.upload-info p {
margin: 5px 0;
font-size: 14px;
color: var(--text-primary);
}
.upload-progress {
margin-top: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.upload-progress progress {
width: 100%;
height: 20px;
}
.upload-progress span {
font-size: 14px;
color: var(--primary-color);
}
.upload-file-list {
margin-top: 10px;
padding-left: 20px;
max-height: 150px;
overflow-y: auto;
}
.upload-file-list li {
font-size: 13px;
padding: 3px 0;
color: var(--text-secondary);
}
.upload-multi-progress {
margin-top: 10px;
}
.upload-multi-progress p {
margin: 0 0 5px;
font-size: 13px;
color: var(--primary-color);
}
.upload-multi-progress progress {
width: 100%;
height: 16px;
margin-bottom: 3px;
}
.upload-multi-progress span {
font-size: 12px;
color: var(--text-secondary);
}
</style>