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
1308 lines
36 KiB
Vue
1308 lines
36 KiB
Vue
<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> |