Major overhaul to the theme and now functioning.

This commit is contained in:
2026-01-05 16:07:30 -05:00
parent 5823ca3c39
commit a475dfeab6
5218 changed files with 1643729 additions and 13229 deletions
@@ -1,16 +1,88 @@
<template>
<div>
<h2>Comics Management</h2>
<button @click="organizeComics" :disabled="isOrganizing">
{{ isOrganizing ? 'Organizing...' : 'Organize Comics Now' }}
</button>
<button @click="cleanupToberead" :disabled="isCleaning">
{{ isCleaning ? 'Cleaning...' : 'Cleanup Folders' }}
</button>
<p v-if="message">{{ message }}</p>
<p v-if="cleanupMessage">{{ cleanupMessage }}</p>
<p v-if="error" style="color: red;">Error: {{ error }}</p>
</div>
<v-container>
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<v-card class="mb-4">
<v-toolbar color="primary" density="compact">
<v-toolbar-title>
<v-icon start>mdi-book-open-page-variant</v-icon>
Comics Management
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<p class="text-body-1 mb-4">
Manage your comic library. Organize files into folders and clean up empty directories.
</p>
<v-alert
v-if="message"
type="info"
variant="tonal"
closable
class="mb-4"
@click:close="message = ''"
>
{{ message }}
</v-alert>
<v-alert
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
@click:close="error = ''"
>
{{ error }}
</v-alert>
<div class="d-flex flex-column gap-3">
<v-btn
color="primary"
size="large"
prepend-icon="mdi-folder-move"
:loading="isOrganizing"
:disabled="isOrganizing || isCleaning"
@click="organizeComics"
block
>
{{ isOrganizing ? 'Organizing...' : 'Organize Comics' }}
</v-btn>
<v-btn
color="warning"
size="large"
prepend-icon="mdi-delete-empty"
:loading="isCleaning"
:disabled="isOrganizing || isCleaning"
@click="confirmCleanupDialog = true"
block
variant="outlined"
>
{{ isCleaning ? 'Cleaning...' : 'Cleanup Empty Folders' }}
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Confirmation Dialog -->
<v-dialog v-model="confirmCleanupDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">Confirm Cleanup</v-card-title>
<v-card-text>
This will delete empty folders in the 'toberead' path. Are you sure you want to continue?
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="confirmCleanupDialog = false">Cancel</v-btn>
<v-btn color="error" variant="text" @click="cleanupToberead">Confirm</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
@@ -20,7 +92,7 @@ const isOrganizing = ref(false);
const message = ref('');
const error = ref('');
const isCleaning = ref(false);
const cleanupMessage = ref('');
const confirmCleanupDialog = ref(false);
const organizeComics = async () => {
isOrganizing.value = true;
@@ -30,77 +102,54 @@ const organizeComics = async () => {
try {
const response = await fetch('/api/comics/organize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
message.value = data.message || 'Comics organization process started successfully!';
console.log('Backend response:', data);
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to organize comics: ${response.status}`;
console.error('Error response from backend:', errorData);
}
} catch (err) {
error.value = `Network error: ${err.message}`;
console.error('Network or unexpected error:', err);
} finally {
isOrganizing.value = false;
}
};
const cleanupToberead = async () => {
if (window.confirm("This will delete empty folders in the 'toberead' path. Are you sure you want to continue?")) {
isCleaning.value = true;
cleanupMessage.value = 'Initiating cleanup...';
error.value = '';
confirmCleanupDialog.value = false;
isCleaning.value = true;
message.value = 'Initiating cleanup...';
error.value = '';
try {
const response = await fetch('/api/comics/cleanup_toberead', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
try {
const response = await fetch('/api/comics/cleanup_toberead', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const data = await response.json();
cleanupMessage.value = data.message || 'Cleanup process started successfully!';
console.log('Backend response:', data);
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to start cleanup: ${response.status}`;
console.error('Error response from backend:', errorData);
}
} catch (err) {
error.value = `Network error: ${err.message}`;
console.error('Network or unexpected error:', err);
} finally {
isCleaning.value = false;
if (response.ok) {
const data = await response.json();
message.value = data.message || 'Cleanup process started successfully!';
} else {
const errorData = await response.json();
error.value = errorData.detail || `Failed to start cleanup: ${response.status}`;
}
} else {
cleanupMessage.value = 'Cleanup cancelled.';
} catch (err) {
error.value = `Network error: ${err.message}`;
} finally {
isCleaning.value = false;
}
};
</script>
<style scoped>
div {
font-family: sans-serif;
padding: 20px;
.gap-3 {
display: flex;
flex-direction: column;
gap: 12px;
}
button {
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
margin-right: 10px;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
</style>
</style>
@@ -0,0 +1,237 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="12" md="6">
<v-card class="mb-4">
<v-toolbar color="primary" density="compact">
<v-toolbar-title>
<v-icon start>mdi-download</v-icon>
Coome/Kemono Downloader
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-form @submit.prevent="startDownload">
<v-textarea
v-model="url"
label="URLs (One per line)"
hint="e.g. https://coomer.st/artist/..."
persistent-hint
variant="outlined"
class="mb-2"
rows="3"
auto-grow
></v-textarea>
<v-row>
<v-col cols="6">
<v-checkbox
v-model="options.download_images"
label="Download Images"
density="compact"
hide-details
></v-checkbox>
</v-col>
<v-col cols="6">
<v-checkbox
v-model="options.download_videos"
label="Download Videos"
density="compact"
hide-details
></v-checkbox>
</v-col>
<v-col cols="12">
<v-checkbox
v-model="options.force_overwrite"
label="Overwrite existing files"
density="compact"
hide-details
color="warning"
></v-checkbox>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4 gap-2">
<v-btn
color="error"
variant="text"
@click="cancelDownload"
:disabled="!status.active"
>Cancel</v-btn>
<v-btn
type="submit"
color="primary"
:loading="status.active"
:disabled="!url"
>Start Download</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
<v-card v-if="status.active || status.status !== 'Idle'">
<v-card-title>Status: {{ status.status }}</v-card-title>
<v-card-text>
<div class="mb-2">
<span class="text-caption">Current File: {{ status.current_file }}</span>
</div>
<v-progress-linear
v-if="status.total > 0"
:model-value="(status.downloaded / status.total) * 100"
color="primary"
height="20"
striped
>
<template v-slot:default="{ value }">
<strong>{{ Math.ceil(value) }}%</strong>
</template>
</v-progress-linear>
<v-progress-linear
v-else
indeterminate
color="primary"
></v-progress-linear>
<div class="d-flex justify-space-between mt-2 text-caption">
<span>Speed: {{ formatSpeed(status.speed) }}</span>
<span>ETA: {{ formatTime(status.eta) }}</span>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="fill-height">
<v-card-title>Logs</v-card-title>
<v-card-text class="pa-0">
<v-sheet
color="black"
class="pa-2"
height="400"
style="overflow-y: auto; font-family: monospace; font-size: 0.8rem;"
ref="logContainer"
>
<div v-for="(log, index) in logs" :key="index" class="text-grey-lighten-1">
{{ log }}
</div>
</v-sheet>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
const url = ref('');
const options = ref({
download_images: true,
download_videos: true,
max_workers: 3,
force_overwrite: false
});
const status = ref({
active: false,
status: 'Idle',
downloaded: 0,
total: 0,
speed: 0,
eta: 0,
current_file: ''
});
const logs = ref([]);
const snackbar = ref({ show: false, text: '', color: 'info' });
const logContainer = ref(null);
let pollInterval = null;
const formatSpeed = (speed) => {
if (speed > 1024 * 1024) return (speed / (1024 * 1024)).toFixed(2) + ' MB/s';
if (speed > 1024) return (speed / 1024).toFixed(2) + ' KB/s';
return speed.toFixed(0) + ' B/s';
};
const formatTime = (seconds) => {
if (!seconds || seconds === Infinity) return '--:--';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const showMsg = (text, color = 'info') => {
snackbar.value = { show: true, text, color };
};
const startDownload = async () => {
try {
const urls = url.value.split('\n').map(u => u.trim()).filter(u => u);
if (urls.length === 0) return;
const response = await fetch('/api/downloader/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: urls, ...options.value })
});
if (response.ok) {
showMsg('Download started', 'success');
pollStatus();
} else {
const err = await response.json();
showMsg('Error: ' + err.detail, 'error');
}
} catch (error) {
showMsg('Network error', 'error');
}
};
const cancelDownload = async () => {
try {
await fetch('/api/downloader/cancel', { method: 'POST' });
showMsg('Cancellation requested');
} catch (error) {
console.error(error);
}
};
const fetchStatus = async () => {
try {
const res = await fetch('/api/downloader/status');
if (res.ok) status.value = await res.json();
const logsRes = await fetch('/api/downloader/logs');
if (logsRes.ok) {
const newLogs = await logsRes.json();
if (newLogs.length !== logs.value.length) {
logs.value = newLogs;
scrollToBottom();
}
}
} catch (e) {
console.error(e);
}
};
const scrollToBottom = () => {
nextTick(() => {
if (logContainer.value) {
logContainer.value.$el.scrollTop = logContainer.value.$el.scrollHeight;
}
});
};
const pollStatus = () => {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(fetchStatus, 1000);
};
onMounted(() => {
pollStatus();
});
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval);
});
</script>
@@ -1,168 +1,240 @@
<template>
<div v-if="filePath" class="modal-overlay" @click.self="close">
<div class="modal-content">
<button class="close-button" @click="close">&times;</button>
<h3>{{ fileName }}</h3>
<div class="viewer-container">
<img v-if="fileType === 'image'" :src="fileUrl" :alt="fileName" class="viewable-image" />
<video v-if="fileType === 'video'" :src="fileUrl" controls class="viewable-video"></video>
<audio v-if="fileType === 'audio'" :src="fileUrl" controls class="viewable-audio"></audio>
<pre v-if="fileType === 'text'"><code>{{ textContent }}</code></pre>
<div v-if="fileType === 'cbz'">
<div v-if="cbzImageUrls.length > 0">
<img :src="cbzImageUrls[currentCbzImageIndex]" class="viewable-image" />
<div>
<button @click="prevCbzImage" :disabled="currentCbzImageIndex === 0">Previous</button>
<span>{{ currentCbzImageIndex + 1 }} / {{ cbzImageUrls.length }}</span>
<button @click="nextCbzImage" :disabled="currentCbzImageIndex === cbzImageUrls.length - 1">Next</button>
</div>
<v-dialog v-model="dialog" fullscreen transition="dialog-bottom-transition" :scrim="false">
<v-card theme="dark" class="fill-height d-flex flex-column">
<v-toolbar color="primary" density="compact">
<v-btn icon @click="close">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-toolbar-title>{{ fileName }}</v-toolbar-title>
<v-spacer></v-spacer>
<!-- Viewer Controls -->
<template v-if="fileType === 'cbz' || fileType === 'image'">
<v-btn-toggle v-model="viewMode" density="compact" variant="tonal" mandatory class="mr-2">
<v-btn value="fit-height" icon="mdi-arrow-expand-vertical" title="Fit Height"></v-btn>
<v-btn value="fit-width" icon="mdi-arrow-expand-horizontal" title="Fit Width"></v-btn>
<v-btn value="original" icon="mdi-magnify-plus-outline" title="Original Size"></v-btn>
</v-btn-toggle>
<div class="d-flex align-center mr-4" style="width: 150px">
<v-slider
v-model="zoomLevel"
min="10"
max="300"
step="10"
hide-details
density="compact"
thumb-label
></v-slider>
</div>
<p v-else>Loading CBZ content...</p>
</template>
<v-btn icon @click="close">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text class="d-flex justify-center align-center flex-grow-1 bg-grey-darken-4 pa-0 overflow-hidden position-relative">
<!-- Loading State -->
<v-progress-circular v-if="loading" indeterminate color="primary"></v-progress-circular>
<!-- Viewer Area -->
<div v-else class="viewer-content w-100 h-100 overflow-auto d-flex justify-center" :class="alignClass">
<!-- Image -->
<img
v-if="fileType === 'image'"
:src="fileUrl"
:alt="fileName"
:style="imageStyle"
class="transition-swing"
/>
<!-- Video -->
<video
v-else-if="fileType === 'video'"
:src="fileUrl"
controls
style="max-width: 100%; max-height: 100%;"
></video>
<!-- Audio -->
<audio
v-else-if="fileType === 'audio'"
:src="fileUrl"
controls
style="width: 100%; max-width: 600px;"
></audio>
<!-- Text -->
<v-sheet
v-else-if="fileType === 'text'"
color="grey-darken-3"
class="pa-4 w-100"
style="max-width: 1200px; overflow-x: auto;"
>
<pre><code>{{ textContent }}</code></pre>
</v-sheet>
<!-- CBZ Comic -->
<div v-else-if="fileType === 'cbz'" class="d-flex flex-column align-center" style="min-height: 100%">
<div v-if="cbzImageUrls.length > 0" class="position-relative d-flex flex-column align-center">
<img
:src="cbzImageUrls[currentCbzImageIndex]"
:style="imageStyle"
class="transition-swing cursor-pointer"
@click="nextCbzImage"
/>
<!-- Floating Navigation -->
<div class="comic-nav-controls my-4 d-flex align-center gap-4 pa-2 rounded bg-grey-darken-3 elevation-4">
<v-btn
icon="mdi-chevron-left"
@click.stop="prevCbzImage"
:disabled="currentCbzImageIndex === 0"
variant="text"
></v-btn>
<span class="text-body-2 font-weight-bold mx-2">{{ currentCbzImageIndex + 1 }} / {{ cbzImageUrls.length }}</span>
<v-btn
icon="mdi-chevron-right"
@click.stop="nextCbzImage"
:disabled="currentCbzImageIndex === cbzImageUrls.length - 1"
variant="text"
></v-btn>
</div>
</div>
<p v-else class="text-grey mt-10">Loading Comic...</p>
</div>
<div v-else class="text-h6 text-grey mt-10">
Preview not available for this file type.
</div>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, ref, watchEffect } from 'vue';
import { computed, ref, watchEffect, watch } from 'vue';
const props = defineProps({
filePath: {
type: String,
required: true,
},
shareName: {
type: String,
required: true,
},
filePath: { type: String, required: true },
shareName: { type: String, required: true },
modelValue: { type: Boolean, default: true }
});
const emit = defineEmits(['close']);
const emit = defineEmits(['close', 'update:modelValue']);
const dialog = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const fileName = computed(() => props.filePath.split('/').pop());
const fileUrl = computed(() => `/api/samba/view/${props.shareName}?path=${props.filePath}`);
const textContent = ref('');
const cbzImageUrls = ref([]);
const currentCbzImageIndex = ref(0);
const loading = ref(false);
// Viewer Controls
const viewMode = ref('fit-height'); // fit-height, fit-width, original
const zoomLevel = ref(100);
const imageStyle = computed(() => {
const styles = {};
if (viewMode.value === 'original') {
styles.width = `${zoomLevel.value}%`;
styles.height = 'auto';
styles.maxWidth = 'none';
styles.maxHeight = 'none';
} else if (viewMode.value === 'fit-width') {
styles.width = `${zoomLevel.value}%`;
styles.height = 'auto';
styles.maxWidth = '100vw'; // Allow scroll if zoomed in? No, base fit width implies 100%.
// If zoom is 100%, it fits container width. If 200%, it's 2x container.
} else { // fit-height
styles.height = `${zoomLevel.value}%`; // Relative to container height?
// Usually fit-height means 100vh.
styles.height = `${zoomLevel.value}vh`;
styles.width = 'auto';
}
return styles;
});
const alignClass = computed(() => {
return viewMode.value === 'fit-height' ? 'align-center' : 'align-start';
});
const fileType = computed(() => {
if (!props.filePath) return 'other';
const extension = props.filePath.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(extension)) {
return 'image';
}
if (['mp4', 'mkv', 'avi', 'mov', 'webm'].includes(extension)) {
return 'video';
}
if (['mp3', 'flac', 'wav', 'ogg'].includes(extension)) {
return 'audio';
}
if (['txt', 'py', 'js', 'json', 'xml', 'html', 'css', 'md'].includes(extension)) {
return 'text';
}
if (extension === 'cbz') {
return 'cbz';
}
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(extension)) return 'image';
if (['mp4', 'mkv', 'avi', 'mov', 'webm'].includes(extension)) return 'video';
if (['mp3', 'flac', 'wav', 'ogg'].includes(extension)) return 'audio';
if (['txt', 'py', 'js', 'json', 'xml', 'html', 'css', 'md', 'log'].includes(extension)) return 'text';
if (extension === 'cbz') return 'cbz';
return 'other';
});
watchEffect(async () => {
if (!props.filePath || !dialog.value) return;
loading.value = true;
textContent.value = '';
cbzImageUrls.value = [];
currentCbzImageIndex.value = 0;
// Reset Zoom
viewMode.value = 'fit-height';
zoomLevel.value = 90; // Default nice fit
if (fileType.value === 'text') {
try {
const response = await fetch(fileUrl.value);
if (response.ok) {
textContent.value = await response.text();
} else {
textContent.value = 'Error loading file content.';
}
if (response.ok) textContent.value = await response.text();
else textContent.value = 'Error loading file content.';
} catch (error) {
textContent.value = 'Error loading file content.';
console.error('Error fetching text content:', error);
}
} else if (fileType.value === 'cbz') {
try {
const response = await fetch(`/api/samba/cbz/list/${props.shareName}?path=${props.filePath}`);
if (response.ok) {
const imageNames = await response.json();
imageNames.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
cbzImageUrls.value = imageNames.map(name => `/api/samba/cbz/image/${props.shareName}?path=${props.filePath}&image_name=${encodeURIComponent(name)}`);
}
} catch (error) {
console.error('Error fetching CBZ content:', error);
}
}
loading.value = false;
});
const prevCbzImage = () => {
if (currentCbzImageIndex.value > 0) {
currentCbzImageIndex.value--;
}
if (currentCbzImageIndex.value > 0) currentCbzImageIndex.value--;
};
const nextCbzImage = () => {
if (currentCbzImageIndex.value < cbzImageUrls.value.length - 1) {
currentCbzImageIndex.value++;
}
if (currentCbzImageIndex.value < cbzImageUrls.value.length - 1) currentCbzImageIndex.value++;
};
const close = () => {
emit('close');
emit('update:modelValue', false);
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
.cursor-pointer { cursor: pointer; }
.transition-swing { transition: all 0.2s cubic-bezier(0.25, 0.8, 0.5, 1); }
.comic-nav-controls {
position: fixed;
bottom: 20px;
z-index: 100;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 900px;
max-height: 80vh;
overflow-y: auto;
position: relative;
text-align: center;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
font-size: 24px;
background: none;
border: none;
cursor: pointer;
}
.viewer-container {
margin-top: 20px;
}
.viewable-image,
.viewable-video,
.viewable-audio {
max-width: 100%;
max-height: 70vh;
}
pre {
background-color: #2d2d2d; /* Dark background */
color: #f8f8f2; /* Light text color */
padding: 1em;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
text-align: left;
}
</style>
</style>
@@ -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>
@@ -1,64 +1,174 @@
<template>
<div>
<h2>Video Duplicates Scanner</h2>
<button @click="startScan" :disabled="isLoading">Start Scan</button>
<button @click="pauseScan" :disabled="isLoading">Pause Scan</button>
<button @click="resumeScan" :disabled="isLoading">Resume Scan</button>
<p>Status: {{ status }}</p>
<v-container fluid>
<!-- Status Card -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon start icon="mdi-radar" class="mr-2"></v-icon>
Scanner Status
<v-chip class="ml-4" :color="statusColor" size="small">{{ status }}</v-chip>
</v-card-title>
<v-card-text>
<div class="d-flex gap-2">
<v-btn
color="primary"
prepend-icon="mdi-play"
@click="startScan"
:disabled="isLoading"
variant="tonal"
>Start Scan</v-btn>
<v-btn
color="warning"
prepend-icon="mdi-pause"
@click="pauseScan"
:disabled="isLoading"
variant="tonal"
>Pause</v-btn>
<v-btn
color="success"
prepend-icon="mdi-play-circle-outline"
@click="resumeScan"
:disabled="isLoading"
variant="tonal"
>Resume</v-btn>
</div>
</v-card-text>
</v-card>
<hr>
<v-row>
<!-- Find Duplicates Controls -->
<v-col cols="12" md="4">
<v-card class="fill-height">
<v-card-title>Find Duplicates</v-card-title>
<v-card-text>
<div class="text-caption mb-2">Similarity Threshold: {{ threshold }}</div>
<v-slider
v-model="threshold"
min="0.8"
max="1.0"
step="0.01"
thumb-label
color="primary"
></v-slider>
<v-btn
block
color="primary"
@click="findDuplicates"
:loading="isLoading"
prepend-icon="mdi-magnify"
>Find Duplicates</v-btn>
</v-card-text>
</v-card>
</v-col>
<h2>Find Duplicates</h2>
<div>
<label for="threshold-slider">Similarity Threshold: {{ threshold }}</label>
<input
type="range"
id="threshold-slider"
min="0.8"
max="1.0"
step="0.01"
v-model="threshold"
/>
<button @click="findDuplicates" :disabled="isLoading">Find Duplicates</button>
</div>
<!-- Previous Reports -->
<v-col cols="12" md="8">
<v-card class="fill-height">
<v-card-title>Previous Reports</v-card-title>
<v-list density="compact" style="max-height: 200px; overflow-y: auto;">
<v-list-item
v-for="report in reports"
:key="report.id"
:value="report.id"
@click="getReport(report.id)"
active-color="primary"
>
<template v-slot:prepend>
<v-icon icon="mdi-file-document-outline"></v-icon>
</template>
<v-list-item-title>Report #{{ report.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ new Date(report.created_at).toLocaleString() }} - {{ report.status }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</v-col>
</v-row>
<div v-if="duplicatesReport">
<h3>Duplicate Report #{{ duplicatesReport.report_id }} ({{ duplicatesReport.status }})</h3>
<button @click="deleteSelectedFiles" :disabled="selectedFiles.length === 0">Delete Selected</button>
<div v-for="group in duplicatesReport.groups" :key="group.group_id" class="duplicate-group">
<h4>Group {{ group.group_id }} (Score: {{ group.score.toFixed(4) }})</h4>
<ul>
<li v-for="file in group.files" :key="file.id">
<input type="checkbox" :value="file.filepath" v-model="selectedFiles" />
{{ file.filepath }} ({{ (file.size / 1024 / 1024).toFixed(2) }} MB)
<button @click="viewFile(file.filepath)">View</button>
</li>
</ul>
</div>
</div>
<!-- Active Report View -->
<v-fade-transition>
<v-card v-if="duplicatesReport" class="mt-4">
<v-toolbar color="surface" density="compact">
<v-toolbar-title>
Report #{{ duplicatesReport.report_id }}
<span class="text-caption text-grey ml-2">({{ duplicatesReport.status }})</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="error"
variant="flat"
:disabled="selectedFiles.length === 0"
@click="deleteSelectedFiles"
prepend-icon="mdi-delete"
>
Delete Selected ({{ selectedFiles.length }})
</v-btn>
</v-toolbar>
<hr>
<h2>Previous Reports</h2>
<ul>
<li v-for="report in reports" :key="report.id">
<a href="#" @click.prevent="getReport(report.id)">
Report #{{ report.id }} - {{ new Date(report.created_at).toLocaleString() }} ({{ report.status }})
</a>
</li>
</ul>
<v-card-text class="bg-grey-lighten-4 pa-4 rounded">
<v-expansion-panels multiple>
<v-expansion-panel
v-for="group in duplicatesReport.groups"
:key="group.group_id"
>
<v-expansion-panel-title>
<div class="d-flex align-center justify-space-between w-100 mr-4">
<span>Group {{ group.group_id }}</span>
<v-chip size="small" :color="getScoreColor(group.score)">Score: {{ group.score.toFixed(4) }}</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-list lines="two" density="compact">
<v-list-item v-for="file in group.files" :key="file.id">
<template v-slot:prepend>
<v-checkbox-btn
v-model="selectedFiles"
:value="file.filepath"
color="error"
></v-checkbox-btn>
</template>
<v-list-item-title class="font-weight-bold text-wrap" style="word-break: break-all;">
{{ file.filepath }}
</v-list-item-title>
<v-list-item-subtitle>
Size: {{ (file.size / 1024 / 1024).toFixed(2) }} MB
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
variant="text"
color="info"
icon="mdi-eye"
@click="viewFile(file.filepath)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<div v-if="!duplicatesReport.groups || duplicatesReport.groups.length === 0" class="text-center py-4 text-grey">
No duplicates found in this report.
</div>
</v-card-text>
</v-card>
</v-fade-transition>
<!-- Viewer -->
<FileViewer
v-if="fileToView"
v-model="viewerDialog"
:file-path="fileToView"
:share-name="shareName"
@close="closeViewer"
/>
</div>
<v-snackbar v-model="snackbar.show" :color="snackbar.color">{{ snackbar.text }}</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import FileViewer from './FileViewer.vue';
const status = ref('Idle');
@@ -68,18 +178,33 @@ const duplicatesReport = ref(null);
const reports = ref([]);
const selectedFiles = ref([]);
const fileToView = ref(null);
const viewerDialog = ref(false);
const snackbar = ref({ show: false, text: '', color: 'info' });
const statusColor = computed(() => {
if (status.value.toLowerCase().includes('scanning')) return 'success';
if (status.value.toLowerCase().includes('paused')) return 'warning';
return 'grey';
});
const shareName = computed(() => {
if (fileToView.value) {
// This is a simplification. In a real app, you might have a more robust way
// to determine the share from the path.
if (fileToView.value.startsWith('/videos')) {
return 'isolation';
}
}
return 'media'; // Default share
return 'media';
});
const showMsg = (text, color = 'info') => {
snackbar.value = { show: true, text, color };
};
const getScoreColor = (score) => {
if (score > 0.98) return 'red';
if (score > 0.95) return 'orange';
return 'green';
};
const getStatus = async () => {
try {
@@ -87,63 +212,50 @@ const getStatus = async () => {
if (response.ok) {
const data = await response.json();
status.value = data.status;
} else {
status.value = 'Error fetching status';
}
} catch (error) {
status.value = 'Error fetching status';
console.error('Error fetching status:', error);
}
};
const startScan = async () => {
isLoading.value = true;
status.value = 'Starting scan...';
try {
const response = await fetch('/api/duplicates/scan', { method: 'POST' });
if (response.ok) {
status.value = 'Scan started.';
} else {
status.value = 'Error starting scan.';
showMsg('Scan started', 'success');
getStatus();
}
} catch (error) {
status.value = 'Error starting scan.';
console.error('Error starting scan:', error);
showMsg('Error starting scan', 'error');
}
isLoading.value = false;
};
const pauseScan = async () => {
isLoading.value = true;
status.value = 'Pausing scan...';
try {
const response = await fetch('/api/duplicates/scan/pause', { method: 'POST' });
if (response.ok) {
status.value = 'Scan paused.';
} else {
status.value = 'Error pausing scan.';
showMsg('Scan paused', 'warning');
getStatus();
}
} catch (error) {
status.value = 'Error pausing scan.';
console.error('Error pausing scan:', error);
showMsg('Error pausing scan', 'error');
}
isLoading.value = false;
};
const resumeScan = async () => {
isLoading.value = true;
status.value = 'Resuming scan...';
try {
const response = await fetch('/api/duplicates/scan/resume', { method: 'POST' });
if (response.ok) {
status.value = 'Scan resumed.';
}
else {
status.value = 'Error resuming scan.';
showMsg('Scan resumed', 'success');
getStatus();
}
} catch (error) {
status.value = 'Error resuming scan.';
console.error('Error resuming scan:', error);
showMsg('Error resuming scan', 'error');
}
isLoading.value = false;
};
@@ -153,8 +265,6 @@ const getReports = async () => {
const response = await fetch('/api/duplicates/reports');
if (response.ok) {
reports.value = await response.json();
} else {
console.error('Error fetching reports');
}
} catch (error) {
console.error('Error fetching reports:', error);
@@ -166,12 +276,10 @@ const getReport = async (reportId) => {
const response = await fetch(`/api/duplicates/report/${reportId}`);
if (response.ok) {
duplicatesReport.value = await response.json();
selectedFiles.value = []; // Clear selection when a new report is loaded
} else {
console.error('Error fetching report');
selectedFiles.value = [];
}
} catch (error) {
console.error('Error fetching report:', error);
showMsg('Error fetching report', 'error');
}
};
@@ -186,48 +294,48 @@ const findDuplicates = async () => {
const data = await response.json();
if (data.report_id) {
await getReport(data.report_id);
showMsg('Duplicates found!', 'success');
}
await getReports(); // Refresh the list of reports
await getReports();
} else {
console.error('Error finding duplicates');
showMsg('No duplicates found or error.', 'warning');
}
} catch (error) {
console.error('Error finding duplicates:', error);
showMsg('Error finding duplicates: ' + error.message, 'error');
}
isLoading.value = false;
};
const viewFile = (filePath) => {
fileToView.value = filePath;
viewerDialog.value = true;
};
const closeViewer = () => {
fileToView.value = null;
viewerDialog.value = false;
setTimeout(() => { fileToView.value = null; }, 300);
};
const deleteSelectedFiles = async () => {
if (!confirm(`Are you sure you want to delete ${selectedFiles.value.length} files?`)) {
return;
}
if (!confirm(`Are you sure you want to delete ${selectedFiles.value.length} files?`)) return;
isLoading.value = true;
try {
const response = await fetch('/api/duplicates/files', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filepaths: selectedFiles.value }),
});
if (response.ok) {
// Refresh the report
showMsg('Files deleted successfully', 'success');
if (duplicatesReport.value) {
await getReport(duplicatesReport.value.report_id);
}
} else {
console.error('Error deleting files');
showMsg('Error deleting files', 'error');
}
} catch (error) {
console.error('Error deleting files:', error);
showMsg('Error deleting files: ' + error.message, 'error');
}
isLoading.value = false;
};
@@ -235,20 +343,6 @@ const deleteSelectedFiles = async () => {
onMounted(() => {
getStatus();
getReports();
setInterval(getStatus, 5000); // Poll for status every 5 seconds
setInterval(getStatus, 5000);
});
</script>
<style scoped>
button {
margin: 5px;
}
hr {
margin: 20px 0;
}
.duplicate-group {
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
}
</style>