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

+30
View File
@@ -0,0 +1,30 @@
name: Build and Deploy
on:
push:
branches:
- main
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Build Frontend (Vue 3 + Vuetify)
run: |
cd frontend
npm install
npm run build
- name: Deploy with Docker Compose
run: |
# act_runner에 마운트된 docker.sock을 통해 호스트의 컨테이너를 무중단 재빌드 및 재시작
docker compose up -d --build
+20
View File
@@ -0,0 +1,20 @@
# Python
venv/
bin/
include/
lib/
lib64
pyvenv.cfg
__pycache__/
*.py[cod]
*$py.class
*.env
scratch*
# Node / Vue
frontend/node_modules/
frontend/.vite/
# System
.DS_Store
*.log
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.12-slim
WORKDIR /app
# 7z 압축 해제 도구 설치
RUN apt-get update && apt-get install -y --no-install-recommends \
p7zip-full \
curl \
&& rm -rf /var/lib/apt/lists/*
# 파이썬 의존성 설치
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 백엔드 및 빌드된 프론트엔드 정적 파일 복사
COPY backend/ ./backend/
COPY frontend/dist/ ./frontend/dist/
COPY main.py run.py ./
EXPOSE 8000
ENV TRANSMISSION_HOST=127.0.0.1
ENV TRANSMISSION_PORT=9091
ENV DOWNLOADS_DIR=/downloads
CMD ["python", "run.py"]
+154
View File
@@ -0,0 +1,154 @@
# 🧲 Transmission Magnet Manager
현재 호스트에 Docker로 구동 중인 **Transmission (`linuxserver/transmission`, 포트 9091)** 에 마그넷 링크를 원격으로 추가하고 다운로드 상태를 관리하는 **FastAPI 백엔드 + Vue 3 프론트엔드** 애플리케이션입니다.
---
## 🛠 기술 스택
- **Backend**: Python 3.14, FastAPI, Uvicorn, HTTPX, Pydantic
- **Frontend**: Vue 3 (Composition API, `<script setup>`), Vuetify 3, Material Design Icons (`@mdi/font`), Vite
- **Torrent Engine**: 호스트 Docker 컨테이너 (`dd7da08820de`, Transmission RPC 9091)
---
## 🚀 빠른 시작
### 1. 단일 웹 서버 모드 (권장)
프론트엔드 정적 파일이 빌드되어 포함되어 있으므로 단일 커맨드로 백엔드와 프론트엔드가 모두 서빙됩니다.
```bash
# 가상환경 활성화 후 실행
./venv/bin/python main.py
```
브라우저에서 **[http://localhost:8000](http://localhost:8000)** 으로 접속하세요.
### 2. 창(Desktop Window) 모드
`pywebview`를 통해 독립된 데스크톱 앱 창 형태로 실행할 수도 있습니다.
```bash
./venv/bin/python main.py --window
```
---
## 💻 개발 모드 (HMR & 실시간 반영)
### Backend 개별 실행
```bash
./venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload
```
### Frontend 개별 실행
```bash
cd frontend
npm run dev
```
- 프론트엔드 개발 서버: `http://localhost:5173` (백엔드 `http://localhost:8000`으로 자동 프록시)
### Frontend 프로덕션 재빌드
프론트엔드 코드를 수정한 후 빌드하려면:
```bash
cd frontend
npm run build
```
---
## 🚢 실서버 배포 가이드 (Deployment)
이 프로젝트는 프론트엔드(`frontend/dist`)가 이미 백엔드(`FastAPI`)에 통합되어 빌드되어 있으므로, **포트 `8000`번 하나만 띄우면 웹 UI와 모든 API가 완전히 동작**합니다.
환경에 맞는 배포 방식을 선택하세요:
### 방법 1. Systemd 서비스 등록 (가장 추천: 홈서버/라즈베리파이/Linux)
서버 부팅 시 자동으로 백그라운드에서 실행되도록 등록합니다.
```bash
# 1. 서비스 파일을 systemd 경로로 복사
sudo cp transmission-manager.service /etc/systemd/system/
# 2. 서비스 데몬 리로드 및 부팅 시 자동 시작 활성화
sudo systemctl daemon-reload
sudo systemctl enable transmission-manager
# 3. 서비스 시작
sudo systemctl start transmission-manager
# 4. 상태 확인
sudo systemctl status transmission-manager
```
- 중지: `sudo systemctl stop transmission-manager`
- 로그 확인: `journalctl -u transmission-manager -f`
---
### 방법 2. Docker / Docker Compose 배포
도커 컨테이너로 완전 격리하여 배포할 수 있습니다.
```bash
# 컨테이너 빌드 및 백그라운드 실행
docker compose up -d --build
```
- 로그 확인: `docker compose logs -f`
- 컨테이너 종료: `docker compose down`
---
### 방법 3. Nginx 역방향 프록시 (외부 접속 & 도메인/HTTPS 연결)
도메인을 연결하거나 80/443 포트로 외부 접속을 열고자 할 때 Nginx 설정 예시:
```nginx
server {
listen 80;
server_name my-torrent.example.com; # 도메인 또는 IP
# 대용량 파일 다운로드 및 업로드 타임아웃 방지
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE 실시간 진행률 스트리밍 지원 필수 설정
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
}
}
```
---
## 📌 주요 기능
1. **사이드 네비게이션 메뉴 (`v-navigation-drawer`)**
- **토렌트 관리**: 마그넷 추가 및 활성 토렌트 모니터링 (실시간 활성 토렌트 개수 뱃지 표시)
- **다운로드 파일 뷰어**: 다운로드 완료 디렉토리(`/mnt/ssd/transmission/downloads`) 파일 브라우저
2. **마그넷 링크 추가 (`POST /api/torrents/add`)**
- `magnet:?xt=urn:...` 및 토렌트 URL 추가
- 원클릭 클립보드 붙여넣기 지원
- 저장 경로 커스텀 지정 옵션 (비워둘 시 기본 다운로드 경로 `/downloads/complete` 사용)
- 추가 즉시 일시정지 옵션
3. **실시간 토렌트 현황 모니터링 (`GET /api/torrents`)**
- 파일명, 다운로드 진행률 (%), 파일 크기
- 실시간 다운로드 / 업로드 속도 표시
- 4초 주기 자동 새로고침 및 수동 새로고침
4. **토렌트 제어**
- 일시정지 (`POST /api/torrents/{id}/stop`)
- 다운로드 시작/재개 (`POST /api/torrents/{id}/start`)
- 토렌트 삭제 (`DELETE /api/torrents/{id}`: 데이터 보존 제거 또는 디스크 데이터 완전 삭제 지원)
5. **다운로드 파일 뷰어 (`GET /api/files`)**
- 브레드크럼(경로 이동) 및 상위 폴더 이동 지원
- 파일 확장자별 자동 분류 아이콘 (폴더, 압축파일, 동영상, 음악, 이미지, 문서 등)
- 파일 개별 다운로드 및 미디어 미리보기(동영상/오디오/이미지/문서 스트리밍 뷰어) 지원
- **파일 한 번에 다운로드 (일괄 ZIP 다운로드)**:
- **폴더 전체 다운로드**: 현재 폴더 또는 하위 폴더를 통째로 하나의 `.zip`으로 묶어 한 번에 다운로드 (`GET /api/files/download-folder`)
- **체크박스 다중 선택 다운로드**: 원하는 파일/폴더들을 체크박스로 다중 선택하여 하나의 `.zip` 파일로 묶어서 일괄 다운로드 (`POST /api/files/download-batch`)
- **선택 항목 일괄 삭제**: 체크한 파일들을 한 번에 디스크에서 삭제 지원
- **압축 파일 풀기 & 실시간 진행 과정 (`POST /api/files/extract-stream`)**:
- 암호화 압축 파일 초고속 사전 감지 및 암호(비밀번호) 입력 지원
- SSE(Server-Sent Events) 기반 **실시간 압축 해제 진행률(0~100%) 프로그레스 바**, 처리 중인 파일명, 완료 파일 개수 실시간 표시
- 파일/폴더 삭제 지원
6. **연결 상태 모니터링 (`GET /api/status`)**
- Transmission 컨테이너와의 RPC 통신 상태 및 버전 표시
+21
View File
@@ -0,0 +1,21 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
TRANSMISSION_HOST: str = os.getenv("TRANSMISSION_HOST", "127.0.0.1")
TRANSMISSION_PORT: int = int(os.getenv("TRANSMISSION_PORT", "9091"))
TRANSMISSION_PATH: str = os.getenv("TRANSMISSION_PATH", "/transmission/rpc")
TRANSMISSION_USER: str = os.getenv("TRANSMISSION_USER", "")
TRANSMISSION_PASSWORD: str = os.getenv("TRANSMISSION_PASSWORD", "")
# 다운로드 디렉토리 기본 경로 (Transmission 볼륨 마운트 경로)
DOWNLOADS_BASE_PATH: str = os.getenv("DOWNLOADS_DIR", "/mnt/ssd/transmission/downloads")
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
@property
def rpc_url(self) -> str:
return f"http://{self.TRANSMISSION_HOST}:{self.TRANSMISSION_PORT}{self.TRANSMISSION_PATH}"
settings = Settings()
+462
View File
@@ -0,0 +1,462 @@
import os
import shutil
import asyncio
import re
import json
import tempfile
import zipfile
from pathlib import Path
from typing import List, Dict, Any, Optional, AsyncGenerator, Tuple
from fastapi import HTTPException
from .config import settings
CATEGORY_EXTENSIONS = {
"video": [".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts"],
"audio": [".mp3", ".flac", ".wav", ".aac", ".ogg", ".m4a", ".wma"],
"image": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
"archive": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".iso"],
"document": [".pdf", ".txt", ".md", ".csv", ".json", ".xml", ".html", ".smi", ".srt", ".vtt"],
}
def get_category(ext: str) -> str:
ext_lower = ext.lower()
for cat, exts in CATEGORY_EXTENSIONS.items():
if ext_lower in exts:
return cat
return "other"
def resolve_secure_path(sub_path: str = "") -> Path:
"""Path traversal 방지 및 경로 확인"""
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
if not base_dir.exists():
# 디렉토리가 없을 경우 생성 시도
base_dir.mkdir(parents=True, exist_ok=True)
# 상대 경로 처리
target_path = (base_dir / sub_path.lstrip("/")).resolve()
try:
# base_dir 내부 경로인지 확인
target_path.relative_to(base_dir)
except ValueError:
raise HTTPException(status_code=403, detail="접근이 제한된 잘못된 경로입니다.")
if not target_path.exists():
raise HTTPException(status_code=404, detail="요청한 파일 또는 폴더를 찾을 수 없습니다.")
return target_path
def list_directory(sub_path: str = "") -> Dict[str, Any]:
"""디렉토리 내 파일 및 폴더 목록 조회"""
target_path = resolve_secure_path(sub_path)
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
if not target_path.is_dir():
raise HTTPException(status_code=400, detail="디렉토리가 아닙니다.")
items = []
total_size = 0
try:
entries = sorted(list(target_path.iterdir()), key=lambda p: (not p.is_dir(), p.name.lower()))
for entry in entries:
try:
stat = entry.stat()
is_dir = entry.is_dir()
rel_path = str(entry.relative_to(base_dir)).replace("\\", "/")
size = 0
if not is_dir:
size = stat.st_size
total_size += size
ext = entry.suffix
category = "folder" if is_dir else get_category(ext)
items.append({
"name": entry.name,
"path": rel_path,
"is_dir": is_dir,
"size": size,
"modified": stat.st_mtime,
"extension": ext,
"category": category,
})
except (PermissionError, FileNotFoundError):
continue
current_rel = str(target_path.relative_to(base_dir)).replace("\\", "/")
if current_rel == ".":
current_rel = ""
return {
"current_path": current_rel,
"items": items,
"total_items": len(items),
"total_size": total_size,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"디렉토리 읽기 실패: {str(e)}")
def delete_path(sub_path: str) -> None:
"""파일 또는 폴더 삭제"""
target_path = resolve_secure_path(sub_path)
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
# 루트 디렉토리 자체는 삭제 불가
if target_path == base_dir:
raise HTTPException(status_code=400, detail="최상위 다운로드 디렉토리는 삭제할 수 없습니다.")
try:
if target_path.is_dir():
shutil.rmtree(target_path)
else:
target_path.unlink()
except Exception as e:
raise HTTPException(status_code=500, detail=f"삭제 실패: {str(e)}")
async def check_archive_encrypted(sub_path: str) -> Dict[str, Any]:
"""압축 파일 내부 암호화 여부 초고속 검사"""
archive_path = resolve_secure_path(sub_path)
if not archive_path.is_file():
raise HTTPException(status_code=400, detail="압축 파일이 아니거나 존재하지 않습니다.")
# 1. zip 파일인 경우: 파이썬 표준 라이브러리로 즉각 확인 (대용량 파일도 수 밀리초만에 완료)
if archive_path.suffix.lower() == ".zip":
try:
import zipfile
with zipfile.ZipFile(str(archive_path)) as zf:
is_encrypted = any(z.flag_bits & 0x1 for z in zf.infolist())
return {
"is_encrypted": is_encrypted,
"filename": archive_path.name,
}
except Exception:
pass
# 2. 기타 확장자(.7z, .rar 등)인 경우: 7z l 명령으로 확인 (타임아웃 적용)
cmd = ["7z", "l", "-slt", "-p-", str(archive_path)]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=3.0)
out_text = stdout.decode('utf-8', errors='ignore') + stderr.decode('utf-8', errors='ignore')
is_encrypted = (
"Encrypted = +" in out_text or
any(kw in out_text for kw in ["Wrong password", "Enter password", "Can not open encrypted"])
)
return {
"is_encrypted": is_encrypted,
"filename": archive_path.name,
}
except Exception:
return {
"is_encrypted": False,
"filename": archive_path.name,
}
async def extract_archive(
sub_path: str,
password: Optional[str] = None,
create_subfolder: bool = True,
custom_dest: Optional[str] = None,
delete_archive: bool = False
) -> Dict[str, Any]:
"""압축 파일 해제 (7z 활용, 암호 지원)"""
archive_path = resolve_secure_path(sub_path)
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
if not archive_path.is_file():
raise HTTPException(status_code=400, detail="압축 파일이 아니거나 존재하지 않습니다.")
parent_dir = archive_path.parent
if create_subfolder:
folder_name = custom_dest.strip() if (custom_dest and custom_dest.strip()) else archive_path.stem
folder_name = folder_name.replace("/", "_").replace("\\", "_")
dest_dir = parent_dir / folder_name
else:
dest_dir = parent_dir
dest_dir.mkdir(parents=True, exist_ok=True)
# 7z 명령어 구성
cmd = ["7z", "x", "-y"]
if password is not None and str(password).strip():
cmd.append(f"-p{password.strip()}")
else:
cmd.append("-p-") # 암호 프롬프트 대기 없이 즉시 에러 반환
cmd.append(f"-o{str(dest_dir)}")
cmd.append(str(archive_path))
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL
)
stdout, stderr = await proc.communicate()
out_text = stdout.decode('utf-8', errors='ignore')
err_text = stderr.decode('utf-8', errors='ignore')
combined_output = out_text + "\n" + err_text
# 암호 오류 패턴 검사
password_err_keywords = [
"Wrong password",
"Enter password",
"Data Error in encrypted file",
"Cannot open encrypted",
"Headers Error",
"Sub items Errors"
]
has_password_error = any(kw in combined_output for kw in password_err_keywords)
if has_password_error:
if not password or not str(password).strip():
raise HTTPException(
status_code=400,
detail="PASSWORD_REQUIRED: 이 파일은 암호로 보호되어 있습니다. 압축 해제 암호를 입력해주세요."
)
else:
raise HTTPException(
status_code=400,
detail="WRONG_PASSWORD: 입력한 암호가 올바르지 않습니다. 암호를 다시 확인해주세요."
)
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"압축 해제 중 오류가 발생했습니다: {err_text[:300] or out_text[:300]}"
)
# 원본 압축 파일 삭제 옵션
if delete_archive:
try:
archive_path.unlink()
except Exception:
pass
rel_dest = str(dest_dir.relative_to(base_dir)).replace("\\", "/")
if rel_dest == ".":
rel_dest = ""
return {
"success": True,
"message": f"'{archive_path.name}' 압축이 성공적으로 해제되었습니다.",
"dest_path": rel_dest
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"압축 해제 실행 실패: {str(e)}")
async def extract_archive_stream(
sub_path: str,
password: Optional[str] = None,
create_subfolder: bool = True,
custom_dest: Optional[str] = None,
delete_archive: bool = False
) -> AsyncGenerator[str, None]:
"""실시간 진행률을 SSE 형식으로 스트리밍하는 압축 해제 함수"""
archive_path = resolve_secure_path(sub_path)
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
if not archive_path.is_file():
yield f"data: {json.dumps({'status': 'error', 'error_type': 'NOT_FOUND', 'message': '압축 파일이 아니거나 존재하지 않습니다.'})}\n\n"
return
parent_dir = archive_path.parent
if create_subfolder:
folder_name = custom_dest.strip() if (custom_dest and custom_dest.strip()) else archive_path.stem
folder_name = folder_name.replace("/", "_").replace("\\", "_")
dest_dir = parent_dir / folder_name
else:
dest_dir = parent_dir
dest_dir.mkdir(parents=True, exist_ok=True)
# 7z 명령어: -bsp1 (진행률 출력), -bb1 (파일명 출력)
cmd = ["7z", "x", "-y", "-bsp1", "-bb1"]
if password is not None and str(password).strip():
cmd.append(f"-p{password.strip()}")
else:
cmd.append("-p-")
cmd.append(f"-o{str(dest_dir)}")
cmd.append(str(archive_path))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL
)
current_percent = 0
current_file = ""
files_done = 0
full_output = []
# 초기 상태 전송
yield f"data: {json.dumps({'status': 'progress', 'percent': 0, 'current_file': '압축 해제 준비 중...', 'files_done': 0})}\n\n"
try:
buffer = b""
while True:
chunk = await proc.stdout.read(256)
if not chunk:
break
buffer += chunk
# \r, \n, \x08 단위로 분할하여 파싱
if b"\n" in buffer or b"\r" in buffer or b"\x08" in buffer:
# 텍스트 디코딩
text = buffer.decode("utf-8", errors="ignore")
buffer = b""
full_output.append(text)
clean = re.sub(r"[\x08\r]+", " ", text)
percent_m = re.search(r"(\d+)\s*%", clean)
file_m = re.search(r"-\s+(.+)$", clean)
done_m = re.search(r"%\s+(\d+)", clean)
updated = False
if percent_m:
p = int(percent_m.group(1))
if p >= current_percent:
current_percent = p
updated = True
if file_m:
f = file_m.group(1).strip()
if f and f != current_file:
current_file = f
updated = True
if done_m:
files_done = int(done_m.group(1))
updated = True
if updated:
yield f"data: {json.dumps({'status': 'progress', 'percent': current_percent, 'current_file': current_file, 'files_done': files_done})}\n\n"
stderr_bytes = await proc.stderr.read()
err_text = stderr_bytes.decode("utf-8", errors="ignore")
combined_output = "".join(full_output) + "\n" + err_text
# 암호 오류 패턴 검사
password_err_keywords = [
"Wrong password",
"Enter password",
"Data Error in encrypted file",
"Cannot open encrypted",
"Headers Error",
"Sub items Errors"
]
has_password_error = any(kw in combined_output for kw in password_err_keywords)
if has_password_error:
if not password or not str(password).strip():
yield f"data: {json.dumps({'status': 'error', 'error_type': 'PASSWORD_REQUIRED', 'message': '이 파일은 암호로 보호되어 있습니다. 압축 해제 암호를 입력해주세요.'})}\n\n"
else:
yield f"data: {json.dumps({'status': 'error', 'error_type': 'WRONG_PASSWORD', 'message': '입력한 암호가 올바르지 않습니다. 암호를 다시 확인해주세요.'})}\n\n"
return
if proc.returncode != 0:
yield f"data: {json.dumps({'status': 'error', 'error_type': 'EXTRACT_FAILED', 'message': f'압축 해제 오류: {err_text[:200]}'})}\n\n"
return
# 원본 압축 파일 삭제 옵션
if delete_archive:
try:
archive_path.unlink()
except Exception:
pass
rel_dest = str(dest_dir.relative_to(base_dir)).replace("\\", "/")
if rel_dest == ".":
rel_dest = ""
yield f"data: {json.dumps({'status': 'completed', 'percent': 100, 'current_file': '압축 해제 완료', 'files_done': files_done, 'message': f'{archive_path.name} 압축이 성공적으로 해제되었습니다.', 'dest_path': rel_dest})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'status': 'error', 'error_type': 'EXCEPTION', 'message': str(e)})}\n\n"
def create_folder_zip(sub_path: str) -> Tuple[str, str]:
"""폴더 전체를 zip으로 묶어 임시 파일 생성"""
target_path = resolve_secure_path(sub_path)
if not target_path.is_dir():
raise HTTPException(status_code=400, detail="대상 경로가 폴더가 아닙니다.")
folder_name = target_path.name or "downloads"
zip_filename = f"{folder_name}.zip"
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
try:
# ZIP_STORED: 이미 압축된 미디어 파일이 많으므로 CPU 낭비 없이 최고 속도로 패키징
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
for root, dirs, files in os.walk(target_path):
for file in files:
file_path = Path(root) / file
# 상대 경로를 아카이브 내부 경로로 설정
arcname = file_path.relative_to(target_path)
try:
zf.write(file_path, arcname=str(arcname))
except Exception:
continue
return tmp_path, zip_filename
except Exception as e:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise HTTPException(status_code=500, detail=f"ZIP 생성 실패: {str(e)}")
def create_batch_zip(sub_paths: List[str], custom_name: Optional[str] = "bundle.zip") -> Tuple[str, str]:
"""선택된 여러 파일/폴더를 하나의 zip으로 묶어 임시 파일 생성"""
if not sub_paths:
raise HTTPException(status_code=400, detail="다운로드할 파일이 선택되지 않았습니다.")
base_dir = Path(settings.DOWNLOADS_BASE_PATH).resolve()
zip_filename = custom_name.strip() if custom_name and custom_name.strip() else "bundle.zip"
if not zip_filename.lower().endswith(".zip"):
zip_filename += ".zip"
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp_path = tmp.name
try:
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
for sp in sub_paths:
target = resolve_secure_path(sp)
if target.is_dir():
for root, dirs, files in os.walk(target):
for file in files:
file_path = Path(root) / file
# 선택된 폴더명을 최상위로 유지
rel_to_target = file_path.relative_to(target)
arcname = Path(target.name) / rel_to_target
try:
zf.write(file_path, arcname=str(arcname))
except Exception:
continue
elif target.is_file():
try:
zf.write(target, arcname=target.name)
except Exception:
continue
return tmp_path, zip_filename
except Exception as e:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise HTTPException(status_code=500, detail=f"선택 항목 ZIP 생성 실패: {str(e)}")
+263
View File
@@ -0,0 +1,263 @@
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, StreamingResponse
import urllib.parse
import os
import asyncio
from .config import settings
from .schemas import AddMagnetRequest, ApiResponse, StatusResponse, ExtractArchiveRequest, BatchDownloadRequest
from .transmission import transmission_client
from .files import (
list_directory,
resolve_secure_path,
delete_path,
extract_archive,
check_archive_encrypted,
extract_archive_stream,
create_folder_zip,
create_batch_zip
)
app = FastAPI(title="Transmission Magnet Manager API", version="1.0.0")
# CORS 허용 (개발 시 Vite dev 서버 5173 포트 등 접근 허용)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/status", response_model=ApiResponse)
async def check_status():
"""Transmission 연결 상태 및 통계 확인"""
try:
session = await transmission_client.get_session_stats()
torrents = await transmission_client.get_torrents()
return ApiResponse(
success=True,
message="Transmission에 성공적으로 연결되었습니다.",
data={
"connected": True,
"rpc_url": settings.rpc_url,
"version": session.get("version"),
"download_dir": session.get("download-dir"),
"torrents_count": len(torrents)
}
)
except Exception as e:
return ApiResponse(
success=False,
message=f"Transmission 연결 실패: {str(e)}",
data={
"connected": False,
"rpc_url": settings.rpc_url,
"version": None,
"torrents_count": 0
}
)
@app.post("/api/torrents/add", response_model=ApiResponse)
async def add_magnet(req: AddMagnetRequest):
"""마그넷 또는 토렌트 URL 추가"""
magnet = req.magnet.strip()
if not magnet:
raise HTTPException(status_code=400, detail="마그넷 주소를 입력해주세요.")
try:
result = await transmission_client.add_torrent(
magnet_or_url=magnet,
download_dir=req.download_dir,
paused=req.paused
)
if result["status"] == "duplicate":
return ApiResponse(
success=True,
message="이미 목록에 존재하는 토렌트입니다.",
data=result
)
return ApiResponse(
success=True,
message="토렌트가 성공적으로 추가되었습니다.",
data=result
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"토렌트 추가 중 오류 발생: {str(e)}")
@app.get("/api/torrents", response_model=ApiResponse)
async def list_torrents():
"""토렌트 목록 조회"""
try:
torrents = await transmission_client.get_torrents()
return ApiResponse(
success=True,
message="토렌트 목록을 가져왔습니다.",
data=torrents
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"토렌트 목록 조회 실패: {str(e)}")
@app.delete("/api/torrents/{torrent_id}", response_model=ApiResponse)
async def delete_torrent(torrent_id: int, delete_data: bool = Query(False, description="로컬 데이터도 함께 삭제")):
"""토렌트 삭제"""
try:
await transmission_client.remove_torrent(torrent_id, delete_local_data=delete_data)
return ApiResponse(
success=True,
message=f"토렌트 #{torrent_id}가 삭제되었습니다. (데이터 삭제: {'예' if delete_data else '아니오'})"
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"토렌트 삭제 실패: {str(e)}")
@app.post("/api/torrents/{torrent_id}/start", response_model=ApiResponse)
async def start_torrent(torrent_id: int):
"""토렌트 시작/재개"""
try:
await transmission_client.start_torrent(torrent_id)
return ApiResponse(
success=True,
message=f"토렌트 #{torrent_id}를 시작했습니다."
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"토렌트 시작 실패: {str(e)}")
@app.post("/api/torrents/{torrent_id}/stop", response_model=ApiResponse)
async def stop_torrent(torrent_id: int):
"""토렌트 일시정지"""
try:
await transmission_client.stop_torrent(torrent_id)
return ApiResponse(
success=True,
message=f"토렌트 #{torrent_id}를 일시정지했습니다."
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"토렌트 일시정지 실패: {str(e)}")
# ==================== 파일 뷰어 API ====================
@app.get("/api/files", response_model=ApiResponse)
async def get_files(path: str = Query("", description="상대 디렉토리 경로")):
"""다운로드 디렉토리 파일 목록 조회"""
data = list_directory(path)
return ApiResponse(
success=True,
message="파일 목록을 가져왔습니다.",
data=data
)
@app.get("/api/files/download")
async def download_file(path: str = Query(..., description="다운로드할 파일 상대 경로")):
"""파일 개별 다운로드 또는 스트리밍 서빙"""
file_path = resolve_secure_path(path)
if file_path.is_dir():
raise HTTPException(status_code=400, detail="폴더는 개별 다운로드할 수 없습니다. '폴더 전체 다운로드'를 이용해주세요.")
filename = file_path.name
quoted_filename = urllib.parse.quote(filename)
headers = {
"Content-Disposition": f"inline; filename*=UTF-8''{quoted_filename}"
}
return FileResponse(path=str(file_path), filename=filename, headers=headers)
@app.get("/api/files/download-folder")
async def download_folder_as_zip(
path: str = Query(..., description="다운로드할 폴더 상대 경로"),
background_tasks: BackgroundTasks = None
):
"""폴더 전체를 ZIP으로 묶어서 한 번에 다운로드"""
tmp_path, zip_name = await asyncio.to_thread(create_folder_zip, path)
quoted_filename = urllib.parse.quote(zip_name)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
}
if background_tasks:
background_tasks.add_task(os.unlink, tmp_path)
return FileResponse(path=tmp_path, filename=zip_name, headers=headers)
@app.post("/api/files/download-batch")
async def download_selected_files_batch(
req: BatchDownloadRequest,
background_tasks: BackgroundTasks = None
):
"""선택된 여러 파일/폴더를 ZIP으로 묶어서 한 번에 다운로드"""
tmp_path, zip_name = await asyncio.to_thread(create_batch_zip, req.paths, req.zip_name)
quoted_filename = urllib.parse.quote(zip_name)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
}
if background_tasks:
background_tasks.add_task(os.unlink, tmp_path)
return FileResponse(path=tmp_path, filename=zip_name, headers=headers)
@app.delete("/api/files", response_model=ApiResponse)
async def remove_file(path: str = Query(..., description="삭제할 파일/폴더 상대 경로")):
"""파일 또는 폴더 삭제"""
delete_path(path)
return ApiResponse(
success=True,
message=f"'{path}'이(가) 삭제되었습니다."
)
@app.get("/api/files/check-archive", response_model=ApiResponse)
async def check_archive(path: str = Query(..., description="압축 파일 상대 경로")):
"""압축 파일 암호화 여부 사전 검사"""
data = await check_archive_encrypted(path)
return ApiResponse(
success=True,
message="압축 파일 검사 완료",
data=data
)
@app.post("/api/files/extract", response_model=ApiResponse)
async def extract_file_archive(req: ExtractArchiveRequest):
"""압축 파일 해제"""
result = await extract_archive(
sub_path=req.path,
password=req.password,
create_subfolder=req.create_subfolder,
custom_dest=req.custom_dest,
delete_archive=req.delete_archive
)
return ApiResponse(
success=True,
message=result["message"],
data=result
)
@app.post("/api/files/extract-stream")
async def extract_file_archive_stream(req: ExtractArchiveRequest):
"""압축 파일 실시간 진행률 스트리밍 해제 (SSE)"""
return StreamingResponse(
extract_archive_stream(
sub_path=req.path,
password=req.password,
create_subfolder=req.create_subfolder,
custom_dest=req.custom_dest,
delete_archive=req.delete_archive
),
media_type="text/event-stream"
)
# Frontend dist 정적 파일 서빙 지원 (Vite build 결과물 서빙)
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
file_path = FRONTEND_DIST / full_path
if file_path.is_file():
return FileResponse(str(file_path))
return FileResponse(str(FRONTEND_DIST / "index.html"))
+43
View File
@@ -0,0 +1,43 @@
from typing import Optional, List, Any
from pydantic import BaseModel, Field
class AddMagnetRequest(BaseModel):
magnet: str = Field(..., description="마그넷 링크 (magnet:?xt=urn:btih:...) 또는 토렌트 URL")
download_dir: Optional[str] = Field(None, description="저장 경로 (비워둘 시 Transmission 기본값 사용)")
paused: bool = Field(False, description="추가 후 일시정지 상태로 둘지 여부")
class TorrentItem(BaseModel):
id: int
name: str
status: int
status_label: str = ""
percent_done: float
rate_download: int
rate_upload: int
total_size: int
eta: Optional[int] = None
download_dir: Optional[str] = None
error_string: Optional[str] = None
is_finished: bool = False
class ApiResponse(BaseModel):
success: bool
message: str
data: Optional[Any] = None
class StatusResponse(BaseModel):
connected: bool
rpc_url: str
version: Optional[str] = None
torrents_count: int = 0
class ExtractArchiveRequest(BaseModel):
path: str = Field(..., description="압축 파일 상대 경로")
password: Optional[str] = Field(None, description="압축 해제 암호 (비밀번호)")
create_subfolder: bool = Field(True, description="하위 폴더를 생성하여 압축 풀기 여부")
custom_dest: Optional[str] = Field(None, description="커스텀 해제 폴더명")
delete_archive: bool = Field(False, description="해제 후 원본 압축파일 삭제 여부")
class BatchDownloadRequest(BaseModel):
paths: List[str] = Field(..., description="다운로드할 파일 및 폴더 상대 경로 목록")
zip_name: Optional[str] = Field("bundle.zip", description="다운로드될 압축 파일명")
+129
View File
@@ -0,0 +1,129 @@
import logging
from typing import Dict, Any, List, Optional
import httpx
from .config import settings
logger = logging.getLogger(__name__)
STATUS_MAP = {
0: "일시정지됨",
1: "검사 대기",
2: "검사 중",
3: "다운로드 대기",
4: "다운로드 중",
5: "시딩 대기",
6: "시딩 중 (완료)",
}
class TransmissionClient:
def __init__(self):
self.session_id: str = ""
self.base_url = settings.rpc_url
self.auth = None
if settings.TRANSMISSION_USER and settings.TRANSMISSION_PASSWORD:
self.auth = (settings.TRANSMISSION_USER, settings.TRANSMISSION_PASSWORD)
async def _request(self, method: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Transmission RPC 요청 전송 (세션 ID 자동 획득 및 409 처리)"""
payload = {"method": method}
if arguments:
payload["arguments"] = arguments
async with httpx.AsyncClient(timeout=10.0, auth=self.auth) as client:
headers = {}
if self.session_id:
headers["X-Transmission-Session-Id"] = self.session_id
# 첫 번째 요청
response = await client.post(self.base_url, json=payload, headers=headers)
# 409 Conflict: 세션 ID 갱신 후 재시도
if response.status_code == 409:
new_session_id = response.headers.get("X-Transmission-Session-Id")
if not new_session_id:
raise Exception("Transmission에서 X-Transmission-Session-Id 헤더를 받지 못했습니다.")
self.session_id = new_session_id
headers["X-Transmission-Session-Id"] = self.session_id
response = await client.post(self.base_url, json=payload, headers=headers)
if response.status_code != 200:
raise Exception(f"Transmission RPC 오류: HTTP {response.status_code} - {response.text}")
res_data = response.json()
if res_data.get("result") != "success":
raise Exception(f"Transmission 요청 실패: {res_data.get('result')}")
return res_data
async def add_torrent(self, magnet_or_url: str, download_dir: Optional[str] = None, paused: bool = False) -> Dict[str, Any]:
"""마그넷 또는 토렌트 URL 추가"""
arguments: Dict[str, Any] = {
"filename": magnet_or_url.strip(),
"paused": paused
}
if download_dir and download_dir.strip():
arguments["download-dir"] = download_dir.strip()
data = await self._request("torrent-add", arguments)
args = data.get("arguments", {})
# 'torrent-added' or 'torrent-duplicate'
if "torrent-added" in args:
return {"status": "added", "torrent": args["torrent-added"]}
elif "torrent-duplicate" in args:
return {"status": "duplicate", "torrent": args["torrent-duplicate"]}
else:
return {"status": "unknown", "arguments": args}
async def get_torrents(self) -> List[Dict[str, Any]]:
"""토렌트 목록 조회"""
fields = [
"id", "name", "status", "percentDone", "rateDownload",
"rateUpload", "totalSize", "eta", "downloadDir",
"errorString", "isFinished", "addedDate"
]
data = await self._request("torrent-get", {"fields": fields})
torrents = data.get("arguments", {}).get("torrents", [])
# 가공 및 상태 레이블 추가
result = []
for t in torrents:
status_code = t.get("status", 0)
result.append({
"id": t.get("id"),
"name": t.get("name", "알 수 없음"),
"status": status_code,
"status_label": STATUS_MAP.get(status_code, "알 수 없음"),
"percent_done": round(t.get("percentDone", 0) * 100, 2),
"rate_download": t.get("rateDownload", 0),
"rate_upload": t.get("rateUpload", 0),
"total_size": t.get("totalSize", 0),
"eta": t.get("eta"),
"download_dir": t.get("downloadDir"),
"error_string": t.get("errorString"),
"is_finished": t.get("isFinished", False),
})
return result
async def remove_torrent(self, torrent_id: int, delete_local_data: bool = False) -> None:
"""토렌트 삭제"""
arguments = {
"ids": [torrent_id],
"delete-local-data": delete_local_data
}
await self._request("torrent-remove", arguments)
async def start_torrent(self, torrent_id: int) -> None:
"""토렌트 시작/재개"""
await self._request("torrent-start", {"ids": [torrent_id]})
async def stop_torrent(self, torrent_id: int) -> None:
"""토렌트 일시정지"""
await self._request("torrent-stop", {"ids": [torrent_id]})
async def get_session_stats(self) -> Dict[str, Any]:
"""세션 정보 및 통계 확인"""
session_data = await self._request("session-get")
return session_data.get("arguments", {})
transmission_client = TransmissionClient()
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
python-multipart>=0.0.9
+15
View File
@@ -0,0 +1,15 @@
version: '3.8'
services:
transmission-manager:
build: .
container_name: transmission-manager
restart: unless-stopped
network_mode: host # 호스트 포트 8000 사용 및 로컬 Transmission(9091) 직접 통신
volumes:
- /mnt/ssd/transmission/downloads:/downloads
environment:
- TRANSMISSION_HOST=127.0.0.1
- TRANSMISSION_PORT=9091
- DOWNLOADS_DIR=/downloads
+1
View File
@@ -0,0 +1 @@
@layer vuetify-components{.v-app-bar{display:flex}.v-app-bar.v-toolbar{background:rgb(var(--v-theme-surface));color:color-mix(in srgb,rgb(var(--v-theme-on-surface)) calc(var(--v-high-emphasis-opacity) * 100%),transparent)}.v-app-bar.v-toolbar:not(.v-toolbar--flat){box-shadow:0 1px 2px rgba(var(--v-shadow-color),var(--v-shadow-key-opacity, .3)),0 2px 6px 2px rgba(var(--v-shadow-color),var(--v-shadow-ambient-opacity, .15));--v-elevation-overlay: color-mix(in srgb, var(--v-elevation-overlay-color) 4%, transparent)}.v-app-bar:not(.v-toolbar--absolute){padding-inline-end:var(--v-scrollbar-offset)}}@layer vuetify-components{.v-toolbar{align-items:flex-start;display:flex;flex:none;flex-direction:column;justify-content:space-between;max-width:100%;position:relative;transition:.2s cubic-bezier(.4,0,.2,1);transition-property:height,width,transform,max-width,left,right,top,bottom,box-shadow;width:100%}@media(prefers-reduced-motion:reduce){.v-toolbar{transition-property:box-shadow}}.v-toolbar{border-color:rgba(var(--v-border-color),var(--v-border-opacity));border-style:solid;border-width:0}.v-toolbar--border{border-width:thin;box-shadow:none}.v-toolbar{box-shadow:0 0 rgba(var(--v-shadow-color),var(--v-shadow-key-opacity, .3)),0 0 rgba(var(--v-shadow-color),var(--v-shadow-ambient-opacity, .15));--v-elevation-overlay: color-mix(in srgb, var(--v-elevation-overlay-color) 0%, transparent)}.v-toolbar{border-radius:0}.v-toolbar{background:rgb(var(--v-theme-surface-light));color:color-mix(in srgb,rgb(var(--v-theme-on-surface-light)) calc(var(--v-high-emphasis-opacity) * 100%),transparent)}.v-toolbar--absolute{position:absolute}.v-toolbar--collapse{max-width:112px;overflow:hidden}.v-toolbar--collapse-end{margin-inline-start:auto}.v-toolbar--collapse.v-toolbar--collapse-start{border-end-end-radius:24px}.v-toolbar--collapse.v-toolbar--collapse-end{border-end-start-radius:24px}.v-toolbar--collapse .v-toolbar-title{display:none}.v-toolbar--flat{box-shadow:0 0 rgba(var(--v-shadow-color),var(--v-shadow-key-opacity, .3)),0 0 rgba(var(--v-shadow-color),var(--v-shadow-ambient-opacity, .15));--v-elevation-overlay: color-mix(in srgb, var(--v-elevation-overlay-color) 0%, transparent)}.v-toolbar--floating{display:inline-flex;width:auto}.v-toolbar--rounded{border-radius:4px}.v-toolbar__content,.v-toolbar__extension{align-items:center;display:flex;flex:0 0 auto;position:relative;transition:inherit;width:100%}.v-toolbar__content{overflow:hidden}.v-toolbar__content>.v-btn:first-child{margin-inline-start:4px}.v-toolbar__content>.v-btn:last-child{margin-inline-end:4px}.v-toolbar__content>.v-toolbar-title{margin-inline-start:20px}.v-toolbar--density-prominent .v-toolbar__content{align-items:flex-start}.v-toolbar__image{display:flex;opacity:var(--v-toolbar-image-opacity, 1);transition-property:opacity}.v-toolbar__image{position:absolute;top:0;left:0;width:100%;height:100%}.v-toolbar__prepend,.v-toolbar__append{align-items:center;align-self:stretch;display:flex}.v-toolbar__prepend{margin-inline:4px auto}.v-toolbar__append{margin-inline:auto 4px}.v-toolbar-title{flex:1 1;font-size:1.25rem;min-width:0}.v-toolbar-title{font-size:1.25rem;font-weight:400;letter-spacing:0;line-height:1.75rem;text-transform:none}.v-toolbar--density-prominent .v-toolbar-title{align-self:flex-end;padding-bottom:6px}.v-toolbar--density-prominent .v-toolbar-title{font-size:1.5rem;font-weight:400;letter-spacing:0;line-height:2.25rem;text-transform:none}.v-toolbar-title__placeholder{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.v-toolbar-items{display:flex;height:inherit;align-self:stretch}.v-toolbar-items>.v-btn{border-radius:0}}@layer vuetify-components{.v-img{--v-theme-overlay-multiplier: 3;z-index:0}.v-img.v-img--absolute{height:100%;left:0;overflow:hidden;position:absolute;top:0;width:100%;z-index:-1}.v-img.v-img--fit-content{max-width:fit-content}.v-img.v-img--fit-content>.v-img__img{position:relative}.v-img.v-img--fit-content>.v-responsive__sizer{display:none}.v-img--booting .v-responsive__sizer{transition:none}.v-img--rounded{border-radius:4px}.v-img__img,.v-img__picture,.v-img__gradient,.v-img__placeholder,.v-img__error{z-index:-1}.v-img__img,.v-img__picture,.v-img__gradient,.v-img__placeholder,.v-img__error{position:absolute;top:0;left:0;width:100%;height:100%}.v-img__img--preload{filter:blur(4px)}.v-img__img--contain{object-fit:contain}.v-img__img--cover{object-fit:cover}.v-img__gradient{background-repeat:no-repeat}}@layer vuetify-components{.v-responsive{display:flex;flex:1 0 auto;max-height:100%;max-width:100%;overflow:hidden;position:relative}.v-responsive--inline{display:inline-flex;flex:0 0 auto}.v-responsive__content{flex:1 0 0px;max-width:100%}.v-responsive__sizer~.v-responsive__content{margin-inline-start:-100%}.v-responsive__sizer{flex:1 0 0px;transition:padding-bottom .2s cubic-bezier(.4,0,.2,1);pointer-events:none}}@layer vuetify-components{.v-btn{align-items:center;border-radius:4px;display:inline-flex;flex-direction:row;font-weight:500;justiLine truncated
+44
View File
@@ -0,0 +1,44 @@
var Np=Object.defineProperty;var Am=e=>{throw TypeError(e)};var Rp=(e,t,n)=>t in e?Np(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Ln=(e,t,n)=>Rp(e,typeof t!="symbol"?t+"":t,n),Tm=(e,t,n)=>t.has(e)||Am("Cannot "+n);var Ta=(e,t,n)=>(Tm(e,t,"read from private field"),n?n.call(e):t.get(e)),Vc=(e,t,n)=>t.has(e)?Am("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),Ic=(e,t,n,a)=>(Tm(e,t,"write to private field"),a?a.call(e,n):t.set(e,n),n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))a(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const r of i.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&a(r)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function a(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();/**
* @vue/shared v3.5.44
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function bs(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const mt={},ti=[],fa=()=>{},uy=()=>!1,Wo=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),_t=Object.assign,Tf=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Hp=Object.prototype.hasOwnProperty,kt=(e,t)=>Hp.call(e,t),Re=Array.isArray,Al=e=>br(e)==="[object Map]",La=e=>br(e)==="[object Set]",Dm=e=>br(e)==="[object Date]",zp=e=>br(e)==="[object RegExp]",Qe=e=>typeof e=="function",At=e=>typeof e=="string",Jn=e=>typeof e=="symbol",Vt=e=>e!==null&&typeof e=="object",Df=e=>(Vt(e)||Qe(e))&&Qe(e.then)&&Qe(e.catch),sy=Object.prototype.toString,br=e=>sy.call(e),Wp=e=>br(e).slice(8,-1),Ss=e=>br(e)==="[object Object]",ks=e=>At(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,ai=bs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ps=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Up=/-\w/g,nn=ps(e=>e.replace(Up,t=>t.slice(1).toUpperCase())),jp=/\B([A-Z])/g,Hn=ps(e=>e.replace(jp,"-$1").toLowerCase()),yl=ps(e=>e.charAt(0).toUpperCase()+e.slice(1)),qr=ps(e=>e?`on${yl(e)}`:""),gn=(e,t)=>!Object.is(e,t),lr=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},cy=(e,t,n,a=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:n})},xs=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Fu=e=>{const t=At(e)?Number(e):NaN;return isNaN(t)?e:t};let Em;const Cs=()=>Em||(Em=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{}),Kp="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol",Gp=bs(Kp);function Se(e){if(Re(e)){const t={};for(let n=0;n<e.length;n++){const a=e[n],l=At(a)?Zp(a):Se(a);if(l)for(const i in l)t[i]=l[i]}return t}else if(At(e)||Vt(e))return e}const Yp=/;(?![^(]*\))/g,qp=/:([^]+)/,Xp=/"(?:[^"\\]|\\[^])*"|'(?:[^'\\]|\\[^])*'|\\[^]|\/\*[^]*?\*\//g;function Zp(e){const t={};return e.replace(Xp,n=>n.startsWith("/*")?"":n).split(Yp).forEach(n=>{if(n){const a=n.split(qp);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function fe(e){let t="";if(At(e))t=e;else if(Re(e))for(let n=0;n<e.length;n++){const a=fe(e[n]);a&&(t+=a+" ")}else if(Vt(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}function _s(e){if(!e)return null;let{class:t,style:n}=e;return t&&!At(t)&&(e.class=fe(t)),n&&(e.style=Se(n)),e}const Jp="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,playsinline,readonly",Qp=bs(Jp);function dy(e){return!!e||e===""}function ex(e,t,n){if(e.length!==t.length)return!1;let a=!0;for(let l=0;a&&l<e.length;l++)a=va(e[l],t[l],n);return a}function Mm(e,t,n){if(e.size!==t.size)return!1;const a=Array.from(t),l=new Uint8Array(a.length);for(const i of e){let r=-1;for(let o=0;o<a.length;o++)if(!l[o]&&va(i,a[o],n)){r=o;break}if(r<0)return!1;l[r]=1}return!0}function tx(e,t,n){let a=Al(e),l=Al(t);if(a||l||(a=La(e),l=La(t),a||l))return a&&l?Mm(e,t,n):!1;const i=Object.keys(e).length,r=Object.keys(t).length;if(i!==r)return!1;for(const o in e){const u=e.hasOwnProperty(o),d=t.hasOwnProperty(o);if(u&&!d||!u&&d||!va(e[o],t[o],n))return!1}return String(e)===String(t)}function Bm(e,t,n,a){n||(n=[new Map,new Map]);const[l,i]=n;if(l.has(e)||i.has(t))return l.get(e)===t&&i.get(t)===e;l.set(e,t),i.set(t,e);const r=a(e,t,n);return l.delete(e),i.delete(t),r}function va(e,t,n){if(e===t)return!0;let a=Dm(e),l=Dm(t);return a||l?a&&l?e.getTime()===t.getTime():!1:(a=Jn(e),l=Jn(t),a||l?e===t:(a=Re(e),l=Re(t),a||l?a&&l?Bm(e,t,n,ex):!1:(a=Vt(e),l=Vt(t),a||l?!a||!l?!1:Bm(e,t,n,tx):String(e)===String(t))))}function Vs(e,t){return e.findIndex(n=>va(n,t))}const fy=e=>!!(e&&e.__v_isRef===!0),it=e=>At(e)?e:e==null?"":Re(e)||Vt(e)&&(e.toString===sy||!Qe(e.toString))?fy(e)?it(e.value):JSON.stringify(e,vy,2):String(e),vy=(e,t)=>fy(t)?vy(e,t.value):Al(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[a,l],i)=>(n[Pc(a,i)+" =>"]=l,n),{})}:La(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>Pc(n))}:Jn(t)?Pc(t):Vt(t)&&!Re(t)&&!Ss(t)?String(t):t,Pc=(e,t="")=>{var n;return Jn(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};function nx(e){return e==null?"initial":typeof e=="string"?e===""?" ":e:String(e)}/**
* @vue/reactivity v3.5.44
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let dn;class Ef{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&dn&&(dn.active?(this.parent=dn,this.index=(dn.scopes||(dn.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes){const a=this.scopes.slice();for(t=0,n=a.length;t<n;t++)a[t].pause()}for(t=0,n=this.effects.length;t<n;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,n;if(this.scopes){const l=this.scopes.slice();for(t=0,n=l.length;t<n;t++)l[t].resume()}const a=this.effects.slice();for(t=0,n=a.length;t<n;t++)a[t].resume()}}run(t){if(this._active){const n=dn;try{return dn=this,t()}finally{dn=n}}}on(){++this._on===1&&(this.prevScope=dn,dn=this)}off(){if(this._on>0&&--this._on===0){if(dn===this)dn=this.prevScope;else{let t=dn;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,a;for(n=0,a=this.effects.length;n<a;n++)this.effects[n].stop();for(this.effects.length=0,n=0,a=this.cleanups.length;n<a;n++)this.cleanups[n]();if(this.cleanups.length=0,this.scopes){const l=this.scopes.slice();for(n=0,a=l.length;n<a;n++)l[n].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const l=this.parent.scopes.pop();l&&l!==this&&(this.parent.scopes[this.index]=l,l.index=this.index)}this.parent=void 0}}}function Fl(e){return new Ef(e)}function Mf(){return dn}function pt(e,t=!1){dn&&dn.cleanups.push(e)}let Nt;const Ac=new WeakSet;class oo{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,dn&&(dn.active?dn.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Ac.has(this)&&(Ac.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||hy(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,$m(this),gy(this);const t=Nt,n=Sa;Nt=this,Sa=!0;try{return this.fn()}finally{yy(this),Nt=t,Sa=n,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)Ff(t);this.deps=this.depsTail=void 0,$m(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Ac.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){ud(this)&&this.run()}get dirty(){return ud(this)}}let my=0,Xr,Zr;function hy(e,t=!1){if(e.flags|=8,t){e.next=Zr,Zr=e;return}e.next=Xr,Xr=e}function Bf(){my++}function $f(){if(--my>0)return;if(Zr){let t=Zr;for(Zr=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Xr;){let t=Xr;for(Xr=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=n}}if(e)throw e}function gy(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function yy(e){let t,n=e.depsTail,a=n;for(;a;){const l=a.prevDep;a.version===-1?(a===n&&(n=l),Ff(a),ax(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=l}e.deps=t,e.depsTail=n}function ud(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(by(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function by(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===uo)||(e.globalVersion=uo,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!ud(e))))return;e.flags|=2;const t=e.dep,n=Nt,a=Sa;Nt=e,Sa=!0;try{gy(e);const l=e.fn(e._value);(t.version===0||gn(l,e._value))&&(e.flags|=128,e._value=l,t.version++)}catch(l){throw t.version++,l}finally{Nt=n,Sa=a,yy(e),e.flags&=-3}}function Ff(e,t=!1){const{dep:n,prevSub:a,nextSub:l}=e;if(a&&(a.nextSub=l,e.prevSub=void 0),l&&(l.prevSub=a,e.nextSub=void 0),n.subs===e&&(n.subs=a,!a&&n.computed)){n.computed.flags&=-5;for(let i=n.computed.deps;i;i=i.nextDep)Ff(i,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ax(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}function lx(e,t){e.effect instanceof oo&&(e=e.effect.fn);const n=new oo(e);t&&_t(n,t);try{n.run()}catch(l){throw n.stop(),l}const a=n.run.bind(n);return a.effect=n,a}function ix(e){e.effect.stop()}let Sa=!0;const wy=[];function Na(){wy.push(Sa),Sa=!1}function Ra(){const e=wy.pop();Sa=e===void 0?!0:e}function $m(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Nt;Nt=void 0;try{t()}finally{Nt=n}}}let uo=0;class rx{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Is{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Nt||!Sa||Nt===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Nt)n=this.activeLink=new rx(Nt,this),Nt.deps?(n.prevDep=Nt.depsTail,Nt.depsTail.nextDep=Line truncated
* @vue/runtime-core v3.5.44
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/const By=[];function Mx(e){By.push(e)}function Bx(){By.pop()}function $x(e,t){}const Fx={SETUP_FUNCTION:0,0:"SETUP_FUNCTION",RENDER_FUNCTION:1,1:"RENDER_FUNCTION",NATIVE_EVENT_HANDLER:5,5:"NATIVE_EVENT_HANDLER",COMPONENT_EVENT_HANDLER:6,6:"COMPONENT_EVENT_HANDLER",VNODE_HOOK:7,7:"VNODE_HOOK",DIRECTIVE_HOOK:8,8:"DIRECTIVE_HOOK",TRANSITION_HOOK:9,9:"TRANSITION_HOOK",APP_ERROR_HANDLER:10,10:"APP_ERROR_HANDLER",APP_WARN_HANDLER:11,11:"APP_WARN_HANDLER",FUNCTION_REF:12,12:"FUNCTION_REF",ASYNC_COMPONENT_LOADER:13,13:"ASYNC_COMPONENT_LOADER",SCHEDULER:14,14:"SCHEDULER",COMPONENT_UPDATE:15,15:"COMPONENT_UPDATE",APP_UNMOUNT_CLEANUP:16,16:"APP_UNMOUNT_CLEANUP"},Ox={sp:"serverPrefetch hook",bc:"beforeCreate hook",c:"created hook",bm:"beforeMount hook",m:"mounted hook",bu:"beforeUpdate hook",u:"updated",bum:"beforeUnmount hook",um:"unmounted hook",a:"activated hook",da:"deactivated hook",ec:"errorCaptured hook",rtc:"renderTracked hook",rtg:"renderTriggered hook",0:"setup function",1:"render function",2:"watcher getter",3:"watcher callback",4:"watcher cleanup function",5:"native event handler",6:"component event handler",7:"vnode hook",8:"directive hook",9:"transition hook",10:"app errorHandler",11:"app warnHandler",12:"ref function",13:"async component loader",14:"scheduler flush",15:"component update",16:"app unmount cleanup function"};function Sr(e,t,n,a){try{return a?e(...a):e()}catch(l){Vi(l,t,n)}}function Qn(e,t,n,a){if(Qe(e)){const l=Sr(e,t,n,a);return l&&Df(l)&&l.catch(i=>{Vi(i,t,n)}),l}if(Re(e)){const l=[];for(let i=0;i<e.length;i++)l.push(Qn(e[i],t,n,a));return l}}function Vi(e,t,n,a=!0){const l=t?t.vnode:null,{errorHandler:i,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||mt;if(t){let o=t.parent;const u=t.proxy,d=`https://vuejs.org/error-reference/#runtime-${n}`;for(;o;){const s=o.ec;if(s){for(let c=0;c<s.length;c++)if(s[c](e,u,d)===!1)return}o=o.parent}if(i){Na(),Sr(i,null,10,[e,u,d]),Ra();return}}Lx(e,n,l,a,r)}function Lx(e,t,n,a=!0,l=!1){if(l)throw e;console.error(e)}const Fn=[];let ba=-1;const $a=[];let Il=null,Ji=0;const $y=Promise.resolve();let Nu=null;function Ne(e){const t=Nu||$y;return e?t.then(this?e.bind(this):e):t}function Nx(e){let t=ba+1,n=Fn.length;for(;t<n;){const a=t+n>>>1,l=Fn[a],i=vo(l);i<e||i===e&&l.flags&2?t=a+1:n=a}return t}function Nf(e){if(!(e.flags&1)){const t=vo(e),n=Fn[Fn.length-1];!n||!(e.flags&2)&&t>=vo(n)?Fn.push(e):Fn.splice(Nx(t),0,e),e.flags|=1,Fy()}}function Fy(){Nu||(Nu=$y.then(Ly))}function fo(e){if(!Re(e))Il&&e.id===-1?Il.splice(Ji+1,0,e):e.flags&1||($a.push(e),e.flags|=1);else for(let t=0;t<e.length;t++)$a.push(e[t]);Fy()}function Oy(e,t,n,a=ba+1){for(;a<Fn.length;a++){const l=Fn[a];if(l&&l.flags&2){if(e&&l.id!==e.uid||t!==void 0&&!(l.id>=t))continue;Fn.splice(a,1),a=e?a-1:ba,l.flags&4&&(l.flags&=-2),l(),l.flags&4||(l.flags&=-2)}}}function Ru(e,t,n=0){if($a.length){let a=$a,l=0;if(t){a=[];for(let r=0;r<$a.length;r++){const o=$a[r];(o.i!==void 0?!o.i||o.i.uid<n:t.has(o))?$a[l++]=o:a.push(o)}}const i=[...new Set(a)].sort((r,o)=>vo(r)-vo(o));if($a.length=l,Il){for(let r=0;r<i.length;r++)Il.push(i[r]);return}for(Il=i,Ji=0;Ji<Il.length;Ji++){const r=Il[Ji];r.flags&4&&(r.flags&=-2),r.flags&8||r(),r.flags&=-2}Il=null,Ji=0}}let Ec=!1;function Rx(e,t){if(Ec){e();return}const n=t===void 0?void 0:new Set($a);e(),Ec=!0;try{Oy(void 0,t),Ru(void 0,n,t)}finally{Ec=!1}}const vo=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Ly(e){try{for(ba=0;ba<Fn.length;ba++){const t=Fn[ba];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Sr(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;ba<Fn.length;ba++){const t=Fn[ba];t&&(t.flags&=-2)}ba=-1,Fn.length=0,Ru(),Nu=null,(Fn.length||$a.length)&&Ly()}}let Qi,su=[];function Ny(e,t){var n,a;Qi=e,Qi?(Qi.enabled=!0,su.forEach(({event:l,args:i})=>Qi.emit(l,...i)),su=[]):typeof window<"u"&&window.HTMLElement&&!((a=(n=window.navigator)==null?void 0:n.userAgent)!=null&&a.includes("jsdom"))?((t.__VUE_DEVTOOLS_HOOK_REPLAY__=t.__VUE_DEVTOOLS_HOOK_REPLAY__||[]).push(i=>{Ny(i,t)}),setTimeout(()=>{Qi||(t.__VUE_DEVTOOLS_HOOK_REPLAY__=null,su=[])},3e3)):su=[]}let pn=null,Ds=null;function mo(e){const t=pn;return pn=e,Ds=e&&e.type.__scopeId||null,t}function Hx(e){Ds=e}function zx(){Ds=null}const Wx=e=>Ve;function Ve(e,t=pn,n){if(!t||e._n)return e;const a=(...l)=>{a._d&&bo(-1);const i=mo(t),r=cl.length;let o;try{o=e(...l)}finally{for(let u=cl.length;u>r;u--)Rs();mo(i),a._d&&bo(1)}return o};return a._n=!0,a._c=!0,a._d=!0,a}function xt(e,t){if(pn===null)return e;const n=Go(pn),a=e.dirs||(e.dirs=[]);for(let l=0;l<t.length;l++){let[i,r,o,u=mt]=t[l];i&&(Qe(i)&&(i={mounted:i,updated:i}),i.deep&&nl(r),a.push({dir:i,instance:n,value:r,oldValue:void 0,arg:o,modifiers:u}))}return e}function Ma(e,t,n,a){const l=e.dirs,i=t&&t.dirs;for(let r=0;r<l.length;r++){const o=l[r];i&&(o.oldValue=i[r].value);let u=o.dir[a];u&&(Na(),Qn(u,n,8,[e.el,o,e,t]),Ra())}}function bt(e,t){if(kn){let n=kn.provides;const a=kn.parent&&kn.parent.provides;a===n&&(n=kn.provides=Object.create(a)),n Line truncated
`&&(k.tagName==="PRE"||k.tagName==="TEXTAREA")&&(U=U.slice(1));const{textContent:G}=k;G!==U&&G!==U.replace(/\r\n|\r/g,`
`)&&(Iu(k,0)||ji(),k.textContent=S.children)}if(A){if(R||W||!_||B&48){const U=k.tagName.includes("-"),G=k.namespaceURI.includes("svg")?"svg":k.namespaceURI.includes("MathML")?"mathml":void 0;for(const Z in A)if(R&&(Z.endsWith("value")||Z==="indeterminate")||Wo(Z)&&!ai(Z)||Z[0]==="."||U&&!ai(Z)||T&&T.includes(Z)){if(tC(k,Z,A[Z]))continue;a(k,Z,null,A[Z],G,w)}}else if(A.onClick)a(k,"onClick",null,A.onClick,void 0,w);else if(B&4&&ka(A.style))for(const U in A.style)A.style[U]}let Y;(Y=A&&A.onVnodeBeforeMount)&&Nn(Y,w,S),P&&Ma(S,null,w,"beforeMount"),((Y=A&&A.onVnodeMounted)||P||N)&&Cb(()=>{Y&&Nn(Y,w,S),N&&L.enter(k),P&&Ma(S,null,w,"mounted")},x)}return k.nextSibling},v=(k,S,w,x,y,_,V)=>{V=V||!!S.dynamicChildren;const T=S.children,A=T.length;let B=!1;for(let E=0;E<A;E++){const P=V?T[E]:T[E]=Rn(T[E]),L=P.type===sl;k?(L&&!V&&E+1<A&&Rn(T[E+1]).type===sl&&(u(l(k.data.slice(P.children.length)),w,i(k)),k.data=P.children),k=c(k,P,x,y,_,V)):L&&!P.children?u(P.el=l(""),w):(B||(B=!0,Iu(w,1)||ji()),n(null,P,w,null,x,y,du(w),_))}return k},m=(k,S,w,x,y,_)=>{const{slotScopeIds:V}=S;V&&(y=y?y.concat(V):V);const T=r(k),A=v(i(k),S,T,w,x,y,_);return A&&tr(A)&&A.data==="]"?i(S.anchor=A):(ji(),u(S.anchor=d("]"),T,A),A)},h=(k,S,w,x,y,_)=>{if(aC(k,S)||ji(),S.el=null,_){const A=g(k);for(;;){const B=i(k);if(B&&B!==A)o(B);else break}}const V=i(k),T=r(k);return o(k),n(null,S,T,V,w,x,du(T),y),w&&(w.vnode.el=S.el,Ns(w,S.el)),V},g=(k,S="[",w="]")=>{let x=0;for(;k;)if(k=i(k),k&&tr(k)&&(k.data===S&&x++,k.data===w)){if(x===0)return i(k);x--}return k},b=(k,S,w)=>{const x=S.parentNode;x&&x.replaceChild(k,S);let y=w;for(;y;)y.vnode.el===S&&(y.vnode.el=y.subTree.el=k),y=y.parent},p=k=>k.nodeType===1&&k.tagName==="TEMPLATE";return[s,c]}const eC=new Set(["src","srcset","href","poster"]);function tC(e,t,n){return eC.has(t)?e.getAttribute(t)===(n==null?null:`${n}`):!1}const Wu="data-allow-mismatch",nC={0:"text",1:"children",2:"class",3:"style",4:"attribute"};function Iu(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(Wu);)e=e.parentElement;return Wf(e&&e.getAttribute(Wu),t)}function Wf(e,t){if(e==null)return!1;if(e==="")return!0;{const n=e.split(",");return t===0&&n.includes("children")?!0:n.includes(nC[t])}}function aC(e,t){return Iu(e.parentElement,1)||lC(e)||iC(t)}function lC(e){return e.nodeType===1&&Wf(e.getAttribute(Wu),1)}function iC({props:e}){const t=e&&e[Wu];return typeof t=="string"&&Wf(t,1)}const rC=Cs().requestIdleCallback||(e=>setTimeout(e,1)),oC=Cs().cancelIdleCallback||(e=>clearTimeout(e)),uC=(e=1e4)=>t=>{const n=rC(t,{timeout:e});return()=>oC(n)};function sC(e){const{top:t,left:n,bottom:a,right:l}=e.getBoundingClientRect(),{innerHeight:i,innerWidth:r}=window;return(t>0&&t<i||a>0&&a<i)&&(n>0&&n<r||l>0&&l<r)}const cC=e=>(t,n)=>{const a=new IntersectionObserver(i=>{for(const r of i)if(r.isIntersecting){a.disconnect(),t();break}},e),l=i=>{if(sC(i))return t(),a.disconnect(),!1;if(getComputedStyle(i).display==="contents"){for(let r=0;r<i.children.length;r++)if(l(i.children[r])===!1)return!1}a.observe(i)};return n(i=>{if(i instanceof Element)return l(i)}),()=>a.disconnect()},dC=e=>t=>{if(e){const n=matchMedia(e);if(n.matches)t();else return n.addEventListener("change",t,{once:!0}),()=>n.removeEventListener("change",t)}},fC=(e=[])=>(t,n)=>{At(e)&&(e=[e]);let a=!1;const l=r=>{a||(a=!0,i(),t(),r.target.dispatchEvent(new r.constructor(r.type,r)))},i=()=>{n(r=>{for(const o of e)r.removeEventListener(o,l)})};return n(r=>{for(const o of e)r.addEventListener(o,l,{once:!0})}),i};function vC(e,t){if(tr(e)&&e.data==="["){let n=1,a=e.nextSibling;for(;a;){if(a.nodeType===1){if(t(a)===!1)break}else if(tr(a))if(a.data==="]"){if(--n===0)break}else a.data==="["&&n++;a=a.nextSibling}}else t(e)}const ul=e=>!!e.type.__asyncLoader;function mC(e){Qe(e)&&(e={loader:e});const{loader:t,loadingComponent:n,errorComponent:a,delay:l=200,hydrate:i,timeout:r,suspensible:o=!0,onError:u}=e;let d=null,s,c=0;const f=()=>(c++,d=null,v()),v=()=>{let m;return d||(m=d=t().catch(h=>{if(h=h instanceof Error?h:new Error(String(h)),u)return new Promise((g,b)=>{u(h,()=>g(f()),()=>b(h),c+1)});throw h}).then(h=>m!==d&&d?d:(h&&(h.__esModule||h[Symbol.toStringTag]==="Module")&&(h=h.default),s=h,h)))};return Bs({name:"AsyncComponentWrapper",__asyncLoader:v,__asyncHydrate(m,h,g){const b=m.isConnected;let p=!1;(h.bu||(h.bu=[])).push(()=>p=!0);const k=()=>{p||!m.parentNode||b&&!m.isConnected||g()},S=i?()=>{const w=i(k,x=>vC(m,x));w&&(h.bum||(h.bum=[])).push(w)}:k;s?S():v().then(()=>!h.isUnmounted&&S())},get __asyncResolved(){return s},setup(){const m=kn;if(zf(m),s)return()=>fu(s,m);const h=w=>{d=null,Vi(w,m,13,!a)};if(o&&m.suspense||hi)return v().then(w=>()=>fu(w,m)).catch(w=>(h(w),()=>a?C(a,{error:w}):null));const g=oe(!1),b=oe(),p=oe(!!l);let k,S;return Pi(()=>{k!=null&&clearTimeout(k),S!=null&&clearTimeout(S)}),l&&(S=setTimeout(()=>{m.isUnmounted||(p.value=!1)},l)),r!=null&&(k=setTimeout(()=>{if(!m.isUnmounted&&!g.value&&!b.value){const w=new Error(`Async component timed out after ${r}ms.`); Line truncated
* @vue/runtime-dom v3.5.44
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let Cd;const Xm=typeof window<"u"&&window.trustedTypes;if(Xm)try{Cd=Xm.createPolicy("vue",{createHTML:e=>e})}catch{}const $b=Cd?e=>Cd.createHTML(e):e=>e,D_="http://www.w3.org/2000/svg",E_="http://www.w3.org/1998/Math/MathML",Za=typeof document<"u"?document:null,Zm=Za&&Za.createElement("template"),Fb={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,a)=>{const l=t==="svg"?Za.createElementNS(D_,e):t==="mathml"?Za.createElementNS(E_,e):n?Za.createElement(e,{is:n}):Za.createElement(e);return e==="select"&&a&&a.multiple!=null&&l.setAttribute("multiple",a.multiple),l},createText:e=>Za.createTextNode(e),createComment:e=>Za.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Za.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,a,l,i){const r=n?n.previousSibling:t.lastChild;if(l&&(l===i||l.nextSibling))for(;t.insertBefore(l.cloneNode(!0),n),!(l===i||!(l=l.nextSibling)););else{Zm.innerHTML=$b(a==="svg"?`<svg>${e}</svg>`:a==="mathml"?`<math>${e}</math>`:e);const o=Zm.content;if(a==="svg"||a==="mathml"){const u=o.firstChild;for(;u.firstChild;)o.appendChild(u.firstChild);o.removeChild(u)}t.insertBefore(o,n)}return[r?r.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},xl="transition",Br="animation",cr=Symbol("_vtc"),Ob={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},Lb=_t({},Hf,Ob),M_=e=>(e.displayName="Transition",e.props=Lb,e),fl=M_((e,{slots:t})=>Ua(Yy,Nb(e),t)),Xl=(e,t=[])=>{Re(e)?e.forEach(n=>n(...t)):e&&e(...t)},Jm=e=>e?Re(e)?e.some(t=>t.length>1):e.length>1:!1;function Nb(e){const t={};for(const B in e)B in Ob||(t[B]=e[B]);if(e.css===!1)return t;const{name:n="v",type:a,duration:l,enterFromClass:i=`${n}-enter-from`,enterActiveClass:r=`${n}-enter-active`,enterToClass:o=`${n}-enter-to`,appearFromClass:u=i,appearActiveClass:d=r,appearToClass:s=o,leaveFromClass:c=`${n}-leave-from`,leaveActiveClass:f=`${n}-leave-active`,leaveToClass:v=`${n}-leave-to`}=e,m=B_(l),h=m&&m[0],g=m&&m[1],{onBeforeEnter:b,onEnter:p,onEnterCancelled:k,onLeave:S,onLeaveCancelled:w,onBeforeAppear:x=b,onAppear:y=p,onAppearCancelled:_=k}=t,V=(B,E,P,L)=>{B._enterCancelled=L,_l(B,E?s:o),_l(B,E?d:r),P&&P()},T=(B,E)=>{B._isLeaving=!1,_l(B,c),_l(B,v),_l(B,f),E&&E()},A=B=>(E,P)=>{const L=B?y:p,R=()=>V(E,B,P);Xl(L,[E,R]),Qm(()=>{_l(E,B?u:i),Ea(E,B?s:o),Jm(L)||eh(E,a,h,R)})};return _t(t,{onBeforeEnter(B){Xl(b,[B]),Ea(B,i),Ea(B,r)},onBeforeAppear(B){Xl(x,[B]),Ea(B,u),Ea(B,d)},onEnter:A(!1),onAppear:A(!0),onLeave(B,E){B._isLeaving=!0;const P=()=>T(B,E);Ea(B,c),B._enterCancelled?(Ea(B,f),_d(B)):(_d(B),Ea(B,f)),Qm(()=>{B._isLeaving&&(_l(B,c),Ea(B,v),Jm(S)||eh(B,a,g,P))}),Xl(S,[B,P])},onEnterCancelled(B){V(B,!1,void 0,!0),Xl(k,[B])},onAppearCancelled(B){V(B,!0,void 0,!0),Xl(_,[B])},onLeaveCancelled(B){T(B),Xl(w,[B])}})}function B_(e){if(e==null)return null;if(Vt(e))return[Fc(e.enter),Fc(e.leave)];{const t=Fc(e);return[t,t]}}function Fc(e){return Fu(e)}function Ea(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[cr]||(e[cr]=new Set)).add(t)}function _l(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const n=e[cr];n&&(n.delete(t),n.size||(e[cr]=void 0))}function Qm(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let $_=0;function eh(e,t,n,a){const l=e._endId=++$_,i=()=>{l===e._endId&&a()};if(n!=null)return setTimeout(i,n);const{type:r,timeout:o,propCount:u}=Rb(e,t);if(!r)return a();const d=r+"end";let s=0;const c=()=>{e.removeEventListener(d,f),i()},f=v=>{v.target===e&&++s>=u&&c()};setTimeout(()=>{s<u&&c()},o+1),e.addEventListener(d,f)}function Rb(e,t){const n=window.getComputedStyle(e),a=m=>(n[m]||"").split(", "),l=a(`${xl}Delay`),i=a(`${xl}Duration`),r=th(l,i),o=a(`${Br}Delay`),u=a(`${Br}Duration`),d=th(o,u);let s=null,c=0,f=0;t===xl?r>0&&(s=xl,c=r,f=i.length):t===Br?d>0&&(s=Br,c=d,f=u.length):(c=Math.max(r,d),s=c>0?r>d?xl:Br:null,f=s?s===xl?i.length:u.length:0);const v=s===xl&&/\b(?:transform|all)(?:,|$)/.test(a(`${xl}Property`).toString());return{type:s,timeout:c,propCount:f,hasTransform:v}}function th(e,t){for(;e.length<t.length;)e=e.concat(e);return Math.max(...t.map((n,a)=>nh(n)+nh(e[a])))}function nh(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function _d(e){return(e?e.ownerDocument:document).body.offsetHeight}function F_(e,t,n){const a=e[cr];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Zu=Symbol("_vod"),Qf=Symbol("_vsh"),na={name:"show",beforeMount(e,{value:t},{transition:n}){e[Zu]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):$r(e,t)},mounted(e,{value:t},{tra Line truncated
`))),e[Xn]=Ll(l);const i=a||l.props&&l.props.type==="number";al(e,t?"change":"input",r=>{r.target.composing||e[Xn](Nc(e.value,n,i))}),(n||i)&&al(e,"change",()=>{e.value=Nc(e.value,n,i)}),t||(al(e,"compositionstart",f1),al(e,"compositionend",fh),al(e,"change",fh))},mounted(e,{value:t,modifiers:{trim:n,number:a}}){const l=t??"",i=e[gu];delete e[gu],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[Xn](Nc(e.value,n,a)):e.value=l},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:a,trim:l,number:i}},r){if(e[Xn]=Ll(r),e.composing)return;const o=(i||e.type==="number")&&!/^0\d/.test(e.value)?xs(e.value):e.value,u=t??"";if(o===u)return;const d=e.getRootNode();(d instanceof Document||d instanceof ShadowRoot)&&d.activeElement===e&&e.type!=="range"&&(a&&t===n||l&&e.value.trim()===u)||(e.value=u)}},ev={deep:!0,created(e,t,n){e[Xn]=Ll(n),al(e,"change",()=>{const a=e._modelValue,l=dr(e),i=e.checked,r=e[Xn];if(Re(a)){const o=Vs(a,l),u=o!==-1;if(i&&!u)r(a.concat(l));else if(!i&&u){const d=[...a];d.splice(o,1),r(d)}}else if(La(a)){const o=new Set(a);i?o.add(l):o.delete(l),r(o)}else r(Yb(e,i))})},mounted:vh,beforeUpdate(e,t,n){e[Xn]=Ll(n),vh(e,t,n)}};function vh(e,{value:t,oldValue:n},a){e._modelValue=t;let l;if(Re(t))l=Vs(t,a.props.value)>-1;else if(La(t))l=t.has(a.props.value);else{if(t===n)return;l=va(t,Yb(e,!0))}e.checked!==l&&(e.checked=l)}const tv={created(e,{value:t},n){e.checked=va(t,n.props.value),e[Xn]=Ll(n),al(e,"change",()=>{e[Xn](dr(e))})},beforeUpdate(e,{value:t,oldValue:n},a){e[Xn]=Ll(a),t!==n&&(e.checked=va(t,a.props.value))}},Gb={deep:!0,created(e,{value:t,modifiers:{number:n}},a){e._modelValue=t,al(e,"change",()=>{const l=Array.prototype.filter.call(e.options,u=>u.selected).map(u=>n?xs(dr(u)):dr(u)),i=e.multiple,r=i?La(e._modelValue)?new Set(l):l:l[0],o=e._pendingValue=[i,i?Re(r)?l.slice():l:r];try{e[Xn](r)}finally{Ne(()=>{e._pendingValue===o&&(e._pendingValue=void 0)})}}),e[Xn]=Ll(a)},mounted(e,{value:t}){mh(e,t)},beforeUpdate(e,{value:t},n){e._modelValue=t,e[Xn]=Ll(n)},updated(e,{value:t}){const n=e._pendingValue;e._pendingValue=void 0,(!n||n[0]!==e.multiple||!v1(t,n[1],n[0]))&&mh(e,t)}};function v1(e,t,n){if(!n||Re(e))return va(e,t);if(La(e)){if(e.size!==t.length)return!1;for(const a of t)if(!e.has(a))return!1;return!0}return!1}function mh(e,t){const n=e.multiple,a=Re(t);if(!(n&&!a&&!La(t))){for(let l=0,i=e.options.length;l<i;l++){const r=e.options[l],o=dr(r);if(n)if(a){const u=typeof o;u==="string"||u==="number"?r.selected=t.some(d=>String(d)===String(o)):r.selected=Vs(t,o)>-1}else r.selected=t.has(o);else if(va(dr(r),t)){e.selectedIndex!==l&&(e.selectedIndex=l);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function dr(e){return"_value"in e?e._value:e.value}function Yb(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const qb={created(e,t,n){yu(e,t,n,null,"created")},mounted(e,t,n){yu(e,t,n,null,"mounted")},beforeUpdate(e,t,n,a){yu(e,t,n,a,"beforeUpdate")},updated(e,t,n,a){yu(e,t,n,a,"updated")}};function Xb(e,t){switch(e){case"SELECT":return Gb;case"TEXTAREA":return So;default:switch(t){case"checkbox":return ev;case"radio":return tv;default:return So}}}function yu(e,t,n,a,l){const r=Xb(e.tagName,n.props&&n.props.type)[l];r&&r(e,t,n,a)}function m1(){So.getSSRProps=({value:e})=>({value:e}),tv.getSSRProps=({value:e},t)=>{if(t.props&&va(t.props.value,e))return{checked:!0}},ev.getSSRProps=({value:e},t)=>{if(Re(e)){if(t.props&&Vs(e,t.props.value)>-1)return{checked:!0}}else if(La(e)){if(t.props&&e.has(t.props.value))return{checked:!0}}else if(e)return{checked:!0}},qb.getSSRProps=(e,t)=>{if(typeof t.type!="string")return;const n=Xb(t.type.toUpperCase(),t.props&&t.props.type);if(n.getSSRProps)return n.getSSRProps(e,t)}}const h1=["ctrl","shift","alt","meta"],g1={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>h1.some(n=>e[`${n}Key`]&&!t.includes(n))},sa=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),a=t.join(".");return n[a]||(n[a]=((l,...i)=>{for(let r=0;r<t.length;r++){const o=g1[t[r]];if(o&&o(l,t))return}return e(l,...i)}))},y1={esc:"escape",space:" ",up:"arrow-up",left:"arrow-left",right:"arrow-right",down:"arrow-down",delete:"backspace"},Zb=(e,t)=>{const n=e._withKeys||(e._withKeys={}),a=t.join(".");return n[a]||(n[a]=(l=>{if(!("key"in l))return;const i=Hn(l.key);if(t.some(r=>r===i||y1[r]===i))return e(l)}))},Jb=_t({patchProp:zb},Fb);let Qr,hh=!1;function Qb(){return Qr||(Qr=bb(Jb))}function ew(){return Qr=hh?Qr:wb(Jb),hh=!0,Qr}const Us=((...e)=>{Qb().render(...e)}),b1=((...e)=>{ew().hydrate(...e)}),es=((...e)=>{const t=Qb().createApp(...e),{mount:n}=t;return t.mount=a=>{const l=aw(a);if(!l)return;const i=t._component;!Qe(i)&&!i.render&&!i.template&&(i.template=l.innerHTML),l.nodeType===1&&(l.textContent="");const Line truncated
* vue v3.5.44
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/const S1=()=>{},k1=Object.freeze(Object.defineProperty({__proto__:null,BaseTransition:Yy,BaseTransitionPropsValidators:Hf,Comment:qt,DeprecationTypes:T_,EffectScope:Ef,ErrorCodes:Fx,ErrorTypeStrings:x_,Fragment:we,KeepAlive:gC,ReactiveEffect:oo,Static:Tl,Suspense:l_,Teleport:Ky,Text:sl,TrackOpTypes:Ax,Transition:fl,TransitionGroup:zs,TriggerOpTypes:Tx,VueElement:Hs,assertNumber:$x,callWithAsyncErrorHandling:Qn,callWithErrorHandling:Sr,camelize:nn,capitalize:yl,cloneVNode:ha,compatUtils:A_,compile:S1,computed:M,createApp:es,createBlock:tn,createCommentVNode:zt,createElementBlock:St,createElementVNode:I,createHydrationRenderer:wb,createPropsRestProxy:$C,createRenderer:bb,createSSRApp:tw,createSlots:SC,createStaticVNode:f_,createTextVNode:nt,createVNode:C,customRef:Dy,defineAsyncComponent:mC,defineComponent:Bs,defineCustomElement:Wb,defineEmits:_C,defineExpose:VC,defineModel:AC,defineOptions:IC,defineProps:CC,defineSSRCustomElement:Q_,defineSlots:PC,devtools:C_,effect:lx,effectScope:Fl,getCurrentInstance:_n,getCurrentScope:Mf,getCurrentWatcher:Dx,getTransitionRawChildren:Ms,guardReactiveProps:Ko,h:Ua,handleError:Vi,hasInjectionContext:Ry,hydrate:b1,hydrateOnIdle:uC,hydrateOnInteraction:fC,hydrateOnMediaQuery:dC,hydrateOnVisible:cC,initCustomFormatter:S_,initDirectivesForSSR:w1,inject:Ye,isMemoSame:Mb,isProxy:wr,isReactive:ka,isReadonly:Ca,isRef:Mt,isRuntimeOnly:y_,isShallow:jn,isVNode:_a,markRaw:Of,mergeDefaults:MC,mergeModels:BC,mergeProps:le,nextTick:Ne,nodeOps:Fb,normalizeClass:fe,normalizeProps:_s,normalizeStyle:Se,onActivated:Uf,onBeforeMount:Ii,onBeforeUnmount:sn,onBeforeUpdate:Os,onDeactivated:$s,onErrorCaptured:eb,onMounted:Rt,onRenderTracked:Qy,onRenderTriggered:Jy,onScopeDispose:pt,onServerPrefetch:Zy,onUnmounted:Pi,onUpdated:kr,onWatcherCleanup:My,openBlock:Ze,patchProp:zb,popScopeId:zx,provide:bt,proxyRefs:Lf,pushScopeId:Hx,queuePostFlushCb:fo,reactive:yn,readonly:Lt,ref:oe,registerRuntimeCompiler:g_,render:Us,renderList:Uu,renderSlot:kC,resolveComponent:tb,resolveDirective:wC,resolveDynamicComponent:ab,resolveFilter:P_,resolveTransitionHooks:sr,setBlockTracking:bo,setDevtoolsHook:__,setTransitionHooks:Ha,shallowReactive:co,shallowReadonly:Wn,shallowRef:ne,ssrContextKey:Hy,ssrUtils:I_,stop:ix,toDisplayString:it,toHandlerKey:qr,toHandlers:pC,toRaw:Oe,toRef:z,toRefs:_i,toValue:pe,transformVNodeArgs:c_,triggerRef:dd,unref:Dt,useAttrs:EC,useCssModule:n1,useCssVars:L_,useHost:Ub,useId:Wt,useModel:UC,useSSRContext:zy,useShadowRoot:t1,useSlots:DC,useTemplateRef:Xx,useTransitionState:Rf,vModelCheckbox:ev,vModelDynamic:qb,vModelRadio:tv,vModelSelect:Gb,vModelText:So,vShow:na,version:Bb,warn:p_,watch:ve,watchEffect:Xt,watchPostEffect:Ux,watchSyncEffect:Wy,withAsyncContext:FC,withCtx:Ve,withDefaults:TC,withDirectives:xt,withKeys:Zb,withMemo:k_,withModifiers:sa,withScopeId:Wx},Symbol.toStringTag,{value:"Module"}));function js(e,t){t=Array.isArray(t)?t.slice(0,-1).map(n=>`'${n}'`).join(", ")+` or '${t.at(-1)}'`:`'${t}'`}var yh={};const zn=typeof window<"u",bh=zn&&"matchMedia"in window&&typeof window.matchMedia=="function",p1=zn&&"ResizeObserver"in window,x1=zn&&"IntersectionObserver"in window,C1=typeof yh.VITE_LOGGER_ENABLED<"u"&&yh.VITE_LOGGER_ENABLED==="true",_1=/[\u0300-\u036F]/g,V1=/ς/g;function Pd(e){return e.toLowerCase().replace(V1,"σ")}const lw={ł:"l",ø:"o",đ:"d",ð:"d",þ:"th",ħ:"h",ŧ:"t",ŋ:"n",ß:"ss",æ:"ae",œ:"oe",ı:"i",Ł:"L",Ø:"O",Đ:"D",Ð:"D",Þ:"Th",Ħ:"H",Ŧ:"T",Ŋ:"N","ẞ":"Ss",Æ:"Ae",Œ:"Oe"},I1=new RegExp(`[${Object.keys(lw).join("")}]`,"g");function iw(e){return e.normalize("NFD").replace(_1,"").replace(I1,t=>lw[t])}function P1(e,t,n){let a="";const l=[];let i=0;for(const r of e){const o=t?Pd(r):r,u=n?iw(o):o;a+=u;for(let d=0;d<u.length;d++)l.push(i);i+=r.length}return l.push(e.length),{folded:a,map:l}}function Rc(e,t,n){const a=[];let l=e.indexOf(t);for(;l!==-1&&(a.push([l,l+t.length]),!!n);)l=e.indexOf(t,l+t.length);return a}function A1(e,t,n){const a=e[t];let l=e[n];if(n>0&&e[n]===e[n-1]){let i=n;const r=e[n];for(;i<e.length&&e[i]===r;)i++;l=e[i]}return[a,l]}function T1(e,t){const n=[];for(const[a,l]of e){const i=A1(t,a,l),r=n.at(-1);(!r||r[0]!==i[0]||r[1]!==i[1])&&n.push(i)}return n}function Hc(e,t,n,a,l,i){const r=a?iw(t):t,o=n?Pd(r):r;if(o.length===0)return[];if(!l){if(!n)return Rc(e,o,i);const s=Pd(e);if(s.length===e.length)return Rc(s,o,i)}const{folded:u,map:d}=P1(e,n,l);return T1(Rc(u,o,i),d)}function D1(e,t){const n=[...e,...t].toSorted((l,i)=>l[0]-i[0]||l[1]-i[1]),a=[];for(const l of n){const i=a.at(-1);i&&l[0]<i[1]||a.push(l)}return a}function rw(e,t,n={}){const{ignoreCase:a=!1,ignoreAccents:l=!1,matchAll:i=!1}=n,r=l===!0||l==="query",o=l===!0||l==="target";if(r===o)return Hc(e,t,a,r,o,i);const u=D1(Hc(e,t,a,!1,!1,!0),Hc(e,t,a,r,o,!0));return i?u:u.slice(0,1)}const wh="currentInstance";function E1(){const e=k1;return wh in e?e[wh]:_n()}function M1(){return!dl(E1())}function Ge(e){return typeof e=="function"}function Ie(e){return typeof e=="string"}function ct(eLine truncated
Expected #hex, #hexa, rgb(), rgba(), hsl(), hsla(), object or number`)}function za(e){const{h:t,s:n,v:a,a:l}=e,i=o=>{const u=(o+t/60)%6;return a-a*n*Math.max(Math.min(u,4-u,1),0)},r=[i(5),i(3),i(1)].map(o=>Math.round(o*255));return{r:r[0],g:r[1],b:r[2],a:l}}function Kh(e){return za(sv(e))}function qo(e){if(!e)return{h:0,s:1,v:1,a:1};const t=e.r/255,n=e.g/255,a=e.b/255,l=Math.max(t,n,a),i=Math.min(t,n,a);let r=0;l!==i&&(l===t?r=60*(0+(n-a)/(l-i)):l===n?r=60*(2+(a-t)/(l-i)):l===a&&(r=60*(4+(t-n)/(l-i)))),r<0&&(r=r+360);const o=l===0?0:(l-i)/l,u=[r,o,l];return{h:u[0],s:u[1],v:u[2],a:e.a}}function Fd(e){const{h:t,s:n,v:a,a:l}=e,i=a-a*n/2,r=i===1||i===0?0:(a-i)/Math.min(i,1-i);return{h:t,s:r,l:i,a:l}}function sv(e){const{h:t,s:n,l:a,a:l}=e,i=a+n*Math.min(a,1-a),r=i===0?0:2-2*a/i;return{h:t,s:r,v:i,a:l}}function Tw({r:e,g:t,b:n,a}){return ct(a)?`rgb(${e}, ${t}, ${n}, ${a})`:`rgb(${e}, ${t}, ${n})`}function Dw(e){return Tw(za(e))}function ku(e){const t=Math.round(e).toString(16);return("00".substr(0,2-t.length)+t).toUpperCase()}function Ew({r:e,g:t,b:n,a}){return`#${[ku(e),ku(t),ku(n),ct(a)?ku(Math.round(a*255)):""].join("")}`}function Mw(e){e=jV(e);let[t,n,a,l]=kV(e,2).map(i=>parseInt(i,16));return l=ct(l)?l/255:l,{r:t,g:n,b:a,a:l}}function UV(e){const t=Mw(e);return qo(t)}function Bw(e){return Ew(za(e))}function jV(e){return e.startsWith("#")&&(e=e.slice(1)),e=e.replace(/([^0-9a-f])/gi,"F"),(e.length===3||e.length===4)&&(e=e.split("").map(t=>t+t).join("")),e.length!==6&&(e=Ah(Ah(e,6),8,"F")),e}function KV(e,t){const n=Iw(uv(e));return n[0]=n[0]+t*10,Aw(Pw(n))}function GV(e,t){const n=Iw(uv(e));return n[0]=n[0]-t*10,Aw(Pw(n))}function Od(e){const t=Zn(e);return uv(t)[1]}function YV(e,t){const n=Od(e),a=Od(t),l=Math.max(n,a),i=Math.min(n,a);return(l+.05)/(i+.05)}function $w(e){const t=Math.abs(Uh(Zn(0),Zn(e)));return Math.abs(Uh(Zn(16777215),Zn(e)))>Math.min(t,50)}function te(e,t){return n=>Object.keys(e).reduce((a,l)=>{const r=je(e[l])?e[l]:{type:e[l]};return n&&l in n?a[l]={...r,default:n[l]}:a[l]=r,t&&!a[l].source&&(a[l].source=t),a},{})}const Ae=te({class:[String,Array,Object],style:{type:[String,Array,Object],default:null}},"component");function Ut(e,t){const n=_n();if(!n)throw new Error(`[Vuetify] ${e} must be called from inside a setup function`);return n}function ja(e="composables"){const t=Ut(e).type;return oi((t==null?void 0:t.aliasName)||(t==null?void 0:t.name))}function Fw(e,t=Ut("injectSelf")){const{provides:n}=t;if(n&&e in n)return n[e]}const bi=Symbol.for("vuetify:defaults"),Ow=Symbol.for("vuetify:defaults:root");function qV(e){return oe(e)}function Ys(){const e=Ye(bi);if(!e)throw new Error("[Vuetify] Could not find defaults instance");return e}function $t(e,t){const n=Ys(),a=Ye(Ow,null),l=oe(e),i=M(()=>{var c;if(Dt(t==null?void 0:t.disabled))return n.value;const o=Dt(t==null?void 0:t.scoped),u=Dt(t==null?void 0:t.reset),d=Dt(t==null?void 0:t.root);if(!l.value&&!(o||u||d))return n.value;let s=xn(l.value,{prev:n.value});if(o)return s;if(u||d){const f=Number(u||1/0),v=Ie(d)?(c=s.prev)==null?void 0:c[d]:void 0;d&&(a!=null&&a.value)&&(s=a.value);for(let m=0;m<=f&&!(!s||!("prev"in s));m++)s=s.prev;return s&&v&&(s=xn(xn(s,{prev:s}),v)),s}return s.prev?xn(s.prev,s,void 0,(f,v)=>v!==void 0):s});return bt(bi,i),i}function Gh(e){const t=Ut("injectComponentDefaults");return z(()=>{var n,a;return(a=(n=Fw(bi,t))==null?void 0:n.value)==null?void 0:a[e]})}function aa(e){const t=Ut("injectNestedDefaults"),n=Ys(),a=t.props._as??t.type.name;return z(()=>{var l,i;return(i=(l=n.value)==null?void 0:l[a])==null?void 0:i[e]})}function XV(e,t){return e.props&&(typeof e.props[t]<"u"||typeof e.props[oi(t)]<"u")}function ZV(e={},t,n=Ys()){const a=Ut("useDefaults");if(t=t??a.type.name??a.type.__name,!t)throw new Error("[Vuetify] Could not determine component name");const l=M(()=>{var u;return(u=n.value)==null?void 0:u[e._as??t]}),i=new Proxy(e,{get(u,d){var v,m,h,g;const s=Reflect.get(u,d);if(d==="class"||d==="style")return[(v=l.value)==null?void 0:v[d],s].filter(b=>b!=null);if(XV(a.vnode,d))return s;const c=(m=l.value)==null?void 0:m[d];if(c!==void 0)return c;const f=(g=(h=n.value)==null?void 0:h.global)==null?void 0:g[d];return f!==void 0?f:s}}),r=ne();Xt(()=>{if(l.value){const u=Object.entries(l.value).filter(([d])=>d.startsWith(d[0].toUpperCase()));r.value=u.length?Object.fromEntries(u):void 0}else r.value=void 0});function o(){const u=Fw(bi,a);bt(bi,M(()=>r.value?xn((u==null?void 0:u.value)??{},r.value):u==null?void 0:u.value))}return{props:i,provideSubDefaults:o}}function In(e){if(e._setup=e._setup??e.setup,!e.name)return e;if(e._setup){e.props=te(e.props??{},e.name)();const t=Object.keys(e.props).filter(n=>n!=="class"&&n!=="style");e.filterProps=function(a){return un(a,t)},e.props._as=String,e.setup=function(a,l){const i=Ys();if(!i.value)return e._setup(a,l);const{props:r,provideSubDefaults:o}=ZV(a,a._as??e.name,i),u=e._setup(r,l);return o(),u}}return e}function de(e=!0){return t=>(e?In:Bs)(t)}function JV(e,t){return t Line truncated
KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PY SA SG SV TH TT TW UM US
VE VI WS YE ZA ZW`.includes(t):return{firstDay:0,firstWeekSize:1};case`AI AL AM AR AU AZ BA BM BN BY CL CM CN CR CY EC GE HR KG KZ LB LK LV
MD ME MK MN MY NZ RO RS SI TJ TM TR UA UY UZ VN XK`.includes(t):return{firstDay:1,firstWeekSize:1};case`AD AN AT AX BE BG CH CZ DE DK EE ES FI FJ FO FR GB GF GP GR HU IE IS
IT LI LT LU MC MQ NL NO PL RE RU SE SK SM VA`.includes(t):return{firstDay:1,firstWeekSize:4};case"AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY".includes(t):return{firstDay:6,firstWeekSize:1};case t==="MV":return{firstDay:5,firstWeekSize:1};case t==="PT":return{firstDay:0,firstWeekSize:4};default:return null}}function bI(e,t,n){var s;const a=[];let l=[];const i=jw(e),r=Kw(e),o=n??((s=Xo(t))==null?void 0:s.firstDay)??0,u=(i.getDay()-o+7)%7,d=(r.getDay()-o+7)%7;for(let c=0;c<u;c++){const f=new Date(i);f.setDate(f.getDate()-(u-c)),l.push(f)}for(let c=1;c<=r.getDate();c++){const f=new Date(e.getFullYear(),e.getMonth(),c);l.push(f),l.length===7&&(a.push(l),l=[])}for(let c=1;c<7-d;c++){const f=new Date(r);f.setDate(f.getDate()+c),l.push(f)}return l.length>0&&a.push(l),a}function ao(e,t,n){var i;let a=(n??((i=Xo(t))==null?void 0:i.firstDay)??0)%7;[0,1,2,3,4,5,6].includes(a)||(a=0);const l=new Date(e);for(;l.getDay()!==a;)l.setDate(l.getDate()-1);return l}function wI(e,t){var l;const n=new Date(e),a=((((l=Xo(t))==null?void 0:l.firstDay)??0)+6)%7;for(;n.getDay()!==a;)n.setDate(n.getDate()+1);return n}function jw(e){return new Date(e.getFullYear(),e.getMonth(),1)}function Kw(e){return new Date(e.getFullYear(),e.getMonth()+1,0)}function SI(e){const t=e.split("-").map(Number);return new Date(t[0],t[1]-1,t[2])}const kI=/^([12]\d{3}-([1-9]|0[1-9]|1[0-2])-([1-9]|0[1-9]|[12]\d|3[01]))$/;function Gw(e){if(e==null)return new Date;if(e instanceof Date)return e;if(typeof e=="string"){let t;if(kI.test(e))return SI(e);if(t=Date.parse(e),!isNaN(t))return new Date(t)}return null}const Jh=new Date(2e3,0,2);function pI(e,t,n){var l;const a=t??((l=Xo(e))==null?void 0:l.firstDay)??0;return da(7).map(i=>{const r=new Date(Jh);return r.setDate(Jh.getDate()+a+i),new Intl.DateTimeFormat(e,{weekday:n??"narrow"}).format(r)})}function xI(e,t,n,a){const l=Gw(e)??new Date,i=a==null?void 0:a[t];if(typeof i=="function")return i(l,t,n);let r={};switch(t){case"fullDate":r={year:"numeric",month:"short",day:"numeric"};break;case"fullDateWithWeekday":r={weekday:"long",year:"numeric",month:"long",day:"numeric"};break;case"normalDate":const o=l.getDate(),u=new Intl.DateTimeFormat(n,{month:"long"}).format(l);return`${o} ${u}`;case"normalDateWithWeekday":r={weekday:"short",day:"numeric",month:"short"};break;case"shortDate":r={month:"short",day:"numeric"};break;case"year":r={year:"numeric"};break;case"month":r={month:"long"};break;case"monthShort":r={month:"short"};break;case"monthAndYear":r={month:"long",year:"numeric"};break;case"monthAndDate":r={month:"long",day:"numeric"};break;case"weekday":r={weekday:"long"};break;case"weekdayShort":r={weekday:"short"};break;case"dayOfMonth":return new Intl.NumberFormat(n).format(l.getDate());case"hours12h":r={hour:"numeric",hour12:!0};break;case"hours24h":r={hour:"numeric",hour12:!1};break;case"minutes":r={minute:"numeric"};break;case"seconds":r={second:"numeric"};break;case"fullTime":r={hour:"numeric",minute:"numeric"};break;case"fullTime12h":r={hour:"numeric",minute:"numeric",hour12:!0};break;case"fullTime24h":r={hour:"numeric",minute:"numeric",hour12:!1};break;case"fullDateTime":r={year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"numeric"};break;case"fullDateTime12h":r={year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"numeric",hour12:!0};break;case"fullDateTime24h":r={year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"numeric",hour12:!1};break;case"keyboardDate":r={year:"numeric",month:"2-digit",day:"2-digit"};break;case"keyboardDateTime":return r={year:"numeric",month:"2-digit",day:"2-digit",hour:"numeric",minute:"numeric"},new Intl.DateTimeFormat(n,r).format(l).replace(/, /g," ");case"keyboardDateTime12h":return r={year:"numeric",month:"2-digit",day:"2-digit",hour:"numeric",minute:"numeric",hour12:!0},new Intl.DateTimeFormat(n,r).format(l).replace(/, /g," ");case"keyboardDateTime24h":return r={year:"numeric",month:"2-digit",day:"2-digit",hour:"numeric",minute:"numeric",hour12:!1},new Intl.DateTimeFormat(n,r).format(l).replace(/, /g," ");default:r=i??{timeZone:"UTC",timeZoneName:"short"}}return new Intl.DateTimeFormat(n,r).format(l)}function CI(e,t){const n=e.toJsDate(t),a=n.getFullYear(),l=Th(String(n.getMonth()+1),2,"0"),i=Th(String(n.getDate()),2,"0");return`${a}-${l}-${i}`}function _I(e){const[t,n,a]=e.split("-").map(Number);return new Date(t,n-1,a)}function VI(e,t){const n=new Date(e);return n.setMinutes(n.getMinutes()+t),n}function II(e,t){const n=new Date(e);return n.setHours(n.getHours()+t),n}function ui(e,t){const n=new Date(e);return n.setDate(n.getDate()+t),n}function PI(e,t){const n=new Date(e);return n.setDate(n.getDate()+t*7),n}function AI(e,t){const n=new Date(e);return n.setDate(1),n.setMonth(n.getMonth()+t),n}function Ao(e){return e.getFullYear()}function TI(e){return e.getMonth()}function DI(e,t,n,a){const l=Xo(t),i=n??(l==null?void 0:l.firstDay)??0,r=(l==null?void 0:l.firstWeekSize)??1;return a!==void 0?EI(e,t,i,a):MI(e,t,i,r)}function EI(e, Line truncated
`,...n.map(l=>` ${l};
`),`}
`)}function lg(e,t){const n=e.dark?2:1,a=e.dark?1:2,l=[];for(const[i,r]of Object.entries(e.colors)){const o=Zn(r);l.push(`--${t}theme-${i}: ${o.r},${o.g},${o.b}`+(ct(o.a)?`,${o.a}`:"")),i.startsWith("on-")||l.push(`--${t}theme-${i}-overlay-multiplier: ${Od(r)>.18?n:a}`)}for(const[i,r]of Object.entries(e.variables)){const o=Ie(r)&&r.startsWith("#")?Zn(r):void 0,u=o?`${o.r}, ${o.g}, ${o.b}`+(ct(o.a)?`, ${o.a}`:""):void 0;l.push(`--${t}${i}: ${u??r}`)}return l}function sP(e,t,n){const a={};if(n)for(const l of["lighten","darken"]){const i=l==="lighten"?KV:GV;for(const r of da(n[l],1))a[`${e}-${l}-${r}`]=Ew(i(Zn(t),r))}return a}function cP(e,t){if(!t)return{};let n={};for(const a of t.colors){const l=e[a];l&&(n={...n,...sP(a,l,t)})}return n}function dP(e,t){const n={};for(const a of Object.keys(e)){if(a.startsWith("on-")||e[`on-${a}`])continue;const l=`on-${a}`,i=Zn(e[a]);n[l]=$w(i)?t["theme-on-dark"]:t["theme-on-light"]}return n}function fP(e,t){if(!t)return e;const n=`:where(${t})`;return e===":root"?n:`${n} ${e}`}function vP(e,t,n){const a=mP(e,t);a&&(a.innerHTML=n)}function mP(e,t){if(!rt)return null;let n=document.getElementById(e);return n||(n=document.createElement("style"),n.id=e,n.type="text/css",t&&n.setAttribute("nonce",t),document.head.appendChild(n)),n}function hP(e){const t=uP(e),n=ne(t.defaultTheme),a=oe(t.themes),l=ne("light"),i=M({get(){return n.value==="system"?l.value:n.value},set(x){n.value=x}}),r=M(()=>{const x={};for(const[y,_]of Object.entries(a.value)){const V=_.dark||y==="dark"?a.value.dark:a.value.light,T=xn(V,_),A={...T.colors,...cP(T.colors,t.variations)};x[y]={...T,colors:{...A,...dP(A,T.variables)}}}return x}),o=z(()=>r.value[i.value]),u=z(()=>n.value==="system"),d=M(()=>{var _;const x=[],y=t.scoped?t.prefix:"";x.push(`@layer theme-base {
`),Zl(x,":root",[`color-scheme: ${(_=o.value)!=null&&_.dark?"dark":"light"}`],t.scope),Zl(x,":root",lg(o.value,t.prefix),t.scope);for(const[V,T]of Object.entries(r.value))Zl(x,`.${t.prefix}theme--${V}`,[`color-scheme: ${T.dark?"dark":"light"}`,...lg(T,t.prefix)],t.scope);if(x.push(`}
`),t.utilities){const V=[],T=[],A=new Set(Object.values(r.value).flatMap(B=>Object.keys(B.colors)));for(const B of A)B.startsWith("on-")?Zl(T,`.${B}`,[`color: rgb(var(--${t.prefix}theme-${B}))`],t.scope):(Zl(V,`.${y}bg-${B}`,[`--${t.prefix}theme-overlay-multiplier: var(--${t.prefix}theme-${B}-overlay-multiplier)`,`background-color: rgb(var(--${t.prefix}theme-${B}))`,`color: rgb(var(--${t.prefix}theme-on-${B}))`],t.scope),Zl(T,`.${y}text-${B}`,[`color: rgb(var(--${t.prefix}theme-${B}))`],t.scope),Zl(T,`.${y}border-${B}`,[`--${t.prefix}border-color: var(--${t.prefix}theme-${B})`],t.scope));x.push(`@layer theme-background {
`,...V.map(B=>` ${B}`),`}
`,`@layer theme-foreground {
`,...T.map(B=>` ${B}`),`}
`)}return`@layer vuetify-utilities {
`+x.map(V=>` ${V}`).join("")+`
}`}),s=z(()=>t.isDisabled?void 0:`${t.prefix}theme--${i.value}`),c=z(()=>Object.keys(r.value));if(Ks){let y=function(){l.value=x.matches?"dark":"light"};var w=y;const x=window.matchMedia("(prefers-color-scheme: dark)");y(),x.addEventListener("change",y,{passive:!0}),Mf()&&pt(()=>{x.removeEventListener("change",y)})}function f(x){if(t.isDisabled)return;const y=x._context.provides.usehead;if(y){let T=function(){return{style:[{textContent:d.value,id:t.stylesheetId,nonce:t.cspNonce||!1,tagPosition:"bodyOpen"}]}};var _=T;if(y.push){const A=y.push(T);rt&&ve(d,()=>{A.patch(T)})}else rt?(y.addHeadObjs(z(T)),Xt(()=>y.updateDOM())):y.addHeadObjs(T())}else{let T=function(){vP(t.stylesheetId,t.cspNonce,d.value)};var V=T;rt?ve(d,T,{immediate:!0}):T()}}let v;function m(x){if(!x){v=null;return}let y,_;if(ri(x)){const A=new Cn(x);y=A.left+A.width/2,_=A.top+A.height/2}else y=x.clientX,_=x.clientY;const V=Math.min(98.9,100*y/window.innerWidth),T=100*_/window.innerHeight;v=`${V.toFixed(2)}% ${T.toFixed(2)}%`}function h(x){if(!(x??t.transition)&&!v)return!1;const _=je(t.transition)?t.transition:{},V=je(x)?x:{};return{origin:V.origin??v??_.origin,duration:V.duration??_.duration}}function g(x,y){if(!rt||!document.startViewTransition||ea())return x(),Promise.resolve();const _=y.origin??"50% 0%",V=y.duration??"500ms",T=document.createElement("style");T.textContent=`html:active-view-transition-type(vuetify-theme)::view-transition-old(root) { animation: none; }html:active-view-transition-type(vuetify-theme)::view-transition-new(root) { animation: v-circle-in ${V} ease-in-out; }@keyframes v-circle-in { from { clip-path: circle(0% at ${_}); } to { clip-path: circle(150% at ${_}); }}`,document.head.appendChild(T);const A=document.startViewTransition({update:()=>x(),types:["vuetify-theme"]});return A.finished.then(()=>{v=null,T.remove()}),A.updateCallbackDone}function b(x,y){if(x!=="system"&&!c.value.includes(x))return Promise.resolve();const _=()=>{i.value=x},V=h(y);return V?g(_,V):(_(),Promise.resolve())}function p(x=c.value,y){const _=x.indexOf(i.value),V=_===-1?0:(_+1)%x.length;return b(x[V],y)}function k(x=["light","dark"],y){return p(x,y)}const S=new Proxy(i,{get(x,y){return Reflect.get(x,y)},set(x,y,_){return y==="value"&&js(`theme.global.name.value = ${_}`,`theme.change('${_}')`),Reflect.set(x,y,_)}});return{install:f,change:b,cycle:p,toggle:k,setTransitionOrigin:m,isDisabled:t.isDisabled,isSystem:u,name:i,themes:a,current:o,computedThemes:r,prefix:t.prefix,themeClasses:s,styles:d,global:{name:S,current:o}}}function ft(e){Ut("provideTheme");const t=Ye(Do,null);if(!t)throw new Error("Could not find Vuetify theme injection");const n=z(()=>e.theme??t.name.value),i={...t,name:n,current:z(()=>t.computedThemes.value[n.value]),themeClasses:z(()=>t.isDisabled?void 0:`${t.prefix}theme--${n.value}`)};return bt(Do,i),i}function yv(){Ut("useTheme");const e=Ye(Do,null);if(!e)throw new Error("Could not find Vuetify theme injection");return e}const Eo=Symbol.for("vuetify:layout"),Jw=Symbol.for("vuetify:layout-item"),ig=1e3,Qw=te({overlaps:{type:Array,default:()=>[]},fullHeight:Boolean},"layout"),Ai=te({name:{type:String},order:{type:[Number,String],default:0},absolute:Boolean},"layout-item");function bv(){const e=Ye(Eo);if(!e)throw new Error("[Vuetify] Could not find injected layout");return{getLayoutItem:e.getLayoutItem,mainRect:e.mainRect,mainStyles:e.mainStyles,layoutRect:e.layoutRect}}function Ti(e){const t=Ye(Eo);if(!t)throw new Error("[Vuetify] Could not find injected layout");const n=e.id??`layout-item-${Wt()}`,a=Ut("useLayoutItem");bt(Jw,{id:n});const l=ne(!1);$s(()=>l.value=!0),Uf(()=>l.value=!1);const{layoutItemStyles:i,layoutItemScrimStyles:r}=t.register(a,{...e,active:M(()=>l.value?!1:e.active.value),id:n});return sn(()=>t.unregister(n)),{layoutItemStyles:i,layoutRect:t.layoutRect,layoutItemScrimStyles:r}}const gP=(e,t,n,a,l,i)=>{let r={top:0,left:0,right:0,bottom:0},o={...r};const u=[{id:"",layer:{...r},percent:{...o}}];for(const d of e){const s=t.get(d),c=n.get(d),f=a.get(d);if(!s||!c||!f)continue;const v={...r,[s.value]:r[s.value]+(f.value?l(c.value,s.value):0)},m={...o,[s.value]:o[s.value]+(f.value?i(c.value):0)};u.push({id:d,layer:v,percent:m}),r=v,o=m}return u};function eS(e){const t=Ye(Eo,null),n=M(()=>t?t.rootZIndex.value-100:ig),a=oe([]),l=yn(new Map),i=yn(new Map),r=yn(new Map),o=yn(new Map),u=yn(new Map),d=yi(),s=ne();vl(()=>d.el,A=>{const{left:B,top:E,width:P,height:L}=A[0].contentRect;s.value=new Cn({x:B,y:E,width:P,height:L})});function c(A,B){var P,L;const E=B==="left"||B==="right"?(P=s.value)==null?void 0:P.width:(L=s.value)==null?void 0:L.height;return Td(A,E??0)}function f(A){return!s.value&&ns(A)?parseFloat(A):0}function v(A,B){return B?`calc(${B}% + ${A}px)`:`${A}px`}const m=ne(!1);let h=-1;ve(()=>{var A,B;return[(A=s.value)==null?void 0:A.width,(B=s.value)==null?void 0:B.height]},()=>{m.value=!0,window.clearTimeout(h),h=window.setTimeout(()=>m.value=!1,100)}),pt(()=>window.clearTimeout(h));const g=M(()=>{con Line truncated
`);c=m.pop();for(const h of m){const g=h.match(/^data:\s*(.*)$/m);if(g)try{const b=JSON.parse(g[1]);b.status==="progress"&&i?i(b):b.status==="completed"&&r?r(b):b.status==="error"&&o&&o(b)}catch(b){console.warn("SSE 파싱 에러:",b,g[1])}}}}function Jl(e){return`${Hi}/files/download?path=${encodeURIComponent(e)}`}function Cg(e){return`${Hi}/files/download-folder?path=${encodeURIComponent(e)}`}async function rD(e,t="selected_files.zip"){const n=await fetch(`${Hi}/files/download-batch`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({paths:e,zip_name:t})});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.detail||"일괄 다운로드 생성에 실패했습니다.")}const a=await n.blob(),l=window.URL.createObjectURL(a),i=document.createElement("a");i.href=l,i.download=t,document.body.appendChild(i),i.click(),window.URL.revokeObjectURL(l),document.body.removeChild(i)}const r0=Ka("v-alert-title");function oD(e,t){const n=oe(),a=ne(!1),l=ne(!1),{isIntersecting:i}=xr(n),r=oV(()=>e.value=!1,{duration:t}),o=z(()=>e.value&&i.value&&!a.value&&!l.value),u=z(()=>pe(t)>0?r.remaining.value/pe(t)*100:100);return ve([o,()=>pe(t)],()=>o.value?r.start():r.stop()),ve(e,()=>{a.value=!1,l.value=!1}),{rootEl:n,progress:u,events:{onPointerenter:()=>a.value=!0,onPointerleave:()=>a.value=!1,onFocusin:()=>l.value=!0,onFocusout(s){s.currentTarget.contains(s.relatedTarget)||(l.value=!1)}}}}const o0=te({iconSize:[Number,String],iconSizes:{type:Array,default:()=>[["x-small",10],["small",16],["default",24],["large",28],["x-large",32]]}},"iconSize");function u0(e,t){return{iconSize:M(()=>{const a=new Map(e.iconSizes),l=e.iconSize??t()??"default";return a.has(l)?a.get(l):l})}}const uD=["success","info","warning","error"],sD=te({border:{type:[Boolean,String],validator:e=>Bt(e)||["top","end","bottom","start"].includes(e)},borderColor:String,closable:Boolean,closeIcon:{type:De,default:"$close"},closeLabel:{type:String,default:"$vuetify.close"},icon:{type:[Boolean,String,Function,Object],default:null},modelValue:{type:Boolean,default:!0},prominent:Boolean,title:String,text:String,timeout:{type:[Number,String],default:-1},timer:[Boolean,String],type:{type:String,validator:e=>uD.includes(e)},...Ae(),...Ft(),...Jt(),...en(),...o0(),...Ya(),...Vr(),...It(),...Xe(),...st(),...Ga({transition:{component:Di}}),...ia({variant:"flat"})},"VAlert"),s0=de()({name:"VAlert",props:sD(),emits:{"click:close":e=>!0,"update:modelValue":e=>!0},setup(e,{emit:t,slots:n}){const a=Pe(e,"modelValue"),l=z(()=>{if(e.icon!==!1)return e.type?e.icon??`$${e.type}`:e.icon}),{iconSize:i}=u0(e,()=>e.prominent?44:void 0),{themeClasses:r}=ft(e),{colorClasses:o,colorStyles:u,variantClasses:d}=kl(()=>({color:e.color??e.type,variant:e.variant})),{densityClasses:s}=hn(e),{dimensionStyles:c}=Qt(e),{elevationClasses:f}=Yt(e),{locationStyles:v}=Gl(e),{positionClasses:m}=Ir(e),{roundedClasses:h,roundedStyles:g}=Tt(e),{textColorClasses:b,textColorStyles:p}=Gt(()=>e.borderColor),{t:k}=dt(),{rootEl:S,progress:w,events:x}=oD(a,()=>Number(e.timeout)),y=z(()=>({"aria-label":k(e.closeLabel),onClick(_){a.value=!1,t("click:close",_)}}));return()=>{const _=!!(n.prepend||l.value),V=!!(n.title||e.title),T=!!(n.close||e.closable),A=!!e.timer&&Number(e.timeout)>0,B={density:e.density,icon:l.value,size:e.iconSize||e.prominent?i.value:void 0};return C(fn,{transition:e.transition},{default:()=>[a.value&&C(e.tag,le({ref:S,class:["v-alert",e.border&&{"v-alert--border":!!e.border,[`v-alert--border-${e.border===!0?"start":e.border}`]:!0},{"v-alert--prominent":e.prominent},r.value,o.value,s.value,f.value,m.value,h.value,d.value,e.class],style:[u.value,c.value,v.value,g.value,e.style],role:"alert"},x),{default:()=>{var E,P;return[Sl(!1,"v-alert"),A&&e.timer!=="close"&&C(hl,{key:"timer",class:"v-alert__timer",absolute:!0,location:e.timer==="bottom"?"bottom":"top",modelValue:w.value,shape:"flat"},null),e.border&&I("div",{key:"border",class:fe(["v-alert__border",b.value]),style:Se(p.value)},null),_&&I("div",{key:"prepend",class:"v-alert__prepend"},[n.prepend?C(Ue,{key:"prepend-defaults",disabled:!l.value,defaults:{VIcon:{...B}}},n.prepend):C(We,le({key:"prepend-icon"},B),null)]),I("div",{class:"v-alert__content"},[V&&C(r0,{key:"title"},{default:()=>{var L;return[((L=n.title)==null?void 0:L.call(n))??e.title]}}),((E=n.text)==null?void 0:E.call(n))??e.text,(P=n.default)==null?void 0:P.call(n)]),n.append&&I("div",{key:"append",class:"v-alert__append"},[n.append()]),T&&I("div",{key:"close",class:"v-alert__close"},[n.close?C(Ue,{key:"close-defaults",defaults:{VBtn:{icon:e.closeIcon,size:"x-small",variant:"text"}}},{default:()=>{var L;return[(L=n.close)==null?void 0:L.call(n,{props:y.value,timerProgress:w.value})]}}):C(Fe,le({key:"close-btn",icon:e.closeIcon,size:"x-small",variant:"text"},y.value),null),A&&e.timer==="close"&&C(Gn,{key:"timer",class:"v-alert__timer v-alert__timer--close",modelValue:w.value,shape:"flat",width:2},null)])]}})]})}}}),c0=te({gridlines:{type: Line truncated
`,...e.delimiters??[]].map(_o).join("|");return ce.split(new RegExp(`(?:${Te})+`))}async function ze(ce){for(let xe of ce)xe=xe.trim(),xe&&(Je(ca(e,xe)),await Ne())}function tt(ce){c.value=!0,setTimeout(()=>{v.value=!0})}function wt(ce){var xe;if(v.value=!1,!((xe=i.value)!=null&&xe.$el.contains(ce.relatedTarget))){if(D(ce))return;c.value=!1}}let lt=0;function ye(){lt=performance.now()}function $e(ce){var Be;const xe=ce.relatedTarget,Te=(Be=r.value)==null?void 0:Be.contentEl;(Te!=null&&Te.contains(xe)||!xe&&performance.now()-lt<10)&&(c.value=!0)}return ve(c,(ce,xe)=>{if(!(ce||ce===xe)&&(m.value=-1,U.value=!1,P.value)){const Te=e.trimValues?P.value.trim():P.value;if(!Te){P.value="";return}if(e.multiple){Je(ca(e,Te));return}if(!A.value){Te!==P.value&&Je(ca(e,Te));return}x.value.some(({title:Be})=>Be===Te)?B.value="":Je(ca(e,Te))}}),ve(U,ce=>{if(ce||re(null),!e.hideSelected&&ce&&x.value.length&&f.value){const xe=Ce();rt&&!e.noAutoScroll&&window.requestAnimationFrame(()=>{var Te;xe>=0&&((Te=s.value)==null||Te.scrollToIndex(xe,"center"))})}ce&&(E.value=null)}),ve(g,(ce,xe)=>{U.value||c.value&&!xe.length&&ce.length&&(U.value=!0)}),me(()=>{const ce=!!(!e.hideNoData||N.value.length||n["prepend-item"]||n["append-item"]||n["no-data"]),xe=x.value.length>0,Te=Vn.filterProps(e),Be={search:P,filteredItems:R.value};return C(Vn,le({ref:i},Te,{form:e.autocomplete==="suppress"?"":void 0,name:e.autocomplete==="suppress"?e.name:void 0,modelValue:P.value,"onUpdate:modelValue":et=>P.value=et,focused:c.value,"onUpdate:focused":et=>c.value=et,validationValue:x.externalValue,counterValue:L.value,dirty:xe,class:["v-combobox",{"v-combobox--active-menu":U.value,"v-combobox--chips":!!e.chips,"v-combobox--selection-slot":!!A.value,"v-combobox--selecting-index":m.value>-1,[`v-combobox--${e.multiple?"multiple":"single"}`]:!0},e.class],style:e.style,readonly:y.isReadonly.value,placeholder:xe?void 0:e.placeholder,"onClick:clear":K,"onMousedown:control":X,onKeydown:ie,onPaste:_e,onBlur:$e,"aria-expanded":se.value,"aria-controls":Q.value}),{...n,default:({id:et})=>{var ot;return I(we,null,[H.value.map((qe,vt)=>I("input",{key:vt,type:"hidden",name:e.name,value:qe,form:e.form},null)),C(Ul,le({id:Z.value,ref:r,modelValue:U.value,"onUpdate:modelValue":qe=>U.value=qe,activator:"parent",captureFocus:!1,openOnArrow:!1,disabled:Y.value,_disableKeys:!0,eager:e.eager,maxHeight:310,openOnClick:!1,closeOnContentClick:!1,onAfterEnter:Ee,onAfterLeave:Le},e.menuProps,{contentClass:["v-combobox__content",l.value,(ot=e.menuProps)==null?void 0:ot.contentClass]}),{default:()=>[C(Wa,{onFocusin:tt,onKeydown:ee,onMousedown:ye},{default:()=>[n["menu-header"]&&I("header",{ref:u},[n["menu-header"](Be)]),ce&&C(gl,le({key:"combobox-list",ref:o,class:"v-list--navigable",filterable:!0,selected:H.value,selectStrategy:e.multiple?"independent":"single-independent",onMousedown:qe=>qe.preventDefault(),selectable:!!N.value.length,onFocusout:wt,tabindex:"-1","aria-live":"polite","aria-labelledby":`${et.value}-label`,"aria-multiselectable":e.multiple,color:e.itemColor??e.color},ae,e.listProps),{default:()=>{var qe,vt,An;return[(qe=n["prepend-item"])==null?void 0:qe.call(n),!N.value.length&&!e.hideNoData&&(((vt=n["no-data"])==null?void 0:vt.call(n))??C(Mn,{key:"no-data",title:a(e.noDataText)},null)),C(oc,{ref:s,renderless:!0,items:N.value,itemKey:"value"},{default:({item:Ct,index:rn,itemRef:jt})=>{var cn,lu,Pm;const Aa=rv(Ct.props),pl=le(Ct.props,{ref:jt,key:Ct.value,active:O.value&&Ct===$.value?!0:void 0,onClick:()=>Je(Ct,null),"aria-posinset":rn+1,"aria-setsize":N.value.length});return Ct.type==="divider"?((cn=n.divider)==null?void 0:cn.call(n,{props:Ct.raw,index:rn}))??C(Kt,le(Ct.props,{ref:jt,key:`divider-${rn}`}),null):Ct.type==="subheader"?((lu=n.subheader)==null?void 0:lu.call(n,{props:Ct.raw,index:rn}))??C(Dr,le(Ct.props,{ref:jt,key:`subheader-${rn}`}),null):((Pm=n.item)==null?void 0:Pm.call(n,{item:Ct.raw,internalItem:Ct,index:rn,props:pl}))??C(Mn,le(pl,{role:"option"}),{prepend:({isSelected:iu})=>I(we,null,[e.multiple&&!e.hideSelected?C(ta,{key:Ct.value,modelValue:iu,ripple:!1,tabindex:"-1","aria-hidden":!0,onClick:Lp=>Lp.preventDefault()},null):void 0,Aa.prependAvatar&&C(Zt,{image:Aa.prependAvatar},null),Aa.prependIcon&&C(We,{icon:Aa.prependIcon},null)]),title:()=>{var iu;return f.value?Ct.title:C(nu,{text:Ct.title,matches:(iu=W(Ct))==null?void 0:iu.title,markClass:"v-combobox__mask",matchAll:!0,ignoreCase:!0},null)}})}}),(An=n["append-item"])==null?void 0:An.call(n)]}}),n["menu-footer"]&&I("footer",{ref:d},[n["menu-footer"](Be)])]})]}),x.value.map((qe,vt)=>{var Aa,pl;function An(cn){cn.stopPropagation(),cn.preventDefault(),Je(qe,!1)}const Ct=le(On.filterProps(qe.props),{"onClick:close":An,onKeydown(cn){cn.key!=="Enter"&&cn.key!==" "||(cn.preventDefault(),cn.stopPropagation(),An(cn))},onMousedown(cn){cn.preventDefault(),cn.stopPropagation()},modelValue:!0,"onUpdate:modelValue":void 0}),rn=T.value?!!n.chip:!!n.selection,jt=rn?Gs(T.value?n.chip({item:qe.raw,internalItem: Line truncated
`):q}),w=z(()=>{let q=l.date();return q=l.setDate(q,1),q=l.setMonth(q,g.value),q=l.setYear(q,p.value),q}),x=z(()=>l.format(w.value,"monthAndYear")),y=z(()=>l.format(w.value,"monthShort")),_=z(()=>l.format(w.value,"year")),V=z(()=>`date-picker-header${k.value?"-reverse":""}-transition`),T=M(()=>{if(e.disabled)return!0;const q=[];if(u.value!=="month")q.push("prev-month","next-month","prev-year","next-year");else{let H=l.date();if(H=l.startOfMonth(H),H=l.setMonth(H,g.value),H=l.setYear(H,p.value),s.value){const $=l.addDays(l.startOfMonth(H),-1),O=l.addDays(l.startOfYear(H),-1);l.isAfter(s.value,$)&&q.push("prev-month"),l.isAfter(s.value,O)&&q.push("prev-year")}if(c.value){const $=l.addDays(l.endOfMonth(H),1),O=l.addDays(l.endOfYear(H),1);l.isAfter($,c.value)&&q.push("next-month"),l.isAfter(O,c.value)&&q.push("next-year")}}return q}),A=M(()=>e.allowedYears||P),B=M(()=>e.allowedMonths||L);function E(q,H){const $=e.allowedDates;if(!Ge($))return!0;const O=1+tP(l,q,H);for(let D=0;D<O;D++)if($(l.addDays(q,D)))return!0;return!1}function P(q){if(Ge(e.allowedDates)){const H=l.parseISO(`${q}-01-01`);return E(H,l.endOfYear(H))}if(Array.isArray(e.allowedDates)&&e.allowedDates.length){for(const H of e.allowedDates)if(l.getYear(l.date(H))===q)return!0;return!1}return!0}function L(q){if(Ge(e.allowedDates)){const H=String(q+1).padStart(2,"0"),$=l.parseISO(`${p.value}-${H}-01`);return E($,l.endOfMonth($))}if(Array.isArray(e.allowedDates)&&e.allowedDates.length){for(const H of e.allowedDates)if(l.getYear(l.date(H))===p.value&&l.getMonth(l.date(H))===q)return!0;return!1}return!0}function R(){g.value<11?g.value++:(p.value++,g.value=0,re()),J()}function W(){g.value>0?g.value--:(p.value--,g.value=11,re()),J()}function N(){if(p.value++,c.value){const q=String(g.value+1).padStart(2,"0"),H=l.parseISO(`${p.value}-${q}-01`);l.isAfter(H,c.value)&&(g.value=l.getMonth(c.value))}re()}function Y(){if(p.value--,s.value){const q=String(g.value+1).padStart(2,"0"),H=l.endOfMonth(l.parseISO(`${p.value}-${q}-01`));l.isAfter(s.value,H)&&(g.value=l.getMonth(s.value))}re()}const U=ne();function G(q){var H;(H=U.value)==null||H.focusItem(q)}function Z(){u.value="month"}function se(){u.value="month",Ne(()=>{var q;return(q=U.value)==null?void 0:q.focusGrid()})}function Q(){u.value=u.value==="months"?"month":"months"}function ae(){u.value=u.value==="year"?"month":"year"}function J(){u.value==="months"&&Q()}function re(){u.value==="year"&&ae()}return ve(o,(q,H)=>{if(e.noAutoNavigation)return;const $=gt(H),O=gt(q);if(!O.length)return;const D=l.date($[$.length-1]),F=l.date(O[O.length-1]);if(l.isSameDay(D,F))return;const K=l.getMonth(F),X=l.getYear(F);K!==g.value&&(g.value=K,J()),X!==p.value&&(p.value=X,re()),k.value=l.isBefore(D,F)}),me(()=>{const q=pi.filterProps(e),H=He(vf.filterProps(e),["viewMode"]),$=mf.filterProps(e),O=hf.filterProps(e),D=He(gf.filterProps(e),["modelValue"]),F=He(yf.filterProps(e),["modelValue"]),K={color:m.value,header:S.value,transition:V.value};return C(pi,le(q,{color:m.value,class:["v-date-picker",`v-date-picker--${u.value}`,{"v-date-picker--show-week":e.showWeek},r.value,e.class],style:[{"--v-date-picker-landscape-header-width":ge(e.landscapeHeaderWidth)},e.style]}),{title:()=>{var X;return((X=n.title)==null?void 0:X.call(n))??I("div",{class:"v-date-picker__title"},[i(e.title)])},header:()=>n.header?C(Ue,{defaults:{VDatePickerHeader:{...K}}},{default:()=>{var X;return[(X=n.header)==null?void 0:X.call(n,K)]}}):C(mf,le({key:"header"},$,K,{onClick:u.value!=="month"?Z:void 0}),{prepend:n.prepend,append:n.append}),default:()=>I(we,null,[C(vf,le(H,{disabled:T.value,viewMode:u.value,text:x.value,monthText:y.value,yearText:_.value,"onClick:next":R,"onClick:prev":W,"onClick:nextYear":N,"onClick:prevYear":Y,"onClick:month":Q,"onClick:year":ae}),{default:n.controls}),C(Mo,{hideOnLeave:!0},{default:()=>[u.value==="months"?C(gf,le({key:"date-picker-months"},D,{modelValue:g.value,"onUpdate:modelValue":[X=>g.value=X,J],min:s.value,max:c.value,year:p.value,allowedMonths:B.value,onEscape:se}),{month:n.month}):u.value==="year"?C(yf,le({key:"date-picker-years"},F,{modelValue:p.value,"onUpdate:modelValue":[X=>p.value=X,re],min:s.value,max:c.value,allowedYears:A.value,onEscape:se}),{year:n.year}):C(hf,le({ref:U,key:"date-picker-month"},fr(a,[/^on.+:day$/])[0],O,{modelValue:o.value,"onUpdate:modelValue":X=>o.value=X,month:g.value,"onUpdate:month":[X=>g.value=X,J],year:p.value,"onUpdate:year":[X=>p.value=X,re],previewValue:d.value,"onUpdate:previewValue":X=>d.value=X,onBoundaryNavigate:X=>t("boundary-navigate",X),min:s.value,max:c.value}),{day:n.day})]})]),actions:n.actions})}),{focusDate:G}}});function dF(e,t,n,a,l){var c;const i=e.max??10**e.size-1,r=Math.min(i,((c=e.softMax)==null?void 0:c.call(e,l))??i);let o=n,u="",d=!1;for(;o<t.length&&!d&&/\d/.test(t[o]);){if(Number(u+t[o])>r){u=String(r).padStart(u.length+1,"0"),o++,d=!0;break}u+=t[o++],e.min&&!Number(u)&&(u="0"),d=u.length===e.size}const s=o;if(a)for(;o<t.length&&!/\d/.test(t[o]);)o++;else o<t Line truncated
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧲</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transmission Magnet Manager</title>
<!-- Pretendard web font for Korean -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<script type="module" crossorigin src="/assets/index-DGu27qaY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cn6C5Cpg.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧲</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transmission Magnet Manager</title>
<!-- Pretendard web font for Korean -->
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1457
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "transmission-magnet-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:all": "bash ../start_dev.sh",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"lucide-vue-next": "^0.468.0",
"vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.12",
"vuetify": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.3"
}
}
+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;
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
import path from 'path'
export default defineConfig({
plugins: [
vue(),
vuetify({ autoImport: true }),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.warn('\x1b[33m%s\x1b[0m', '⚠️ [FastAPI 백엔드 연결 실패] 포트 8000에 백엔드 서버가 실행 중인지 확인하세요: ./venv/bin/python main.py');
});
},
},
},
},
})
+30
View File
@@ -0,0 +1,30 @@
import sys
import threading
import time
import uvicorn
from backend.app.config import settings
def start_api():
uvicorn.run("backend.app.main:app", host=settings.API_HOST, port=settings.API_PORT, log_level="info")
if __name__ == '__main__':
# 인자로 --window 또는 -w 가 전달되면 pywebview 창으로 실행
if "--window" in sys.argv or "-w" in sys.argv:
try:
import webview
server_thread = threading.Thread(target=start_api, daemon=True)
server_thread.start()
time.sleep(1) # 서버 구동 대기
webview.create_window('Transmission Magnet Manager', f'http://127.0.0.1:{settings.API_PORT}')
webview.start()
except ImportError:
print("pywebview가 설치되어 있지 않습니다. 웹 서버 모드로 실행합니다.")
start_api()
else:
# 기본: FastAPI 웹 애플리케이션 실행
print(f"==================================================")
print(f" 🧲 Transmission Magnet Manager")
print(f" 접속 주소: http://localhost:{settings.API_PORT}")
print(f" 창 모드 실행: python main.py --window")
print(f"==================================================")
start_api()
+6
View File
@@ -0,0 +1,6 @@
import uvicorn
from backend.app.config import settings
if __name__ == "__main__":
print(f"Starting Transmission Magnet Manager on http://{settings.API_HOST}:{settings.API_PORT}")
uvicorn.run("backend.app.main:app", host=settings.API_HOST, port=settings.API_PORT, reload=True)
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# FastAPI 백엔드와 Vue 프론트엔드 개발 서버를 함께 실행하는 스크립트
cleanup() {
echo -e "\n🛑 서버들을 종료합니다..."
kill $(jobs -p) 2>/dev/null
exit
}
trap cleanup SIGINT SIGTERM EXIT
echo "🚀 [1/2] FastAPI 백엔드 실행 중 (포트 8000)..."
./venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --reload &
sleep 1
echo "🎨 [2/2] Vite 프론트엔드 개발 서버 실행 중 (포트 5173)..."
cd frontend && npm run dev
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Transmission Magnet & File Manager Web Application
After=network.target docker.service
Wants=docker.service
[Service]
Type=simple
User=simva
WorkingDirectory=/home/simva/work/simva-home
ExecStart=/home/simva/work/simva-home/venv/bin/python main.py
Restart=always
RestartSec=3
# 환경 변수 설정
Environment=PYTHONUNBUFFERED=1
Environment=TRANSMISSION_HOST=127.0.0.1
Environment=TRANSMISSION_PORT=9091
Environment=DOWNLOADS_DIR=/mnt/ssd/transmission/downloads
[Install]
WantedBy=multi-user.target