배포
This commit is contained in:
commit
d9ef64eafe
35 files changed
+4733
No files matched your search
@@ -0,0 +1,281 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import Header from './components/Header.vue';
|
||||
import MagnetForm from './components/MagnetForm.vue';
|
||||
import TorrentList from './components/TorrentList.vue';
|
||||
import FileViewer from './components/FileViewer.vue';
|
||||
import {
|
||||
fetchStatus,
|
||||
fetchTorrents,
|
||||
addMagnet,
|
||||
removeTorrent,
|
||||
startTorrent,
|
||||
stopTorrent
|
||||
} from './api/torrent';
|
||||
|
||||
// 사이드 메뉴 & 탭 상태
|
||||
const drawer = ref(true);
|
||||
const currentTab = ref('torrents'); // 'torrents' | 'files'
|
||||
|
||||
const status = ref({ connected: false, version: '', torrents_count: 0 });
|
||||
const torrents = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Vuetify Snackbar
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'info',
|
||||
icon: 'mdi-information-outline',
|
||||
timeout: 3500,
|
||||
});
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
const showToast = (message, type = 'info', timeout = 3500) => {
|
||||
const iconMap = {
|
||||
success: 'mdi-check-circle-outline',
|
||||
error: 'mdi-alert-circle-outline',
|
||||
info: 'mdi-information-outline',
|
||||
warning: 'mdi-alert-outline',
|
||||
};
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message,
|
||||
color: type,
|
||||
icon: iconMap[type] || 'mdi-information-outline',
|
||||
timeout,
|
||||
};
|
||||
};
|
||||
|
||||
const handleNotify = ({ message, type }) => {
|
||||
showToast(message, type);
|
||||
};
|
||||
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) loading.value = true;
|
||||
try {
|
||||
const [statusRes, torrentsRes] = await Promise.all([
|
||||
fetchStatus(),
|
||||
fetchTorrents(),
|
||||
]);
|
||||
|
||||
if (statusRes.success) {
|
||||
status.value = statusRes.data;
|
||||
} else {
|
||||
status.value.connected = false;
|
||||
}
|
||||
|
||||
if (torrentsRes.success) {
|
||||
torrents.value = torrentsRes.data;
|
||||
}
|
||||
} catch (err) {
|
||||
status.value.connected = false;
|
||||
if (!silent) {
|
||||
showToast(err.message || '데이터를 가져오는데 실패했습니다.', 'error');
|
||||
}
|
||||
} finally {
|
||||
if (!silent) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMagnet = async (formData) => {
|
||||
try {
|
||||
const res = await addMagnet(formData);
|
||||
showToast(res.message || '마그넷이 추가되었습니다.', 'success');
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
showToast(err.message || '마그넷 추가 실패', 'error');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (id) => {
|
||||
try {
|
||||
await startTorrent(id);
|
||||
showToast('토렌트를 시작했습니다.', 'success', 2000);
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
showToast(err.message || '토렌트 시작 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (id) => {
|
||||
try {
|
||||
await stopTorrent(id);
|
||||
showToast('토렌트를 일시정지했습니다.', 'info', 2000);
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
showToast(err.message || '토렌트 일시정지 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async ({ id, deleteData }) => {
|
||||
try {
|
||||
await removeTorrent(id, deleteData);
|
||||
showToast(`토렌트를 삭제했습니다. (데이터 삭제: ${deleteData ? '예' : '아니오'})`, 'success');
|
||||
await loadData(true);
|
||||
} catch (err) {
|
||||
showToast(err.message || '토렌트 삭제 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
// 4초마다 토렌트 정보 자동 갱신
|
||||
pollTimer = setInterval(() => {
|
||||
if (currentTab.value === 'torrents') {
|
||||
loadData(true);
|
||||
}
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- 사이드 메뉴 (Navigation Drawer) -->
|
||||
<v-navigation-drawer v-model="drawer" width="260">
|
||||
<div class="pa-4 d-flex align-center border-b">
|
||||
<v-avatar color="primary" variant="flat" size="36" class="mr-3">
|
||||
<v-icon icon="mdi-download-network" color="white" size="22"></v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-bold" style="line-height: 1.2;">
|
||||
Transmission
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Torrent & File Hub
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 목록 -->
|
||||
<v-list density="comfortable" nav class="pa-2">
|
||||
<!-- 1. 토렌트 관리 -->
|
||||
<v-list-item
|
||||
value="torrents"
|
||||
:active="currentTab === 'torrents'"
|
||||
color="primary"
|
||||
rounded="lg"
|
||||
@click="currentTab = 'torrents'"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-magnet" size="22"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">토렌트</v-list-item-title>
|
||||
<template #append>
|
||||
<v-badge
|
||||
v-if="torrents.length > 0"
|
||||
:content="torrents.length"
|
||||
color="primary"
|
||||
inline
|
||||
></v-badge>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 2. 다운로드 파일 뷰어 -->
|
||||
<v-list-item
|
||||
value="files"
|
||||
:active="currentTab === 'files'"
|
||||
color="primary"
|
||||
rounded="lg"
|
||||
@click="currentTab = 'files'"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-folder-eye-outline" size="22"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-medium">다운로드 파일 뷰어</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- 사이드바 하단 정보 -->
|
||||
<template #append>
|
||||
<div class="pa-3 border-t">
|
||||
<div class="d-flex align-center justify-space-between text-caption text-medium-emphasis">
|
||||
<span>Transmission 상태</span>
|
||||
<v-chip
|
||||
:color="status.connected ? 'success' : 'error'"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
class="font-weight-bold"
|
||||
>
|
||||
{{ status.connected ? '온라인' : '오프라인' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 상단 헤더 -->
|
||||
<Header
|
||||
:status="status"
|
||||
:loading="loading"
|
||||
:current-tab="currentTab"
|
||||
@toggle-drawer="drawer = !drawer"
|
||||
@refresh="() => loadData(false)"
|
||||
/>
|
||||
|
||||
<!-- 메인 컨텐츠 영역 -->
|
||||
<v-main>
|
||||
<v-container class="py-6" style="max-width: 1040px;">
|
||||
<!-- 토렌트 탭 -->
|
||||
<div v-if="currentTab === 'torrents'" class="tab-content">
|
||||
<MagnetForm @add="handleAddMagnet" />
|
||||
<TorrentList
|
||||
:torrents="torrents"
|
||||
:loading="loading"
|
||||
@start="handleStart"
|
||||
@stop="handleStop"
|
||||
@remove="handleRemove"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 다운로드 파일 뷰어 탭 -->
|
||||
<div v-else-if="currentTab === 'files'" class="tab-content">
|
||||
<FileViewer @notify="handleNotify" />
|
||||
</div>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- Vuetify Snackbar 알림 -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="snackbar.timeout"
|
||||
location="top right"
|
||||
rounded="lg"
|
||||
elevation="4"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="snackbar.icon" class="mr-2"></v-icon>
|
||||
<span class="font-weight-medium">{{ snackbar.message }}</span>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
@click="snackbar.show = false"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 전역 Pretendard 폰트 적용 */
|
||||
html, body, .v-application {
|
||||
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif !important;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.border-t {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
const BASE_URL = '/api';
|
||||
|
||||
export async function fetchFiles(path = '') {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : '';
|
||||
const res = await fetch(`${BASE_URL}/files${query}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || '파일 목록을 가져올 수 없습니다.');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteFileOrFolder(path) {
|
||||
const res = await fetch(`${BASE_URL}/files?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || '파일 삭제에 실패했습니다.');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function checkArchive(path) {
|
||||
const res = await fetch(`${BASE_URL}/files/check-archive?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || '압축 파일 상태 검사에 실패했습니다.');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function extractArchive({ path, password = null, create_subfolder = true, custom_dest = null, delete_archive = false }) {
|
||||
const res = await fetch(`${BASE_URL}/files/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: password ? password.trim() : null,
|
||||
create_subfolder,
|
||||
custom_dest,
|
||||
delete_archive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || '압축 해제에 실패했습니다.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function extractArchiveStream({
|
||||
path,
|
||||
password = null,
|
||||
create_subfolder = true,
|
||||
custom_dest = null,
|
||||
delete_archive = false,
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) {
|
||||
const res = await fetch(`${BASE_URL}/files/extract-stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path,
|
||||
password: password ? password.trim() : null,
|
||||
create_subfolder,
|
||||
custom_dest,
|
||||
delete_archive,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || '압축 해제 스트림 연결에 실패했습니다.');
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop(); // 아직 완료되지 않은 마지막 조각은 보관
|
||||
|
||||
for (const chunk of lines) {
|
||||
const match = chunk.match(/^data:\s*(.*)$/m);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]);
|
||||
if (payload.status === 'progress' && onProgress) {
|
||||
onProgress(payload);
|
||||
} else if (payload.status === 'completed' && onSuccess) {
|
||||
onSuccess(payload);
|
||||
} else if (payload.status === 'error' && onError) {
|
||||
onError(payload);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('SSE 파싱 에러:', e, match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadUrl(path) {
|
||||
return `${BASE_URL}/files/download?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
export function getFolderZipUrl(path) {
|
||||
return `${BASE_URL}/files/download-folder?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
export async function downloadBatchZip(paths, zipName = 'selected_files.zip') {
|
||||
const res = await fetch(`${BASE_URL}/files/download-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths, zip_name: zipName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || '일괄 다운로드 생성에 실패했습니다.');
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = zipName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const BASE_URL = '/api';
|
||||
|
||||
export async function fetchStatus() {
|
||||
const res = await fetch(`${BASE_URL}/status`);
|
||||
if (!res.ok) throw new Error('상태 정보를 가져올 수 없습니다.');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchTorrents() {
|
||||
const res = await fetch(`${BASE_URL}/torrents`);
|
||||
if (!res.ok) throw new Error('토렌트 목록을 불러오지 못했습니다.');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function addMagnet({ magnet, download_dir, paused }) {
|
||||
const res = await fetch(`${BASE_URL}/torrents/add`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ magnet, download_dir, paused }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || '토렌트 추가에 실패했습니다.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function removeTorrent(torrentId, deleteData = false) {
|
||||
const res = await fetch(`${BASE_URL}/torrents/${torrentId}?delete_data=${deleteData}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || '토렌트 삭제에 실패했습니다.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startTorrent(torrentId) {
|
||||
const res = await fetch(`${BASE_URL}/torrents/${torrentId}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || '토렌트 시작 실패');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function stopTorrent(torrentId) {
|
||||
const res = await fetch(`${BASE_URL}/torrents/${torrentId}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || '토렌트 일시정지 실패');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue';
|
||||
import {
|
||||
fetchFiles,
|
||||
deleteFileOrFolder,
|
||||
getDownloadUrl,
|
||||
getFolderZipUrl,
|
||||
downloadBatchZip,
|
||||
checkArchive,
|
||||
extractArchiveStream
|
||||
} from '../api/files';
|
||||
|
||||
const emit = defineEmits(['notify']);
|
||||
|
||||
const currentPath = ref('');
|
||||
const items = ref([]);
|
||||
const totalSize = ref(0);
|
||||
const loading = ref(false);
|
||||
|
||||
// 체크박스 다중 선택 상태
|
||||
const selectedPaths = ref([]);
|
||||
const isBatchDownloading = ref(false);
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const deleteDialog = ref(false);
|
||||
const itemToDelete = ref(null);
|
||||
const isBatchDelete = ref(false);
|
||||
|
||||
// 미리보기 다이얼로그
|
||||
const previewDialog = ref(false);
|
||||
const previewItem = ref(null);
|
||||
|
||||
// 압축 해제 다이얼로그 & 상태
|
||||
const extractDialog = ref(false);
|
||||
const archiveToExtract = ref(null);
|
||||
const createSubfolder = ref(true);
|
||||
const deleteArchiveAfter = ref(false);
|
||||
const isExtracting = ref(false);
|
||||
const extractPassword = ref('');
|
||||
const showPassword = ref(false);
|
||||
const isEncryptedArchive = ref(false);
|
||||
const checkingArchive = ref(false);
|
||||
const extractError = ref('');
|
||||
const passwordInputRef = ref(null);
|
||||
|
||||
// 실시간 진행률 상태
|
||||
const progressPercent = ref(0);
|
||||
const currentExtractingFile = ref('');
|
||||
const filesDoneCount = ref(0);
|
||||
|
||||
const loadFiles = async (path = '') => {
|
||||
loading.value = true;
|
||||
selectedPaths.value = []; // 폴더 변경 시 선택 초기화
|
||||
try {
|
||||
const res = await fetchFiles(path);
|
||||
if (res.success) {
|
||||
currentPath.value = res.data.current_path;
|
||||
items.value = res.data.items;
|
||||
totalSize.value = res.data.total_size;
|
||||
}
|
||||
} catch (err) {
|
||||
emit('notify', { message: err.message || '파일 목록 로드 실패', type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 선택 여부 계산
|
||||
const isAllSelected = computed(() => {
|
||||
return items.value.length > 0 && selectedPaths.value.length === items.value.length;
|
||||
});
|
||||
|
||||
const isSomeSelected = computed(() => {
|
||||
return selectedPaths.value.length > 0 && selectedPaths.value.length < items.value.length;
|
||||
});
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedPaths.value = [];
|
||||
} else {
|
||||
selectedPaths.value = items.value.map(i => i.path);
|
||||
}
|
||||
};
|
||||
|
||||
// 선택된 항목들의 총 용량 계산
|
||||
const selectedTotalSize = computed(() => {
|
||||
const selectedSet = new Set(selectedPaths.value);
|
||||
return items.value
|
||||
.filter(i => selectedSet.has(i.path) && !i.is_dir)
|
||||
.reduce((acc, cur) => acc + (cur.size || 0), 0);
|
||||
});
|
||||
|
||||
// 선택 항목 ZIP 일괄 다운로드
|
||||
const handleBatchDownload = async () => {
|
||||
if (selectedPaths.value.length === 0) return;
|
||||
isBatchDownloading.value = true;
|
||||
try {
|
||||
const currentFolderName = currentPath.value.split('/').pop() || 'downloads';
|
||||
const zipName = `${currentFolderName}_선택항목.zip`;
|
||||
await downloadBatchZip(selectedPaths.value, zipName);
|
||||
emit('notify', { message: '일괄 ZIP 다운로드가 시작되었습니다.', type: 'success' });
|
||||
} catch (err) {
|
||||
emit('notify', { message: err.message || '일괄 다운로드 실패', type: 'error' });
|
||||
} finally {
|
||||
isBatchDownloading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 항목 일괄 삭제 모달 오픈
|
||||
const openBatchDelete = () => {
|
||||
if (selectedPaths.value.length === 0) return;
|
||||
isBatchDelete.value = true;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
// 브레드크럼 파싱
|
||||
const breadcrumbs = computed(() => {
|
||||
const parts = currentPath.value ? currentPath.value.split('/') : [];
|
||||
const crumbs = [{ title: '다운로드 루트', path: '' }];
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
accumulated = accumulated ? `${accumulated}/${part}` : part;
|
||||
crumbs.push({
|
||||
title: part,
|
||||
path: accumulated,
|
||||
});
|
||||
}
|
||||
return crumbs;
|
||||
});
|
||||
|
||||
const navigateTo = (path) => {
|
||||
loadFiles(path);
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!currentPath.value) return;
|
||||
const parts = currentPath.value.split('/');
|
||||
parts.pop();
|
||||
loadFiles(parts.join('/'));
|
||||
};
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
if (item.is_dir) {
|
||||
loadFiles(item.path);
|
||||
} else if (item.category === 'archive') {
|
||||
openExtract(item);
|
||||
} else {
|
||||
handlePreview(item);
|
||||
}
|
||||
};
|
||||
|
||||
const openExtract = async (item) => {
|
||||
archiveToExtract.value = item;
|
||||
createSubfolder.value = true;
|
||||
deleteArchiveAfter.value = false;
|
||||
extractPassword.value = '';
|
||||
showPassword.value = false;
|
||||
extractError.value = '';
|
||||
isEncryptedArchive.value = false;
|
||||
checkingArchive.value = true;
|
||||
progressPercent.value = 0;
|
||||
currentExtractingFile.value = '';
|
||||
filesDoneCount.value = 0;
|
||||
extractDialog.value = true;
|
||||
|
||||
try {
|
||||
const res = await checkArchive(item.path);
|
||||
if (res.success && res.data.is_encrypted) {
|
||||
isEncryptedArchive.value = true;
|
||||
nextTick(() => {
|
||||
passwordInputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('암호화 검사 실패:', err);
|
||||
} finally {
|
||||
checkingArchive.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmExtract = async () => {
|
||||
if (!archiveToExtract.value) return;
|
||||
extractError.value = '';
|
||||
isExtracting.value = true;
|
||||
progressPercent.value = 0;
|
||||
currentExtractingFile.value = '압축 해제 시작 중...';
|
||||
filesDoneCount.value = 0;
|
||||
|
||||
try {
|
||||
await extractArchiveStream({
|
||||
path: archiveToExtract.value.path,
|
||||
password: extractPassword.value,
|
||||
create_subfolder: createSubfolder.value,
|
||||
delete_archive: deleteArchiveAfter.value,
|
||||
onProgress: (p) => {
|
||||
progressPercent.value = p.percent || 0;
|
||||
if (p.current_file) {
|
||||
currentExtractingFile.value = p.current_file;
|
||||
}
|
||||
if (p.files_done !== undefined) {
|
||||
filesDoneCount.value = p.files_done;
|
||||
}
|
||||
},
|
||||
onSuccess: async (res) => {
|
||||
progressPercent.value = 100;
|
||||
emit('notify', { message: res.message || '압축 해제가 완료되었습니다.', type: 'success' });
|
||||
setTimeout(async () => {
|
||||
extractDialog.value = false;
|
||||
archiveToExtract.value = null;
|
||||
isExtracting.value = false;
|
||||
await loadFiles(currentPath.value);
|
||||
}, 600);
|
||||
},
|
||||
onError: (errPayload) => {
|
||||
isExtracting.value = false;
|
||||
const errType = errPayload.error_type || '';
|
||||
const errMsg = errPayload.message || '압축 해제 실패';
|
||||
|
||||
if (errType === 'PASSWORD_REQUIRED' || errType === 'WRONG_PASSWORD') {
|
||||
isEncryptedArchive.value = true;
|
||||
extractError.value = errMsg;
|
||||
nextTick(() => {
|
||||
passwordInputRef.value?.focus();
|
||||
});
|
||||
} else {
|
||||
extractError.value = errMsg;
|
||||
emit('notify', { message: errMsg, type: 'error' });
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
isExtracting.value = false;
|
||||
extractError.value = err.message || '압축 해제 중 네트워크 오류';
|
||||
emit('notify', { message: err.message || '압축 해제 중 오류 발생', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (item) => {
|
||||
if (['video', 'audio', 'image', 'document'].includes(item.category)) {
|
||||
previewItem.value = item;
|
||||
previewDialog.value = true;
|
||||
} else {
|
||||
window.open(getDownloadUrl(item.path), '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const openDelete = (item) => {
|
||||
isBatchDelete.value = false;
|
||||
itemToDelete.value = item;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
if (isBatchDelete.value) {
|
||||
// 선택 항목 일괄 삭제
|
||||
for (const p of selectedPaths.value) {
|
||||
await deleteFileOrFolder(p);
|
||||
}
|
||||
emit('notify', { message: `${selectedPaths.value.length}개 항목 일괄 삭제 완료`, type: 'success' });
|
||||
selectedPaths.value = [];
|
||||
} else if (itemToDelete.value) {
|
||||
await deleteFileOrFolder(itemToDelete.value.path);
|
||||
emit('notify', { message: `'${itemToDelete.value.name}' 삭제 완료`, type: 'success' });
|
||||
}
|
||||
await loadFiles(currentPath.value);
|
||||
} catch (err) {
|
||||
emit('notify', { message: err.message || '삭제 실패', type: 'error' });
|
||||
} finally {
|
||||
deleteDialog.value = false;
|
||||
itemToDelete.value = null;
|
||||
isBatchDelete.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
const d = new Date(timestamp * 1000);
|
||||
return d.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category) => {
|
||||
switch (category) {
|
||||
case 'folder': return { icon: 'mdi-folder', color: 'amber-darken-1' };
|
||||
case 'video': return { icon: 'mdi-file-video', color: 'deep-purple-accent-2' };
|
||||
case 'audio': return { icon: 'mdi-file-music', color: 'green-accent-4' };
|
||||
case 'image': return { icon: 'mdi-file-image', color: 'light-blue-accent-3' };
|
||||
case 'archive': return { icon: 'mdi-folder-zip', color: 'orange-accent-3' };
|
||||
case 'document': return { icon: 'mdi-file-document-outline', color: 'teal-accent-3' };
|
||||
default: return { icon: 'mdi-file-outline', color: 'blue-grey-lighten-1' };
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card rounded="lg" elevation="1" border>
|
||||
<!-- 상단 툴바: 브레드크럼 & 액션 -->
|
||||
<v-card-item class="pb-2">
|
||||
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||||
<div class="d-flex align-center flex-wrap">
|
||||
<v-avatar color="primary" variant="tonal" size="32" class="mr-2">
|
||||
<v-icon icon="mdi-folder-eye-outline" size="20"></v-icon>
|
||||
</v-avatar>
|
||||
<span class="text-h6 font-weight-bold mr-3">다운로드 파일 뷰어</span>
|
||||
|
||||
<!-- 상위 폴더 이동 버튼 -->
|
||||
<v-btn
|
||||
icon="mdi-arrow-up"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
:disabled="!currentPath"
|
||||
title="상위 폴더로 이동"
|
||||
class="mr-2"
|
||||
@click="navigateUp"
|
||||
></v-btn>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2 flex-wrap">
|
||||
<v-chip size="small" variant="tonal" color="secondary" class="font-weight-medium">
|
||||
항목 {{ items.length }}개 • 총 {{ formatBytes(totalSize) }}
|
||||
</v-chip>
|
||||
|
||||
<!-- 🌟 현재 폴더 전체 ZIP 다운로드 버튼 -->
|
||||
<v-btn
|
||||
v-if="currentPath && items.length > 0"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-folder-zip-outline"
|
||||
:href="getFolderZipUrl(currentPath)"
|
||||
target="_blank"
|
||||
title="현재 폴더 안의 모든 파일을 하나의 ZIP으로 묶어서 다운로드"
|
||||
>
|
||||
폴더 전체 ZIP 받기
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="loading"
|
||||
title="새로고침"
|
||||
@click="loadFiles(currentPath)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 브레드크럼 경로 네비게이션 -->
|
||||
<div class="d-flex align-center flex-wrap mt-3 px-2 py-1 bg-surface-variant rounded text-caption">
|
||||
<v-icon icon="mdi-home" size="16" class="mr-1 text-medium-emphasis"></v-icon>
|
||||
<template v-for="(crumb, idx) in breadcrumbs" :key="crumb.path">
|
||||
<span
|
||||
class="crumb-link font-weight-medium cursor-pointer"
|
||||
:class="{ 'text-primary font-weight-bold': idx === breadcrumbs.length - 1 }"
|
||||
@click="navigateTo(crumb.path)"
|
||||
>
|
||||
{{ crumb.title }}
|
||||
</span>
|
||||
<span v-if="idx < breadcrumbs.length - 1" class="mx-1 text-medium-emphasis">/</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 🌟 다중 선택 시 나타나는 일괄 액션 바 -->
|
||||
<v-slide-y-transition>
|
||||
<div
|
||||
v-if="selectedPaths.length > 0"
|
||||
class="d-flex align-center justify-space-between flex-wrap gap-2 mt-3 pa-2 rounded bg-primary-container"
|
||||
>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-icon icon="mdi-checkbox-marked-circle" color="primary"></v-icon>
|
||||
<span class="text-body-2 font-weight-bold">
|
||||
{{ selectedPaths.length }}개 항목 선택됨
|
||||
</span>
|
||||
<span v-if="selectedTotalSize > 0" class="text-caption text-medium-emphasis">
|
||||
({{ formatBytes(selectedTotalSize) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2">
|
||||
<!-- 선택 항목 ZIP 다운로드 버튼 -->
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
prepend-icon="mdi-archive-arrow-down"
|
||||
:loading="isBatchDownloading"
|
||||
@click="handleBatchDownload"
|
||||
>
|
||||
선택 항목 ZIP 다운로드
|
||||
</v-btn>
|
||||
|
||||
<!-- 선택 항목 일괄 삭제 버튼 -->
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
@click="openBatchDelete"
|
||||
>
|
||||
일괄 삭제
|
||||
</v-btn>
|
||||
|
||||
<!-- 선택 해제 -->
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="selectedPaths = []"
|
||||
>
|
||||
선택 해제
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card-item>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- 파일 테이블 / 리스트 -->
|
||||
<v-card-text class="pa-0">
|
||||
<div v-if="loading && items.length === 0" class="text-center py-10">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<div class="text-caption text-medium-emphasis mt-2">파일 목록 불러오는 중...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="text-center py-10">
|
||||
<v-icon icon="mdi-folder-open-outline" size="56" color="secondary" class="mb-2 opacity-50"></v-icon>
|
||||
<div class="text-subtitle-2 font-weight-medium">폴더가 비어 있습니다.</div>
|
||||
</div>
|
||||
|
||||
<v-table v-else hover density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- 전체 선택 체크박스 열 -->
|
||||
<th class="text-center" style="width: 48px;">
|
||||
<v-checkbox-btn
|
||||
:model-value="isAllSelected"
|
||||
:indeterminate="isSomeSelected"
|
||||
color="primary"
|
||||
@click.stop="toggleSelectAll"
|
||||
></v-checkbox-btn>
|
||||
</th>
|
||||
<th class="text-left" style="min-width: 250px;">이름</th>
|
||||
<th class="text-right" style="width: 120px;">크기</th>
|
||||
<th class="text-left d-none d-md-table-cell" style="width: 180px;">수정일시</th>
|
||||
<th class="text-center" style="width: 160px;">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.path"
|
||||
class="file-row"
|
||||
:class="{ 'selected-row': selectedPaths.includes(item.path) }"
|
||||
>
|
||||
<!-- 개별 선택 체크박스 -->
|
||||
<td class="text-center" @click.stop>
|
||||
<v-checkbox-btn
|
||||
v-model="selectedPaths"
|
||||
:value="item.path"
|
||||
color="primary"
|
||||
></v-checkbox-btn>
|
||||
</td>
|
||||
|
||||
<!-- 파일/폴더 이름 & 아이콘 -->
|
||||
<td>
|
||||
<div
|
||||
class="d-flex align-center cursor-pointer py-1"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<v-icon
|
||||
:icon="getCategoryIcon(item.category).icon"
|
||||
:color="getCategoryIcon(item.category).color"
|
||||
class="mr-3 flex-shrink-0"
|
||||
size="22"
|
||||
></v-icon>
|
||||
<span class="text-body-2 font-weight-medium text-truncate" :title="item.name">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<v-chip
|
||||
v-if="item.category === 'archive'"
|
||||
size="x-small"
|
||||
color="orange"
|
||||
variant="outlined"
|
||||
class="ml-2 flex-shrink-0"
|
||||
>
|
||||
압축 파일
|
||||
</v-chip>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 크기 -->
|
||||
<td class="text-right text-caption text-medium-emphasis">
|
||||
<span v-if="!item.is_dir">{{ formatBytes(item.size) }}</span>
|
||||
<span v-else class="text-disabled">-</span>
|
||||
</td>
|
||||
|
||||
<!-- 수정일시 -->
|
||||
<td class="text-left text-caption text-medium-emphasis d-none d-md-table-cell">
|
||||
{{ formatDate(item.modified) }}
|
||||
</td>
|
||||
|
||||
<!-- 작업 버튼 -->
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-center justify-center gap-1">
|
||||
<!-- 폴더 열기 -->
|
||||
<v-btn
|
||||
v-if="item.is_dir"
|
||||
icon="mdi-folder-open"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
title="폴더 열기"
|
||||
@click.stop="loadFiles(item.path)"
|
||||
></v-btn>
|
||||
|
||||
<!-- 🌟 폴더 ZIP 다운로드 버튼 -->
|
||||
<v-btn
|
||||
v-if="item.is_dir"
|
||||
icon="mdi-folder-zip-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="teal"
|
||||
title="이 폴더를 ZIP으로 한 번에 다운로드"
|
||||
:href="getFolderZipUrl(item.path)"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
|
||||
<!-- 압축 풀기 버튼 (압축 파일 전용) -->
|
||||
<v-btn
|
||||
v-if="item.category === 'archive'"
|
||||
icon="mdi-archive-arrow-down-outline"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="orange-darken-1"
|
||||
title="압축 풀기"
|
||||
@click.stop="openExtract(item)"
|
||||
></v-btn>
|
||||
|
||||
<!-- 개별 파일 다운로드 -->
|
||||
<v-btn
|
||||
v-if="!item.is_dir"
|
||||
icon="mdi-download"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
title="다운로드"
|
||||
:href="getDownloadUrl(item.path)"
|
||||
target="_blank"
|
||||
></v-btn>
|
||||
|
||||
<!-- 미디어 미리보기 -->
|
||||
<v-btn
|
||||
v-if="!item.is_dir && ['video', 'audio', 'image', 'document'].includes(item.category)"
|
||||
icon="mdi-eye-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="info"
|
||||
title="미리보기 / 재생"
|
||||
@click.stop="handlePreview(item)"
|
||||
></v-btn>
|
||||
|
||||
<!-- 삭제 -->
|
||||
<v-btn
|
||||
icon="mdi-trash-can-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="error"
|
||||
title="삭제"
|
||||
@click.stop="openDelete(item)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 📦 압축 풀기 확인 & 실시간 진행률 다이얼로그 -->
|
||||
<v-dialog v-model="extractDialog" max-width="540" :persistent="isExtracting">
|
||||
<v-card rounded="lg">
|
||||
<v-card-item class="pb-2">
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-archive-arrow-down-outline" color="orange-darken-1" size="28" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
압축 파일 풀기
|
||||
</v-card-title>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<!-- 파일 정보 표시 -->
|
||||
<div class="pa-3 rounded bg-surface-variant text-body-2 font-weight-medium text-truncate mb-3">
|
||||
📦 {{ archiveToExtract?.name }}
|
||||
<span class="text-caption text-medium-emphasis ml-2">({{ formatBytes(archiveToExtract?.size) }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 암호화된 파일 감지 알림 -->
|
||||
<v-alert
|
||||
v-if="isEncryptedArchive"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
icon="mdi-lock-alert"
|
||||
class="mb-3 text-caption"
|
||||
>
|
||||
<strong>암호로 보호된 파일:</strong> 이 파일의 압축을 해제하려면 올바른 암호(비밀번호)를 입력해야 합니다.
|
||||
</v-alert>
|
||||
|
||||
<!-- 암호 입력 필드 -->
|
||||
<div class="mb-3">
|
||||
<v-text-field
|
||||
ref="passwordInputRef"
|
||||
v-model="extractPassword"
|
||||
:label="isEncryptedArchive ? '압축 해제 암호 (필수)' : '압축 해제 암호 (암호가 걸려있는 경우 입력)'"
|
||||
placeholder="비밀번호 입력"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:error="!!extractError"
|
||||
:error-messages="extractError"
|
||||
:disabled="isExtracting"
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
@keyup.enter="confirmExtract"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- 옵션 체크박스들 -->
|
||||
<v-checkbox
|
||||
v-model="createSubfolder"
|
||||
label="새 하위 폴더를 생성하여 압축 풀기"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-1"
|
||||
:disabled="isExtracting"
|
||||
></v-checkbox>
|
||||
|
||||
<v-checkbox
|
||||
v-model="deleteArchiveAfter"
|
||||
label="압축 해제 완료 후 원본 압축 파일 삭제"
|
||||
color="error"
|
||||
density="compact"
|
||||
hide-details
|
||||
:disabled="isExtracting"
|
||||
></v-checkbox>
|
||||
|
||||
<!-- 🚀 실시간 압축 해제 진행 과정 (Progress) -->
|
||||
<div v-if="isExtracting" class="mt-4 pt-3 border-t">
|
||||
<div class="d-flex justify-space-between align-center mb-1">
|
||||
<span class="text-caption font-weight-bold d-flex align-center">
|
||||
<v-icon icon="mdi-progress-download" size="16" class="mr-1 text-orange-darken-1 rotating"></v-icon>
|
||||
<span>압축 해제 진행 중</span>
|
||||
</span>
|
||||
<span class="text-subtitle-2 font-weight-black text-orange-darken-1">
|
||||
{{ progressPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 실시간 진행률 프로그레스 바 -->
|
||||
<v-progress-linear
|
||||
:model-value="progressPercent"
|
||||
color="orange-darken-1"
|
||||
height="20"
|
||||
rounded
|
||||
striped
|
||||
class="mb-2"
|
||||
>
|
||||
<template #default="{ value }">
|
||||
<strong class="text-caption text-white font-weight-bold">{{ Math.ceil(value) }}%</strong>
|
||||
</template>
|
||||
</v-progress-linear>
|
||||
|
||||
<!-- 현재 풀고 있는 파일명 및 처리 개수 -->
|
||||
<div class="d-flex justify-space-between align-center text-caption text-medium-emphasis">
|
||||
<div class="text-truncate mr-2" style="max-width: 360px;" :title="currentExtractingFile">
|
||||
<v-icon icon="mdi-file-outline" size="14" class="mr-1"></v-icon>
|
||||
<span class="font-weight-medium">{{ currentExtractingFile || '처리 중...' }}</span>
|
||||
</div>
|
||||
<div v-if="filesDoneCount > 0" class="flex-shrink-0 text-caption font-weight-medium">
|
||||
{{ filesDoneCount }}개 파일 처리됨
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="px-4 py-3">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="secondary"
|
||||
:disabled="isExtracting"
|
||||
@click="extractDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="orange-darken-1"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-archive-arrow-down"
|
||||
:loading="isExtracting"
|
||||
@click="confirmExtract"
|
||||
>
|
||||
압축 풀기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 (단일 / 일괄 공용) -->
|
||||
<v-dialog v-model="deleteDialog" max-width="460">
|
||||
<v-card rounded="lg">
|
||||
<v-card-item class="pb-2">
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-alert-circle-outline" color="error" size="28" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
{{ isBatchDelete ? '선택 항목 일괄 삭제' : (itemToDelete?.is_dir ? '폴더' : '파일') + ' 삭제 확인' }}
|
||||
</v-card-title>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<div v-if="isBatchDelete">
|
||||
<p class="mb-2">선택한 <strong>{{ selectedPaths.length }}개</strong>의 항목을 디스크에서 모두 삭제하시겠습니까?</p>
|
||||
<div class="text-caption text-error">
|
||||
⚠️ 선택한 항목 중 폴더가 포함된 경우 하위 모든 파일도 함께 영구 삭제됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="mb-2">정말 다음 {{ itemToDelete?.is_dir ? '폴더' : '파일' }}을 디스크에서 삭제하시겠습니까?</p>
|
||||
<div class="pa-3 rounded bg-surface-variant text-body-2 font-weight-medium text-truncate">
|
||||
{{ itemToDelete?.name }}
|
||||
</div>
|
||||
<div v-if="itemToDelete?.is_dir" class="text-caption text-error mt-2">
|
||||
⚠️ 폴더 안의 모든 파일과 하위 폴더도 함께 영구 삭제됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="px-4 py-3">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="secondary" @click="deleteDialog = false">
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn color="error" variant="elevated" prepend-icon="mdi-delete" @click="confirmDelete">
|
||||
삭제하기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 미디어 미리보기 모달 다이얼로그 -->
|
||||
<v-dialog v-model="previewDialog" max-width="850">
|
||||
<v-card rounded="lg">
|
||||
<v-card-item class="pb-2">
|
||||
<div class="d-flex align-center justify-space-between w-100">
|
||||
<div class="d-flex align-center text-truncate mr-2">
|
||||
<v-icon
|
||||
:icon="getCategoryIcon(previewItem?.category).icon"
|
||||
:color="getCategoryIcon(previewItem?.category).color"
|
||||
class="mr-2"
|
||||
></v-icon>
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold text-truncate pa-0">
|
||||
{{ previewItem?.name }}
|
||||
</v-card-title>
|
||||
</div>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="previewDialog = false"></v-btn>
|
||||
</div>
|
||||
</v-card-item>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-4 d-flex justify-center align-center" style="min-height: 250px; background: #000;">
|
||||
<!-- 비디오 플레이어 -->
|
||||
<video
|
||||
v-if="previewItem?.category === 'video'"
|
||||
:src="getDownloadUrl(previewItem.path)"
|
||||
controls
|
||||
autoplay
|
||||
style="max-width: 100%; max-height: 60vh; border-radius: 4px;"
|
||||
></video>
|
||||
|
||||
<!-- 오디오 플레이어 -->
|
||||
<audio
|
||||
v-else-if="previewItem?.category === 'audio'"
|
||||
:src="getDownloadUrl(previewItem.path)"
|
||||
controls
|
||||
autoplay
|
||||
style="width: 100%; max-width: 500px;"
|
||||
></audio>
|
||||
|
||||
<!-- 이미지 뷰어 -->
|
||||
<img
|
||||
v-else-if="previewItem?.category === 'image'"
|
||||
:src="getDownloadUrl(previewItem.path)"
|
||||
style="max-width: 100%; max-height: 65vh; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
|
||||
<!-- 문서/텍스트 iframe -->
|
||||
<iframe
|
||||
v-else-if="previewItem?.category === 'document'"
|
||||
:src="getDownloadUrl(previewItem.path)"
|
||||
style="width: 100%; height: 60vh; border: none; background: #fff;"
|
||||
></iframe>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="px-4 py-2">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
크기: {{ formatBytes(previewItem?.size) }}
|
||||
</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-download"
|
||||
size="small"
|
||||
:href="getDownloadUrl(previewItem?.path)"
|
||||
target="_blank"
|
||||
>
|
||||
다운로드
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-row:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
.selected-row {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08) !important;
|
||||
}
|
||||
.bg-primary-container {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.border-t {
|
||||
border-top: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.rotating {
|
||||
animation: rotate 1.8s linear infinite;
|
||||
}
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: Object,
|
||||
default: () => ({ connected: false, version: '', torrents_count: 0 }),
|
||||
},
|
||||
loading: Boolean,
|
||||
currentTab: {
|
||||
type: String,
|
||||
default: 'torrents',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['refresh', 'toggleDrawer']);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar flat border density="comfortable" class="px-2">
|
||||
<!-- 사이드 메뉴 토글 버튼 -->
|
||||
<v-app-bar-nav-icon
|
||||
variant="text"
|
||||
@click="$emit('toggleDrawer')"
|
||||
title="사이드 메뉴 토글"
|
||||
></v-app-bar-nav-icon>
|
||||
|
||||
<!-- 로고 & 제목 -->
|
||||
<div class="d-flex align-center ml-1">
|
||||
<v-avatar color="primary" variant="tonal" size="34" class="mr-3">
|
||||
<v-icon :icon="currentTab === 'torrents' ? 'mdi-magnet' : 'mdi-folder-eye-outline'" size="20"></v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-bold" style="line-height: 1.2;">
|
||||
{{ currentTab === 'torrents' ? '토렌트 관리' : '다운로드 파일 뷰어' }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis d-none d-sm-block">
|
||||
Transmission & Local File Manager
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- 연결 상태 칩 -->
|
||||
<v-chip
|
||||
:color="status.connected ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="mr-2 font-weight-medium"
|
||||
:prepend-icon="status.connected ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
>
|
||||
<span v-if="status.connected">
|
||||
Transmission v{{ status.version || '연결됨' }}
|
||||
</span>
|
||||
<span v-else>연결 끊김</span>
|
||||
</v-chip>
|
||||
|
||||
<!-- 테마 토글 버튼 -->
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="toggleTheme"
|
||||
title="테마 변경"
|
||||
class="mr-1"
|
||||
>
|
||||
<v-icon :icon="theme.global.current.value.dark ? 'mdi-weather-night' : 'mdi-weather-sunny'"></v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- 새로고침 버튼 -->
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
title="새로고침"
|
||||
></v-btn>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const magnet = ref('');
|
||||
const downloadDir = ref('');
|
||||
const paused = ref(false);
|
||||
const showAdvanced = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
magnet.value = text.trim();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('클립보드 접근 권한이 없거나 지원되지 않습니다.', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = magnet.value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await emit('add', {
|
||||
magnet: trimmed,
|
||||
download_dir: downloadDir.value.trim() || undefined,
|
||||
paused: paused.value,
|
||||
});
|
||||
magnet.value = '';
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card rounded="lg" elevation="1" border class="mb-4">
|
||||
<v-card-item class="pb-2">
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-plus-circle-outline" color="primary" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
마그넷 링크 추가
|
||||
</v-card-title>
|
||||
<v-card-subtitle>
|
||||
Magnet URI 또는 토렌트 URL을 입력하여 Transmission에 추가합니다.
|
||||
</v-card-subtitle>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text class="pt-2">
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<!-- 마그넷 입력창 -->
|
||||
<v-text-field
|
||||
v-model="magnet"
|
||||
label="마그넷 링크 또는 토렌트 주소"
|
||||
placeholder="magnet:?xt=urn:btih:... 또는 http://.../.torrent"
|
||||
prepend-inner-icon="mdi-magnet"
|
||||
append-inner-icon="mdi-content-paste"
|
||||
clearable
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:disabled="isSubmitting"
|
||||
@click:append-inner="handlePaste"
|
||||
>
|
||||
<template #append>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="large"
|
||||
height="48"
|
||||
class="px-5 font-weight-bold"
|
||||
:disabled="!magnet.trim() || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
prepend-icon="mdi-download"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
추가
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<!-- 고급 옵션 토글 영역 -->
|
||||
<v-expand-transition>
|
||||
<div v-if="showAdvanced" class="mt-4 pt-4 border-t">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="8">
|
||||
<v-text-field
|
||||
v-model="downloadDir"
|
||||
label="저장 디렉토리 (선택)"
|
||||
placeholder="/downloads"
|
||||
prepend-inner-icon="mdi-folder-download-outline"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="비워둘 시 Transmission 기본 다운로드 폴더를 사용합니다."
|
||||
persistent-hint
|
||||
:disabled="isSubmitting"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="d-flex align-center">
|
||||
<v-switch
|
||||
v-model="paused"
|
||||
label="추가 후 일시정지 상태 유지"
|
||||
color="warning"
|
||||
hide-details
|
||||
density="compact"
|
||||
:disabled="isSubmitting"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="px-4 py-2">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
color="secondary"
|
||||
:prepend-icon="showAdvanced ? 'mdi-chevron-up' : 'mdi-cog-outline'"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
{{ showAdvanced ? '고급 옵션 닫기' : '고급 옵션 (저장 경로 / 일시정지)' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.border-t {
|
||||
border-top: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
torrents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['start', 'stop', 'remove']);
|
||||
|
||||
// 삭제 다이얼로그 상태
|
||||
const deleteDialog = ref(false);
|
||||
const selectedTorrent = ref(null);
|
||||
const deleteLocalData = ref(false);
|
||||
|
||||
const openDeleteDialog = (torrent) => {
|
||||
selectedTorrent.value = torrent;
|
||||
deleteLocalData.value = false;
|
||||
deleteDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (selectedTorrent.value) {
|
||||
emit('remove', {
|
||||
id: selectedTorrent.value.id,
|
||||
deleteData: deleteLocalData.value,
|
||||
});
|
||||
}
|
||||
deleteDialog.value = false;
|
||||
selectedTorrent.value = null;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatSpeed = (bytesPerSec) => {
|
||||
if (!bytesPerSec || bytesPerSec === 0) return '0 KB/s';
|
||||
return `${formatBytes(bytesPerSec)}/s`;
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 4: return 'primary'; // 다운로드 중
|
||||
case 6: return 'success'; // 시딩 중
|
||||
case 0: return 'secondary'; // 일시정지됨
|
||||
default: return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 4: return 'mdi-download';
|
||||
case 6: return 'mdi-check-all';
|
||||
case 0: return 'mdi-pause';
|
||||
default: return 'mdi-clock-outline';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card rounded="lg" elevation="1" border>
|
||||
<v-card-item class="pb-2">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon icon="mdi-format-list-bulleted" color="primary" class="mr-2"></v-icon>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
토렌트 목록
|
||||
</v-card-title>
|
||||
<v-chip size="small" variant="tonal" color="primary" class="ml-2 font-weight-bold">
|
||||
{{ torrents.length }}개
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- 빈 목록일 때 -->
|
||||
<div v-if="torrents.length === 0" class="text-center py-10">
|
||||
<v-icon icon="mdi-folder-open-outline" size="64" color="secondary" class="mb-3 opacity-60"></v-icon>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
현재 다운로드 또는 시딩 중인 토렌트가 없습니다.
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
상단의 입력창에 마그넷 링크를 입력하여 다운로드를 시작해보세요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토렌트 목록 카드들 -->
|
||||
<div v-else class="d-flex flex-column gap-3">
|
||||
<v-card
|
||||
v-for="torrent in torrents"
|
||||
:key="torrent.id"
|
||||
variant="outlined"
|
||||
rounded="lg"
|
||||
class="pa-4 bg-surface"
|
||||
>
|
||||
<!-- 헤더: 상태 칩, 파일명, 액션 버튼들 -->
|
||||
<div class="d-flex align-start justify-space-between flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex align-center flex-grow-1 mr-2" style="min-width: 260px;">
|
||||
<v-chip
|
||||
:color="getStatusColor(torrent.status)"
|
||||
:prepend-icon="getStatusIcon(torrent.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="font-weight-medium mr-2 flex-shrink-0"
|
||||
>
|
||||
{{ torrent.status_label }}
|
||||
</v-chip>
|
||||
<span class="text-subtitle-2 font-weight-bold text-truncate" :title="torrent.name">
|
||||
{{ torrent.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="d-flex align-center gap-1">
|
||||
<!-- 시작 / 일시정지 -->
|
||||
<v-btn
|
||||
v-if="torrent.status === 0"
|
||||
icon="mdi-play"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
title="다운로드 재개"
|
||||
@click="$emit('start', torrent.id)"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
icon="mdi-pause"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="warning"
|
||||
title="일시정지"
|
||||
@click="$emit('stop', torrent.id)"
|
||||
></v-btn>
|
||||
|
||||
<!-- 삭제 버튼 (다이얼로그 오픈) -->
|
||||
<v-btn
|
||||
icon="mdi-trash-can-outline"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
title="삭제"
|
||||
class="ml-1"
|
||||
@click="openDeleteDialog(torrent)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행률 바 -->
|
||||
<v-progress-linear
|
||||
:model-value="torrent.percent_done"
|
||||
:color="torrent.percent_done === 100 ? 'success' : torrent.status === 0 ? 'secondary' : 'primary'"
|
||||
height="8"
|
||||
rounded
|
||||
striped
|
||||
class="my-2"
|
||||
></v-progress-linear>
|
||||
|
||||
<!-- 메타 정보: 진행률, 전체 용량, 다운로드/업로드 속도 -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap text-caption text-medium-emphasis mt-2">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="font-weight-bold text-high-emphasis">
|
||||
{{ torrent.percent_done }}%
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{{ formatBytes(torrent.total_size) }}</span>
|
||||
<span v-if="torrent.download_dir" class="text-truncate d-none d-sm-inline" style="max-width: 250px;" :title="torrent.download_dir">
|
||||
({{ torrent.download_dir }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-3">
|
||||
<span v-if="torrent.rate_download > 0" class="text-primary font-weight-medium">
|
||||
<v-icon icon="mdi-arrow-down-bold" size="14"></v-icon>
|
||||
{{ formatSpeed(torrent.rate_download) }}
|
||||
</span>
|
||||
<span v-if="torrent.rate_upload > 0" class="text-success font-weight-medium">
|
||||
<v-icon icon="mdi-arrow-up-bold" size="14"></v-icon>
|
||||
{{ formatSpeed(torrent.rate_upload) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 삭제 확인 모달 다이얼로그 -->
|
||||
<v-dialog v-model="deleteDialog" max-width="480">
|
||||
<v-card rounded="lg">
|
||||
<v-card-item class="pb-2">
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-alert-circle-outline" color="error" size="28" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-card-title class="text-h6 font-weight-bold">
|
||||
토렌트 삭제 확인
|
||||
</v-card-title>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<div class="mb-3">
|
||||
다음 토렌트를 삭제하시겠습니까?
|
||||
</div>
|
||||
<div class="pa-3 rounded bg-surface-variant text-body-2 font-weight-medium text-truncate mb-3">
|
||||
{{ selectedTorrent?.name }}
|
||||
</div>
|
||||
|
||||
<!-- 로컬 데이터 삭제 체크박스 -->
|
||||
<v-checkbox
|
||||
v-model="deleteLocalData"
|
||||
label="디스크에 다운로드된 파일도 함께 완전히 삭제"
|
||||
color="error"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="px-4 py-3">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="secondary" @click="deleteDialog = false">
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn color="error" variant="elevated" prepend-icon="mdi-delete" @click="confirmDelete">
|
||||
삭제하기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
// Vuetify
|
||||
import 'vuetify/styles'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
accent: '#3b82f6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
background: '#0f172a',
|
||||
surface: '#1e293b',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b',
|
||||
accent: '#3b82f6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
background: '#f8fafc',
|
||||
surface: '#ffffff',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(vuetify)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,9 @@
|
||||
:root {
|
||||
--font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
Reference in new issue
Block a user