minmal-file-cloud-email-pim.../frontend/src/views/FilesView.vue

757 lines
26 KiB
Vue

<template>
<div class="view-container"
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave"
@drop.prevent="onDrop">
<!-- Drop overlay -->
<div v-if="isDragging" class="drop-overlay">
<div class="drop-content">
<i class="pi pi-cloud-upload"></i>
<p>Dateien oder Ordner hier ablegen</p>
</div>
</div>
<div class="view-header">
<div class="breadcrumb">
<a @click="navigateTo(null)" class="crumb">Dateien</a>
<template v-for="item in filesStore.breadcrumb" :key="item.id">
<i class="pi pi-angle-right crumb-sep"></i>
<a @click="navigateTo(item.id)" class="crumb">{{ item.name }}</a>
</template>
</div>
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-upload" label="Dateien" size="small" @click="triggerUpload" />
<Button icon="pi pi-folder" label="Ordner" size="small" outlined @click="triggerFolderUpload" />
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
<input ref="folderInput" type="file" hidden webkitdirectory @change="handleFolderUpload" />
</div>
</div>
<!-- Upload progress -->
<div v-if="uploadQueue.length" class="upload-progress">
<div class="upload-info">
<i class="pi pi-spin pi-spinner"></i>
<span>{{ uploadCurrent }} / {{ uploadTotal }} Dateien hochgeladen</span>
</div>
<ProgressBar :value="uploadPercent" />
</div>
<DataTable
:value="filesStore.files"
:loading="filesStore.loading"
@row-dblclick="handleDoubleClick"
striped-rows
removable-sort
class="files-table"
>
<template #empty>
<div class="empty-state">
<i class="pi pi-folder-open" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Dieser Ordner ist leer</p>
</div>
</template>
<Column field="name" header="Name" sortable style="min-width: 300px">
<template #body="{ data }">
<div class="file-name" @click="data.is_folder && navigateTo(data.id)">
<i :class="fileIcon(data)" class="file-icon"></i>
<span>{{ data.name }}</span>
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
<span v-if="data.locked" class="lock-badge" :title="'Ausgecheckt von ' + data.locked_by + ' seit ' + formatDate(data.locked_at)">
<i class="pi pi-lock"></i> {{ data.locked_by }}
</span>
</div>
</template>
</Column>
<Column field="size" header="Groesse" sortable style="width: 120px">
<template #body="{ data }">
{{ data.is_folder ? '' : formatSize(data.size) }}
</template>
</Column>
<Column field="updated_at" header="Geaendert" sortable style="width: 180px">
<template #body="{ data }">
{{ formatDate(data.updated_at) }}
</template>
</Column>
<Column header="" style="width: 180px">
<template #body="{ data }">
<div class="row-actions">
<Button
v-if="!data.is_folder"
icon="pi pi-eye"
text rounded size="small"
title="Vorschau"
@click.stop="openPreview(data)"
/>
<Button
:icon="data.is_folder ? 'pi pi-box' : 'pi pi-download'"
text rounded size="small"
:title="data.is_folder ? 'Als ZIP herunterladen' : 'Herunterladen'"
@click.stop="downloadFile(data)"
/>
<Button
:icon="(data.has_shares || data.has_permissions) ? 'pi pi-users' : 'pi pi-share-alt'"
text rounded size="small"
:severity="(data.has_shares || data.has_permissions) ? 'success' : undefined"
:title="(data.has_shares || data.has_permissions) ? 'Freigaben verwalten' : 'Teilen'"
@click.stop="openShare(data)"
/>
<Button
icon="pi pi-pencil"
text rounded size="small"
@click.stop="openRename(data)"
/>
<Button
icon="pi pi-trash"
text rounded size="small"
severity="danger"
@click.stop="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
<!-- New Folder Dialog -->
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
<div class="field">
<label>Ordnername</label>
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewFolder = false" />
<Button label="Erstellen" @click="createFolder" />
</template>
</Dialog>
<!-- Rename Dialog -->
<Dialog v-model:visible="showRename" header="Umbenennen" modal :style="{ width: '400px' }">
<div class="field">
<label>Neuer Name</label>
<InputText v-model="renameName" fluid autofocus @keyup.enter="doRename" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showRename = false" />
<Button label="Umbenennen" @click="doRename" />
</template>
</Dialog>
<!-- Share Dialog -->
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '550px' }">
<div v-if="shareFile" class="share-content">
<h4>{{ shareFile.name }}</h4>
<!-- User Sharing -->
<div class="share-section">
<h5>Mit Benutzer teilen</h5>
<div class="user-share-row">
<InputText v-model="shareUserQuery" placeholder="Benutzername suchen..." fluid @input="searchUsers" />
<Select v-model="shareUserPermission" :options="userPermOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareWithUser" :disabled="!selectedShareUser" />
</div>
<div v-if="userSearchResults.length" class="user-search-results">
<div v-for="u in userSearchResults" :key="u.id"
class="user-result" :class="{ selected: selectedShareUser?.id === u.id }"
@click="selectedShareUser = u; shareUserQuery = u.username; userSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
<div v-if="filePermissions.length" class="existing-shares">
<div v-for="perm in filePermissions" :key="perm.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ perm.username }}</span>
<Tag :value="permLabel(perm.permission)" size="small" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeUserShare(perm.id)" />
</div>
</div>
</div>
<!-- Link Sharing -->
<div class="share-section">
<h5>Freigabe-Link erstellen</h5>
<div class="share-form">
<div class="field">
<label>Berechtigung</label>
<Select v-model="shareLinkPermission" :options="linkPermOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
</div>
<div v-if="shareLinks.length" class="share-section">
<h5>Bestehende Links</h5>
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
<div class="link-info">
<code>{{ currentOrigin }}/share/{{ link.token }}</code>
<small>
{{ {read: 'Nur Lesen', write: 'Lesen+Schreiben', upload_only: 'Nur Upload'}[link.permission] || link.permission }}
| {{ link.download_count }} Downloads
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
</small>
</div>
<div class="link-actions">
<Button icon="pi pi-copy" text size="small" @click="copyLink(link.token)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(link.token)" />
</div>
</div>
</div>
</div>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Loeschen bestaetigen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich loeschen?</p>
<p v-if="deleteTarget?.is_folder" class="text-warn">Alle Dateien in diesem Ordner werden ebenfalls geloescht!</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDelete" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useFilesStore } from '../stores/files'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Tag from 'primevue/tag'
import Select from 'primevue/select'
import ProgressBar from 'primevue/progressbar'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const filesStore = useFilesStore()
const toast = useToast()
const fileInput = ref(null)
const folderInput = ref(null)
const isDragging = ref(false)
const uploadQueue = ref([])
const uploadCurrent = ref(0)
const uploadTotal = ref(0)
const uploadPercent = ref(0)
const showNewFolder = ref(false)
const newFolderName = ref('')
const showRename = ref(false)
const renameName = ref('')
const renameTarget = ref(null)
const showShare = ref(false)
const shareFile = ref(null)
const sharePassword = ref('')
const shareExpiry = ref('')
const shareLinks = ref([])
const filePermissions = ref([])
const shareUserQuery = ref('')
const selectedShareUser = ref(null)
const shareUserPermission = ref('read')
const userSearchResults = ref([])
const userPermOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Schreiben', value: 'write' }, { label: 'Admin', value: 'admin' }]
const linkPermOptions = [
{ label: 'Nur Lesen (Download)', value: 'read' },
{ label: 'Lesen + Hochladen (nur Ordner)', value: 'write' },
{ label: 'Nur Upload (Ordner, kein Einblick)', value: 'upload_only' },
]
const shareLinkPermission = ref('read')
const currentOrigin = window.location.origin
const shareLoading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
function currentParentId() {
const id = route.params.folderId
return id ? parseInt(id) : null
}
function navigateTo(folderId) {
if (folderId) {
router.push(`/files/${folderId}`)
} else {
router.push('/files')
}
}
function handleDoubleClick(event) {
const data = event.data
if (data.is_folder) {
navigateTo(data.id)
} else {
openPreview(data)
}
}
function openPreview(data) {
if (data.locked && data.locked_by !== auth.user?.username) {
toast.add({
severity: 'warn',
summary: 'Datei gesperrt',
detail: `${data.name} wird von ${data.locked_by} bearbeitet. Oeffnen nicht moeglich.`,
life: 5000,
})
return
}
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp|odt|ods|odp|rtf)$/i
if (previewable.test(data.name)) {
router.push(`/preview/${data.id}`)
} else {
downloadFile(data)
}
}
function fileIcon(data) {
if (data.is_folder) return 'pi pi-folder'
const mime = data.mime_type || ''
if (mime.startsWith('image/')) return 'pi pi-image'
if (mime.startsWith('video/')) return 'pi pi-video'
if (mime.startsWith('audio/')) return 'pi pi-volume-up'
if (mime.includes('pdf')) return 'pi pi-file-pdf'
if (mime.includes('word') || mime.includes('document')) return 'pi pi-file-word'
if (mime.includes('sheet') || mime.includes('excel')) return 'pi pi-file-excel'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'pi pi-file'
return 'pi pi-file'
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function triggerUpload() {
fileInput.value?.click()
}
function triggerFolderUpload() {
folderInput.value?.click()
}
async function handleUpload(event) {
const files = Array.from(event.target.files)
if (!files.length) return
await uploadFilesWithProgress(files.map(f => ({ file: f, relativePath: null })))
event.target.value = ''
}
async function handleFolderUpload(event) {
const files = Array.from(event.target.files)
if (!files.length) return
// webkitRelativePath gives us "FolderName/sub/file.txt"
const items = files.map(f => ({
file: f,
relativePath: f.webkitRelativePath || null,
}))
await uploadFilesWithProgress(items)
event.target.value = ''
}
// --- Drag & Drop ---
let dragCounter = 0
function onDragOver(e) {
isDragging.value = true
}
function onDragLeave(e) {
dragCounter--
if (dragCounter <= 0) {
isDragging.value = false
dragCounter = 0
}
}
async function onDrop(e) {
isDragging.value = false
dragCounter = 0
const items = []
if (e.dataTransfer.items) {
// Use DataTransferItem API for directory support
const entries = []
for (const item of e.dataTransfer.items) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.() || item.getAsEntry?.()
if (entry) {
entries.push(entry)
}
}
}
// Recursively read all entries
for (const entry of entries) {
await readEntry(entry, '', items)
}
} else {
// Fallback: flat file list
for (const file of e.dataTransfer.files) {
items.push({ file, relativePath: null })
}
}
if (items.length) {
await uploadFilesWithProgress(items)
}
}
async function readEntry(entry, parentPath, items) {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject))
const relativePath = parentPath ? `${parentPath}/${entry.name}` : null
items.push({ file, relativePath })
} else if (entry.isDirectory) {
const dirPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
const reader = entry.createReader()
// readEntries may need to be called multiple times
let allEntries = []
const readBatch = () => new Promise((resolve, reject) => reader.readEntries(resolve, reject))
let batch
do {
batch = await readBatch()
allEntries = allEntries.concat(batch)
} while (batch.length > 0)
for (const child of allEntries) {
await readEntry(child, dirPath, items)
}
}
}
// --- Upload with progress + folder creation ---
async function uploadFilesWithProgress(items) {
uploadTotal.value = items.length
uploadCurrent.value = 0
uploadPercent.value = 0
uploadQueue.value = items
// Group by directory path -> create folders first
const pathCache = {} // "dir/subdir" -> folder_id
let errors = 0
for (const item of items) {
try {
let targetParentId = currentParentId()
if (item.relativePath) {
// Extract directory part: "Folder/Sub/file.txt" -> "Folder/Sub"
const parts = item.relativePath.split('/')
parts.pop() // remove filename
const dirPath = parts.join('/')
if (dirPath && !pathCache[dirPath]) {
const res = await apiClient.post('/files/ensure-path', {
path: dirPath,
parent_id: currentParentId(),
})
pathCache[dirPath] = res.data.folder_id
// Also cache parent paths
for (let i = 1; i < parts.length; i++) {
const subPath = parts.slice(0, i).join('/')
if (!pathCache[subPath]) {
const subRes = await apiClient.post('/files/ensure-path', {
path: subPath,
parent_id: currentParentId(),
})
pathCache[subPath] = subRes.data.folder_id
}
}
}
if (dirPath) {
targetParentId = pathCache[dirPath]
}
}
await filesStore.uploadFile(item.file, targetParentId)
} catch (err) {
errors++
}
uploadCurrent.value++
uploadPercent.value = Math.round((uploadCurrent.value / uploadTotal.value) * 100)
}
uploadQueue.value = []
if (errors > 0) {
toast.add({ severity: 'warn', summary: `${uploadCurrent.value - errors} hochgeladen, ${errors} Fehler`, life: 5000 })
} else {
toast.add({ severity: 'success', summary: `${uploadCurrent.value} Dateien hochgeladen`, life: 3000 })
}
await filesStore.loadFiles(currentParentId())
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
await filesStore.createFolder(newFolderName.value.trim(), currentParentId())
showNewFolder.value = false
newFolderName.value = ''
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function downloadFile(data) {
window.location.href = filesStore.downloadUrl(data.id)
}
function openRename(data) {
renameTarget.value = data
renameName.value = data.name
showRename.value = true
}
async function doRename() {
if (!renameName.value.trim() || !renameTarget.value) return
try {
await filesStore.renameFile(renameTarget.value.id, renameName.value.trim())
showRename.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function permLabel(perm) {
return { read: 'Lesen', write: 'Schreiben', admin: 'Admin' }[perm] || perm
}
async function openShare(data) {
shareFile.value = data
sharePassword.value = ''
shareExpiry.value = ''
shareUserQuery.value = ''
selectedShareUser.value = null
userSearchResults.value = []
showShare.value = true
// Load existing links and permissions in parallel
try {
const [links, perms] = await Promise.all([
filesStore.getShareLinks(data.id),
apiClient.get(`/files/${data.id}/permissions`).then(r => r.data).catch(() => []),
])
shareLinks.value = links
filePermissions.value = perms
} catch {
shareLinks.value = []
filePermissions.value = []
}
}
let searchTimeout = null
function searchUsers() {
selectedShareUser.value = null
clearTimeout(searchTimeout)
if (shareUserQuery.value.length < 2) { userSearchResults.value = []; return }
searchTimeout = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q: shareUserQuery.value } })
userSearchResults.value = res.data
} catch { userSearchResults.value = [] }
}, 300)
}
async function shareWithUser() {
if (!selectedShareUser.value || !shareFile.value) return
try {
await apiClient.post(`/files/${shareFile.value.id}/permissions`, {
user_id: selectedShareUser.value.id,
permission: shareUserPermission.value,
})
toast.add({ severity: 'success', summary: `Mit ${selectedShareUser.value.username} geteilt`, life: 3000 })
shareUserQuery.value = ''
selectedShareUser.value = null
const res = await apiClient.get(`/files/${shareFile.value.id}/permissions`)
filePermissions.value = res.data
await filesStore.loadFiles(currentParentId())
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function removeUserShare(permId) {
if (!shareFile.value) return
try {
await apiClient.delete(`/files/${shareFile.value.id}/permissions/${permId}`)
filePermissions.value = filePermissions.value.filter(p => p.id !== permId)
await filesStore.loadFiles(currentParentId())
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function createShare() {
console.log('createShare called, shareFile:', shareFile.value?.id, 'permission:', shareLinkPermission.value)
if (!shareFile.value) {
console.error('shareFile is null')
return
}
shareLoading.value = true
try {
const opts = { permission: shareLinkPermission.value }
if (sharePassword.value) opts.password = sharePassword.value
if (shareExpiry.value) opts.expires_at = shareExpiry.value
console.log('Creating share link with opts:', opts)
await filesStore.createShareLink(shareFile.value.id, opts)
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
sharePassword.value = ''
shareExpiry.value = ''
shareLinkPermission.value = 'read'
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
await filesStore.loadFiles(currentParentId())
} catch (err) {
console.error('createShare error:', err)
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error || String(err), life: 5000 })
} finally {
shareLoading.value = false
}
}
function copyLink(token) {
navigator.clipboard.writeText(`${window.location.origin}/share/${token}`)
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
}
async function removeShare(token) {
try {
await filesStore.deleteShareLink(token)
shareLinks.value = shareLinks.value.filter(l => l.token !== token)
await filesStore.loadFiles(currentParentId())
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function confirmDelete(data) {
deleteTarget.value = data
showDeleteConfirm.value = true
}
async function doDelete() {
if (!deleteTarget.value) return
try {
await filesStore.deleteFile(deleteTarget.value.id)
showDeleteConfirm.value = false
toast.add({ severity: 'success', summary: 'Geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
watch(() => route.params.folderId, () => {
filesStore.loadFiles(currentParentId())
})
onMounted(() => {
filesStore.loadFiles(currentParentId())
})
</script>
<style scoped>
.view-container { padding: 1.5rem; position: relative; min-height: 400px; }
.drop-overlay {
position: absolute; inset: 0; z-index: 100;
background: rgba(var(--p-primary-500-rgb, 59, 130, 246), 0.08);
border: 3px dashed var(--p-primary-color);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
.drop-content { text-align: center; color: var(--p-primary-color); }
.drop-content i { font-size: 3rem; }
.drop-content p { font-size: 1.1rem; font-weight: 500; margin: 0.5rem 0 0; }
.upload-progress {
background: var(--p-surface-0); border-radius: 8px; padding: 0.75rem 1rem;
margin-bottom: 1rem; border: 1px solid var(--p-surface-200);
}
.upload-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.875rem; }
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; }
.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; }
.crumb:hover { text-decoration: underline; }
.crumb-sep { font-size: 0.75rem; color: var(--p-surface-400); }
.header-actions { display: flex; gap: 0.5rem; }
.file-name {
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
}
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
.shared-tag { font-size: 0.7rem; }
.lock-badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.7rem; color: var(--p-orange-600); background: var(--p-orange-50);
padding: 0.125rem 0.375rem; border-radius: 4px; margin-left: 0.25rem;
}
.lock-badge i { font-size: 0.65rem; }
.row-actions { display: flex; gap: 0; }
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color);
}
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.share-content h4 { margin: 0 0 1rem; }
.share-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--p-surface-200); }
.share-section:last-child { border-bottom: none; }
.share-section h5 { margin: 0 0 0.75rem; font-size: 0.9rem; }
.share-form { }
.user-share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
.user-search-results { border: 1px solid var(--p-surface-200); border-radius: 6px; margin-top: 0.25rem; max-height: 150px; overflow-y: auto; }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.user-result:hover, .user-result.selected { background: var(--p-primary-50); }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.share-link-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.link-info { display: flex; flex-direction: column; gap: 0.25rem; }
.link-info code { font-size: 0.8rem; word-break: break-all; }
.link-info small { color: var(--p-text-muted-color); }
.link-actions { display: flex; }
.text-warn { color: var(--p-orange-500); font-size: 0.875rem; }
</style>