Major overhaul to the theme and now functioning.
This commit is contained in:
@@ -1,61 +1,247 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>Samba File Explorer</h2>
|
||||
<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>
|
||||
|
||||
<label for="share-select">Select Share:</label>
|
||||
<select id="share-select" v-model="shareName" @change="resetAndFetch">
|
||||
<option v-for="share in availableShares" :key="share" :value="share">{{ share }}</option>
|
||||
</select>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<p>Current Path: {{ shareName }}:{{ currentPath }}</p>
|
||||
<ul>
|
||||
<li v-for="item in files" :key="item.name">
|
||||
<span @click="item.is_directory ? navigate(item) : viewFile(item.path)">
|
||||
{{ item.name }} {{ item.is_directory ? '/' : '' }}
|
||||
</span>
|
||||
<button v-if="!item.is_directory" @click="viewFile(item.path)">View</button>
|
||||
<button v-if="!item.is_directory" @click="downloadFile(item.path)">Download</button>
|
||||
<button @click="renameItem(item.path, item.name, item.is_directory)">Rename</button>
|
||||
<button @click="deleteItem(item.path, item.is_directory)">Delete</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="goBack" :disabled="currentPath === '/'">Go Back</button>
|
||||
<form @submit.prevent="uploadFile">
|
||||
<input type="file" @change="handleFileUpload" />
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
<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="fileToView"
|
||||
:file-path="fileToView"
|
||||
v-if="fileToViewPath"
|
||||
v-model="viewerDialog"
|
||||
:file-path="fileToViewPath"
|
||||
:share-name="shareName"
|
||||
@close="closeViewer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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 } from 'vue';
|
||||
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);
|
||||
const fileToView = 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}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
files.value = data;
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
files.value = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error);
|
||||
showMsg('Error fetching files: ' + error.message, 'error');
|
||||
files.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,10 +250,17 @@ const resetAndFetch = () => {
|
||||
fetchFiles();
|
||||
};
|
||||
|
||||
const navigate = (item) => {
|
||||
const navigateToBreadcrumb = (path) => {
|
||||
currentPath.value = path;
|
||||
fetchFiles();
|
||||
};
|
||||
|
||||
const handleRowClick = (event, { item }) => {
|
||||
if (item.is_directory) {
|
||||
currentPath.value = item.path;
|
||||
fetchFiles();
|
||||
} else {
|
||||
viewFile(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,11 +275,13 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
const viewFile = (filePath) => {
|
||||
fileToView.value = filePath;
|
||||
fileToViewPath.value = filePath;
|
||||
viewerDialog.value = true;
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
fileToView.value = null;
|
||||
viewerDialog.value = false;
|
||||
setTimeout(() => { fileToViewPath.value = null; }, 300); // clear after animation
|
||||
};
|
||||
|
||||
const downloadFile = async (filePath) => {
|
||||
@@ -101,60 +296,85 @@ const downloadFile = async (filePath) => {
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showMsg('Download started');
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
showMsg('Error downloading file: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (itemPath, isDirectory) => {
|
||||
if (!confirm(`Are you sure you want to delete ${itemPath}?`)) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
console.log(`${itemPath} deleted successfully.`);
|
||||
showMsg('Item deleted');
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.error('Error deleting item:', await response.text());
|
||||
showMsg('Error deleting item', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
showMsg('Error deleting item: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const renameItem = async (oldPath, oldName, isDirectory) => {
|
||||
const newName = prompt(`Enter new name for ${oldName}:`, oldName);
|
||||
if (!newName || newName === oldName) {
|
||||
return;
|
||||
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}`;
|
||||
}
|
||||
|
||||
const 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',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`${oldName} renamed to ${newName} successfully.`);
|
||||
showMsg('Renamed successfully');
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.error('Error renaming item:', await response.text());
|
||||
showMsg('Error renaming item', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming item:', error);
|
||||
showMsg('Error renaming item: ' + error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
fileToUpload.value = event.target.files[0];
|
||||
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 () => {
|
||||
@@ -163,20 +383,24 @@ const uploadFile = async () => {
|
||||
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) {
|
||||
console.log('File uploaded successfully.');
|
||||
showMsg('File uploaded successfully');
|
||||
fileToUpload.value = null;
|
||||
uploadDialog.value = false;
|
||||
fetchFiles();
|
||||
} else {
|
||||
console.error('Error uploading file:', await response.text());
|
||||
showMsg('Error uploading file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
showMsg('Error uploading file: ' + error.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,31 +414,7 @@ watch(shareName, () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add some basic styling */
|
||||
div {
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
margin: 5px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
li span {
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
color: #007bff;
|
||||
}
|
||||
li span:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
button {
|
||||
margin-left: 5px;
|
||||
padding: 5px 10px;
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user