This commit is contained in:
simva committed 2026-10-23 12:05:26 +09:00
commit d9ef64eafe
35 files changed
+4733

No files matched your search

+281
View File
@@ -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>
+141
View File
@@ -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);
}
+59
View File
@@ -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;
}
+885
View File
@@ -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>
+87
View File
@@ -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>
+141
View File
@@ -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>
+247
View File
@@ -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>
+49
View File
@@ -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')
+9
View File
@@ -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;
}