Files
personal_development/ServerManagerWebApp/frontend/src/components/SambaExplorer.vue
T

421 lines
13 KiB
Vue

<template>
<v-container fluid>
<v-card class="mb-4">
<v-toolbar color="surface" density="compact">
<v-toolbar-title class="text-h6">
<v-icon start>mdi-folder-network</v-icon>
Samba Explorer
</v-toolbar-title>
<v-spacer></v-spacer>
<div class="d-flex align-center px-4" style="min-width: 200px;">
<v-select
v-model="shareName"
:items="availableShares"
label="Share"
hide-details
density="compact"
variant="outlined"
@update:model-value="resetAndFetch"
prepend-inner-icon="mdi-server"
></v-select>
</div>
<v-divider vertical class="mx-2"></v-divider>
<v-btn icon="mdi-upload" variant="text" @click="uploadDialog = true" title="Upload File"></v-btn>
<v-btn icon="mdi-content-paste" variant="text" @click="pasteItem" :disabled="!clipboard" title="Paste"></v-btn>
<v-chip v-if="clipboard" size="small" color="primary" closable @click:close="clipboard = null" class="ml-2">
{{ clipboard.action === 'cut' ? 'Cut' : 'Copy' }}: {{ clipboard.name }}
</v-chip>
</v-toolbar>
<v-divider></v-divider>
<v-card-text class="py-2">
<div class="d-flex align-center">
<v-btn icon="mdi-arrow-up" variant="text" @click="goBack" :disabled="currentPath === '/'"></v-btn>
<v-breadcrumbs :items="breadcrumbs" density="compact">
<template v-slot:divider>
<v-icon icon="mdi-chevron-right"></v-icon>
</template>
<template v-slot:item="{ item }">
<v-breadcrumbs-item
:title="item.title"
:disabled="item.disabled"
@click="!item.disabled && navigateToBreadcrumb(item.path)"
:class="{'cursor-pointer text-primary': !item.disabled}"
></v-breadcrumbs-item>
</template>
</v-breadcrumbs>
</div>
</v-card-text>
</v-card>
<v-card :loading="loading">
<template v-slot:loader="{ isActive }">
<v-progress-linear
:active="isActive"
color="primary"
height="4"
indeterminate
></v-progress-linear>
</template>
<v-data-table
:headers="headers"
:items="files"
:items-per-page="50"
density="compact"
hover
@click:row="handleRowClick"
>
<!-- Icon Column -->
<template v-slot:item.icon="{ item }">
<v-icon :color="item.is_directory ? 'amber' : 'blue'">
{{ item.is_directory ? 'mdi-folder' : 'mdi-file' }}
</v-icon>
</template>
<!-- Name Column -->
<template v-slot:item.name="{ item }">
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<!-- Actions Column -->
<template v-slot:item.actions="{ item }">
<div class="d-flex justify-end">
<v-btn
v-if="!item.is_directory"
icon="mdi-eye"
variant="text"
size="small"
color="info"
@click.stop="viewFile(item.path)"
title="View"
></v-btn>
<v-btn
v-if="!item.is_directory"
icon="mdi-download"
variant="text"
size="small"
color="success"
@click.stop="downloadFile(item.path)"
title="Download"
></v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="props" @click.stop></v-btn>
</template>
<v-list density="compact">
<v-list-item @click="renameItem(item.path, item.name)">
<template v-slot:prepend>
<v-icon icon="mdi-pencil" size="small"></v-icon>
</template>
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item @click="cutItem(item)">
<template v-slot:prepend>
<v-icon icon="mdi-content-cut" size="small"></v-icon>
</template>
<v-list-item-title>Cut</v-list-item-title>
</v-list-item>
<v-list-item @click="copyItem(item)">
<template v-slot:prepend>
<v-icon icon="mdi-content-copy" size="small"></v-icon>
</template>
<v-list-item-title>Copy</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteItem(item.path)" color="error">
<template v-slot:prepend>
<v-icon icon="mdi-delete" size="small" color="error"></v-icon>
</template>
<v-list-item-title class="text-error">Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
</v-data-table>
</v-card>
<!-- Upload Fab removed (moved to toolbar) -->
<!-- Upload Dialog -->
<v-dialog v-model="uploadDialog" max-width="500">
<v-card>
<v-card-title>Upload File</v-card-title>
<v-card-text>
<v-file-input
v-model="fileToUpload"
label="Select File"
prepend-icon="mdi-paperclip"
variant="outlined"
></v-file-input>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="uploadDialog = false">Cancel</v-btn>
<v-btn color="primary" variant="text" @click="uploadFile" :disabled="!fileToUpload">Upload</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Viewer Component -->
<FileViewer
v-if="fileToViewPath"
v-model="viewerDialog"
:file-path="fileToViewPath"
:share-name="shareName"
@close="closeViewer"
/>
<!-- Global Snackbar -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="3000">
{{ snackbar.text }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false">Close</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import FileViewer from './FileViewer.vue';
const availableShares = ref(['media', 'isolation']);
const shareName = ref(availableShares.value[0]);
const currentPath = ref('/');
const files = ref([]);
const loading = ref(false);
// Upload
const uploadDialog = ref(false);
const fileToUpload = ref(null);
// Viewer
const viewerDialog = ref(false);
const fileToViewPath = ref(null);
// Clipboard
const clipboard = ref(null); // { path: str, name: str, action: 'cut'|'copy' }
// Feedback
const snackbar = ref({ show: false, text: '', color: 'info' });
const headers = [
{ title: '', key: 'icon', sortable: false, width: '40px' },
{ title: 'Name', key: 'name' },
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' },
];
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean);
const items = [{ title: 'Root', disabled: currentPath.value === '/', path: '/' }];
let accumulatedPath = '';
parts.forEach((part, index) => {
accumulatedPath += `/${part}`;
items.push({
title: part,
disabled: index === parts.length - 1,
path: accumulatedPath
});
});
return items;
});
const showMsg = (text, color = 'success') => {
snackbar.value = { show: true, text, color };
};
const fetchFiles = async () => {
loading.value = true;
try {
const response = await fetch(`/api/samba/list/${shareName.value}?path=${currentPath.value}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
files.value = await response.json();
} catch (error) {
showMsg('Error fetching files: ' + error.message, 'error');
files.value = [];
} finally {
loading.value = false;
}
};
const resetAndFetch = () => {
currentPath.value = '/';
fetchFiles();
};
const navigateToBreadcrumb = (path) => {
currentPath.value = path;
fetchFiles();
};
const handleRowClick = (event, { item }) => {
if (item.is_directory) {
currentPath.value = item.path;
fetchFiles();
} else {
viewFile(item.path);
}
};
const goBack = () => {
const parts = currentPath.value.split('/').filter(Boolean);
if (parts.length > 0) {
currentPath.value = '/' + parts.slice(0, -1).join('/') + (parts.length > 1 ? '/' : '');
} else {
currentPath.value = '/';
}
fetchFiles();
};
const viewFile = (filePath) => {
fileToViewPath.value = filePath;
viewerDialog.value = true;
};
const closeViewer = () => {
viewerDialog.value = false;
setTimeout(() => { fileToViewPath.value = null; }, 300); // clear after animation
};
const downloadFile = async (filePath) => {
try {
const response = await fetch(`/api/samba/files/download/${shareName.value}?path=${filePath}`);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filePath.split('/').pop();
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
showMsg('Download started');
} catch (error) {
showMsg('Error downloading file: ' + error.message, 'error');
}
};
const deleteItem = async (itemPath) => {
if (!confirm(`Are you sure you want to delete ${itemPath}?`)) return;
try {
const response = await fetch(`/api/samba/files/${shareName.value}?path=${itemPath}`, {
method: 'DELETE',
});
if (response.ok) {
showMsg('Item deleted');
fetchFiles();
} else {
showMsg('Error deleting item', 'error');
}
} catch (error) {
showMsg('Error deleting item: ' + error.message, 'error');
}
};
const renameItem = async (oldPath, oldName, newPathArg = null) => {
let newPath = newPathArg;
if (!newPath) {
const newName = prompt(`Enter new name for ${oldName}:`, oldName);
if (!newName || newName === oldName) return;
newPath = currentPath.value === '/' ? `/${newName}` : `${currentPath.value.replace(/\/$/, '')}/${newName}`;
}
try {
const response = await fetch(`/api/samba/files/rename/${shareName.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
});
if (response.ok) {
showMsg('Renamed successfully');
fetchFiles();
} else {
showMsg('Error renaming item', 'error');
}
} catch (error) {
showMsg('Error renaming item: ' + error.message, 'error');
}
};
const cutItem = (item) => {
clipboard.value = { path: item.path, name: item.name, action: 'cut' };
showMsg(`Cut ${item.name}`);
};
const copyItem = (item) => {
clipboard.value = { path: item.path, name: item.name, action: 'copy' };
showMsg(`Copied ${item.name} (Note: Copy may be slow as it downloads/uploads)`);
};
const pasteItem = async () => {
if (!clipboard.value) return;
const destDir = currentPath.value === '/' ? '' : currentPath.value.replace(/\/$/, '');
const destPath = `${destDir}/${clipboard.value.name}`;
if (clipboard.value.action === 'cut') {
// Rename (Move)
await renameItem(clipboard.value.path, clipboard.value.name, destPath);
clipboard.value = null; // Clear after cut-paste
} else {
// Copy - Not implemented natively in backend yet, handled as Move for now or Warning?
// User requirement said "add buttons...". Implementing Copy properly requires backend support.
// For now, we will show a message if it's not supported, or try rename if same share?
// Rename is Move. Copy needs Read+Write.
// I'll show error for now or implement backend copy.
showMsg('Copy not fully implemented in backend yet. Use Cut/Move.', 'warning');
}
};
const uploadFile = async () => {
if (!fileToUpload.value) return;
const formData = new FormData();
formData.append('file', fileToUpload.value);
loading.value = true;
try {
const response = await fetch(`/api/samba/files/upload/${shareName.value}?path=${currentPath.value}`, {
method: 'POST',
body: formData,
});
if (response.ok) {
showMsg('File uploaded successfully');
fileToUpload.value = null;
uploadDialog.value = false;
fetchFiles();
} else {
showMsg('Error uploading file', 'error');
}
} catch (error) {
showMsg('Error uploading file: ' + error.message, 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchFiles();
});
watch(shareName, () => {
resetAndFetch();
});
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>