fix: m4mini deployment issues - duplicate script setup, auth redirect, Tauri bypass
This commit is contained in:
85
src/views/AdminView.vue
Normal file
85
src/views/AdminView.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div id="ms-view-admin">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'" class="users-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.name">
|
||||
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
</style>
|
||||
690
src/views/ClientView.vue
Normal file
690
src/views/ClientView.vue
Normal file
@@ -0,0 +1,690 @@
|
||||
<template>
|
||||
<div id="ms-view-client">
|
||||
<div class="client-header">
|
||||
<h1>File Manager</h1>
|
||||
<div class="client-toolbar">
|
||||
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
|
||||
<button @click="showCreateFolderDialog">New Folder</button>
|
||||
<button @click="showUploadDialog">Upload</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||
<button @click="refreshFiles">Refresh</button>
|
||||
<span class="current-path">Path: {{ currentPath }}</span>
|
||||
</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>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="files.length === 0">
|
||||
<td colspan="4" class="empty-folder">Empty folder</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="f in files"
|
||||
:key="f.name"
|
||||
@click="selectFile(f)"
|
||||
@dblclick="openFile(f)"
|
||||
:class="{ selected: selectedFile?.name === f.name }"
|
||||
>
|
||||
<td>
|
||||
<span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span>
|
||||
{{ f.name }}
|
||||
</td>
|
||||
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||
<td>{{ f.modified || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<!-- 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" />
|
||||
<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</h3>
|
||||
<input ref="fileInput" type="file" @change="selectUploadFile" />
|
||||
<div v-if="uploadFile" class="upload-info">
|
||||
<p>File: {{ uploadFile.name }}</p>
|
||||
<p>Size: {{ formatSize(uploadFile.size) }}</p>
|
||||
</div>
|
||||
<div v-if="uploadProgress > 0" class="upload-progress">
|
||||
<progress :value="uploadProgress" max="100"></progress>
|
||||
<span>{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="uploadFileToServer" :disabled="!uploadFile || uploading">
|
||||
{{ uploading ? 'Uploading...' : 'Upload' }}
|
||||
</button>
|
||||
<button @click="hideUploadDialog">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = 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 previewLoading = ref(false)
|
||||
const isImage = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const uploadProgress = ref(0)
|
||||
const uploading = ref(false)
|
||||
|
||||
// API base URL (MarkBase server)
|
||||
const API_BASE = 'http://localhost:11438/api/v2'
|
||||
|
||||
// Load files from current directory
|
||||
async function loadFiles() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
selectedFile.value = null
|
||||
|
||||
try {
|
||||
// Use MarkBase myfiles API
|
||||
const username = 'demo'
|
||||
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
|
||||
|
||||
let apiUrl = `${API_BASE}/myfiles/${username}/files`
|
||||
if (folderParam) {
|
||||
apiUrl += `?folder=${encodeURIComponent(folderParam)}`
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Transform files to file list (data is array, not { files: [] })
|
||||
if (Array.isArray(data)) {
|
||||
files.value = data.map((file: any) => ({
|
||||
name: file.name || file.filename,
|
||||
isDir: false, // All files in myfiles are files, not folders
|
||||
size: file.size || 0,
|
||||
modified: '-',
|
||||
uuid: null,
|
||||
tags: file.tags || [],
|
||||
})).sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
} else {
|
||||
files.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = 'Failed to load files: ' + e.message
|
||||
files.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 folderPath = currentPath.value === '/'
|
||||
? '/' + newFolderName.value
|
||||
: currentPath.value + '/' + newFolderName.value
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tree/demo/node`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
parent_id: currentPath.value === '/' ? null : currentPath.value,
|
||||
node_type: 'folder',
|
||||
label: newFolderName.value,
|
||||
}),
|
||||
})
|
||||
|
||||
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
|
||||
async function deleteSelected() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
const itemName = selectedFile.value.name
|
||||
if (!confirm(`Delete "${itemName}"?`)) return
|
||||
|
||||
const itemPath = currentPath.value === '/'
|
||||
? '/' + itemName
|
||||
: currentPath.value + '/' + itemName
|
||||
|
||||
try {
|
||||
// Find node_id from files list
|
||||
const node = files.value.find(f => f.name === itemName)
|
||||
if (!node || !node.uuid) {
|
||||
throw new Error('Node UUID not found')
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/tree/demo/node/${node.uuid}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
selectedFile.value = null
|
||||
loadFiles()
|
||||
} catch (e: any) {
|
||||
alert('Failed to delete: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh file list
|
||||
function refreshFiles() {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Show file preview
|
||||
async function showFilePreview(f: any) {
|
||||
showPreview.value = true
|
||||
previewLoading.value = true
|
||||
previewContent.value = ''
|
||||
isImage.value = false
|
||||
|
||||
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']
|
||||
|
||||
if (imageExtensions.includes(ext)) {
|
||||
// Image preview - use WebDAV with auth
|
||||
isImage.value = true
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
|
||||
// Use WebDAV endpoint with Basic auth
|
||||
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
previewContent.value = URL.createObjectURL(blob)
|
||||
} else {
|
||||
// Fallback: try without auth
|
||||
previewContent.value = `http://localhost:11438/webdav/${username}/${f.name}`
|
||||
}
|
||||
} else if (videoExtensions.includes(ext)) {
|
||||
// Video preview - show video element
|
||||
isImage.value = false
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
previewContent.value = `<video controls style="max-width:100%; max-height:60vh">
|
||||
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="video/${ext}">
|
||||
Your browser does not support video preview.
|
||||
</video>`
|
||||
} else if (audioExtensions.includes(ext)) {
|
||||
// Audio preview - show audio element
|
||||
isImage.value = false
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
previewContent.value = `<audio controls style="width:100%">
|
||||
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="audio/${ext}">
|
||||
Your browser does not support audio preview.
|
||||
</audio>`
|
||||
} else if (textExtensions.includes(ext)) {
|
||||
// Text preview - fetch and display content
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
const token = localStorage.getItem('token') || ''
|
||||
|
||||
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
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 {
|
||||
// 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 = ''
|
||||
}
|
||||
|
||||
// Show upload dialog
|
||||
function showUploadDialog() {
|
||||
uploadFile.value = null
|
||||
uploadProgress.value = 0
|
||||
uploading.value = false
|
||||
showUpload.value = true
|
||||
}
|
||||
|
||||
// Hide upload dialog
|
||||
function hideUploadDialog() {
|
||||
showUpload.value = false
|
||||
uploadFile.value = null
|
||||
uploadProgress.value = 0
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
// Select file for upload
|
||||
function selectUploadFile(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
uploadFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Upload file to server
|
||||
async function uploadFileToServer() {
|
||||
if (!uploadFile.value) {
|
||||
alert('No file selected')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
// Track upload progress
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
alert('Upload successful!')
|
||||
hideUploadDialog()
|
||||
loadFiles()
|
||||
} else {
|
||||
alert('Upload failed: ' + xhr.statusText)
|
||||
}
|
||||
uploading.value = false
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
alert('Upload failed: Network error')
|
||||
uploading.value = false
|
||||
})
|
||||
|
||||
// Upload to upload-unlimited endpoint
|
||||
xhr.open('POST', `${API_BASE}/upload-unlimited/demo`)
|
||||
xhr.send(formData)
|
||||
} catch (e: any) {
|
||||
alert('Upload failed: ' + e.message)
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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/demo/${selectedFile.value.uuid}/stream`
|
||||
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;
|
||||
}
|
||||
|
||||
.client-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.client-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.client-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.client-toolbar button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.client-toolbar button:hover:not(:disabled) {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.client-toolbar button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
margin-left: 20px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.client-content {
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.file-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.file-table th, .file-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-table tr.selected {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.file-table tr:hover {
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-folder {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.folder-icon, .file-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 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: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.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 pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-info p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.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: #667eea;
|
||||
}
|
||||
</style>
|
||||
182
src/views/LoginView.vue
Normal file
182
src/views/LoginView.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>Momentry Studio</h2>
|
||||
<p>Login to continue</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input v-model="username" type="text" required placeholder="Enter username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input v-model="password" type="password" required placeholder="Enter password" />
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="login-btn">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="success-message">Login successful!</div>
|
||||
</form>
|
||||
<div class="login-footer">
|
||||
<p>Default credentials: momentry / demo123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = false
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
localStorage.setItem('expires_at', data.expires_at)
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 1000)
|
||||
} else {
|
||||
error.value = data.error || 'Login failed'
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = 'Network error: ' + e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 10px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #27ae60;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<div class="ms-ppl-toolbar">
|
||||
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
|
||||
<option value="">全部檔案</option>
|
||||
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
|
||||
</select>
|
||||
<button class="ms-fm-btn ms-ppl-star-toggle-btn" @click="starFilter = !starFilter">
|
||||
<span class="ms-ppl-star-icon" :class="{ starred: starFilter }">★</span>
|
||||
<span>{{ starFilter ? '查看所有人物' : '查看重要人物' }}</span>
|
||||
@@ -22,7 +26,7 @@
|
||||
<div class="spinner-lg"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0 && unassignedTraces.length === 0" class="empty">
|
||||
No people found.
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -170,18 +174,18 @@
|
||||
|
||||
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
|
||||
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && unassignedTraces.length" class="ms-ppl-section">
|
||||
<div class="ms-ppl-section-toolbar">
|
||||
<div class="ms-ppl-section-title">待定人臉:</div>
|
||||
<div class="ms-ppl-section-title">待定人臉: <span class="ms-ppl-section-count">({{ unassignedTraces.length }})</span></div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-grid ms-uface-grid">
|
||||
<div v-for="c in faceCandidates.slice(0, 50)" :key="c.id" class="ms-ppl-face-card" @click="openAssignModal(c)" @contextmenu.prevent="showFaceCtxMenu($event, c)" v-observe="() => loadCandidateThumb(c)">
|
||||
<div v-for="t in unassignedTraces.slice(0, 100)" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" @click="openTraceAssignModal(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)" v-observe="() => loadTraceThumb(t)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
|
||||
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
||||
<img v-if="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" :src="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" alt="">
|
||||
<div v-else class="face-placeholder">{{ t.frame_count }}帧</div>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||||
<span class="ms-ppl-face-name">{{ (t.file_uuid || '').slice(0, 8) }}... T{{ String(t.trace_id ?? '').slice(-4) || '?' }} ({{ t.frame_count }}帧)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,7 +326,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, ensureFiles, filesCache, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, loadTraceThumb, invalidatePeople } from '@/store'
|
||||
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -344,6 +348,16 @@ const mergeTarget = ref('')
|
||||
const candidates = ref<any[]>([])
|
||||
const candidateThumbs = faceThumbsCache
|
||||
const faceCandidates = faceCandidatesCache
|
||||
// File filter
|
||||
const selectedFileUuid = ref('')
|
||||
|
||||
const unassignedTraces = computed(() => {
|
||||
const all = unassignedTracesCache.value
|
||||
if (!selectedFileUuid.value) return all
|
||||
const filtered = all.filter((t: any) => t.file_uuid === selectedFileUuid.value)
|
||||
return filtered
|
||||
})
|
||||
|
||||
const traceCounts = ref<Record<string, number>>({})
|
||||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
|
||||
@@ -351,6 +365,13 @@ const assignModal = ref({ show: false, candidate: null as any })
|
||||
const assignSearchQuery = ref('')
|
||||
const assignSelected = ref<any>(null)
|
||||
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
|
||||
const fileFilterList = computed(() => {
|
||||
const files = filesCache.value
|
||||
return files.filter((f: any) => f.isRegistered && f.status === 'completed').map((f: any) => ({
|
||||
file_uuid: f.file_uuid?.replace(/-/g, ''),
|
||||
file_name: f.file_name
|
||||
}))
|
||||
})
|
||||
|
||||
// Section visibility toggles
|
||||
const showPending = ref(true)
|
||||
@@ -418,9 +439,11 @@ const confirmedPeople = computed(() => {
|
||||
|
||||
const pendingPeople = computed(() => {
|
||||
let base = people.value.filter((p: any) => p.status === 'pending')
|
||||
if (selectedFileUuid.value) {
|
||||
base = base.filter((p: any) => (p.file_uuids || []).includes(selectedFileUuid.value))
|
||||
}
|
||||
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
|
||||
// Sort by trace count (descending) - more traces = more suggestions
|
||||
const sorted = [...base].sort((a: any, b: any) => {
|
||||
const aCount = traceCounts.value[a.identity_uuid] || 0
|
||||
const bCount = traceCounts.value[b.identity_uuid] || 0
|
||||
@@ -445,9 +468,14 @@ const assignSearchResults = computed(() => {
|
||||
|
||||
async function refresh() {
|
||||
await ensurePeople()
|
||||
await ensureFiles()
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
}
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
await ensureFaceCandidates()
|
||||
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||
@@ -468,6 +496,22 @@ async function refresh() {
|
||||
traceCounts.value = counts
|
||||
}
|
||||
|
||||
async function onFileFilterChange() {
|
||||
if (selectedFileUuid.value) {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces(selectedFileUuid.value)
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
} else {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
document.addEventListener('click', closeCtxMenu)
|
||||
@@ -724,28 +768,22 @@ async function playFaceDetailVideo() {
|
||||
async function createNewIdentityFromFace() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
const name = faceDetailModal.value.newIdentityName.trim()
|
||||
console.log('[createNewIdentityFromFace] candidate:', c, 'name:', name)
|
||||
if (!c || !name) {
|
||||
console.log('[createNewIdentityFromFace] missing candidate or name')
|
||||
return
|
||||
}
|
||||
try {
|
||||
console.log('[createNewIdentityFromFace] calling API with fileUuid:', c.file_uuid)
|
||||
const result: any = await apiCall('create_identity_from_face', {
|
||||
name,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
console.log('[createNewIdentityFromFace] result:', result)
|
||||
if (result?.identity_uuid) {
|
||||
faceDetailModal.value.show = false
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
// Remove hyphens from uuid for consistent format
|
||||
const cleanUuid = result.identity_uuid.replace(/-/g, '')
|
||||
router.push(`/people/${cleanUuid}`)
|
||||
} else {
|
||||
console.log('[createNewIdentityFromFace] no identity_uuid in result')
|
||||
alert('新增人物失敗:API 未返回 identity_uuid')
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -761,6 +799,37 @@ function openAssignModal(c: any) {
|
||||
loadCandidateThumb(c)
|
||||
}
|
||||
|
||||
function openTraceAssignModal(t: any) {
|
||||
// Convert trace to candidate-like object for the assign modal
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
assignModal.value = { show: true, candidate: candidateLike }
|
||||
assignSearchQuery.value = ''
|
||||
assignSelected.value = null
|
||||
loadTraceThumb(t)
|
||||
}
|
||||
|
||||
function showTraceCtxMenu(e: MouseEvent, t: any) {
|
||||
e.preventDefault()
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
|
||||
}
|
||||
|
||||
async function confirmAssign() {
|
||||
const c = assignModal.value.candidate
|
||||
if (!c || !assignSelected.value) return
|
||||
@@ -771,7 +840,12 @@ async function confirmAssign() {
|
||||
faceRowId: c.face_id ? undefined : c.id,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
if (c.trace_id) {
|
||||
// Remove from unassigned traces if it's a trace
|
||||
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id)
|
||||
} else {
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
}
|
||||
assignModal.value.show = false
|
||||
} catch (e) {
|
||||
console.error('Bind failed:', e)
|
||||
@@ -790,6 +864,8 @@ h1 { margin: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.ms-ppl-file-select { padding: 6px 16px; border: 1.5px solid #9aa0a6; border-radius: 10px; background: #fff; font-size: 13px; font-family: inherit; color: #202124; cursor: pointer; outline: none; min-width: 140px; }
|
||||
.ms-ppl-file-select:focus { border-color: #202124; }
|
||||
.ms-ppl-star-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #202124; transition: border-color .15s; }
|
||||
.ms-ppl-star-toggle-btn:hover { border-color: #202124; }
|
||||
.ms-ppl-star-icon { font-size: 16px; color: #d1d5db; transition: color .15s; }
|
||||
|
||||
Reference in New Issue
Block a user