Merge pull request #590 from arabcoders/dev
Refactor: Seperate history items from queue
This commit is contained in:
commit
ed77ea508c
91 changed files with 9965 additions and 8359 deletions
|
|
@ -1,8 +1,9 @@
|
|||
# YTPTube
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
|
||||
video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.library.config import Config
|
|||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -83,6 +84,11 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
|
|||
if not (cond := params.get("condition")):
|
||||
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
preset: str = params.get("preset", config.default_preset)
|
||||
key: str = cache.hash(url + str(preset))
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.conditions.router import conditions_test
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
|
@ -34,28 +40,43 @@ async def repo():
|
|||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
def _json_request(path: str, payload: object) -> web.Request:
|
||||
request = make_mocked_request("POST", path)
|
||||
|
||||
async def _json() -> object:
|
||||
return payload
|
||||
|
||||
request.json = _json # type: ignore[attr-defined]
|
||||
return request
|
||||
|
||||
|
||||
class TestAllowInternalUrlsScope:
|
||||
def setup_method(self) -> None:
|
||||
Config._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conditions_test_rejects_internal_url_when_disallowed(self) -> None:
|
||||
config = Config.get_instance()
|
||||
config.allow_internal_urls = False
|
||||
encoder = Encoder()
|
||||
cache = SimpleNamespace(hash=lambda value: value, has=lambda _key: False, set=lambda **_kwargs: None)
|
||||
|
||||
request = _json_request(
|
||||
"/api/conditions/test/",
|
||||
{"url": "http://127.0.0.1/test", "condition": "title", "preset": config.default_preset},
|
||||
)
|
||||
|
||||
response = await conditions_test(request, encoder, cache, config)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert b"internal urls" in body.lower()
|
||||
|
||||
|
||||
class TestConditionsRepository:
|
||||
"""Test suite for ConditionsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_singleton(self, repo):
|
||||
"""Verify repository follows singleton pattern."""
|
||||
instance1 = ConditionsRepository.get_instance()
|
||||
instance2 = ConditionsRepository.get_instance()
|
||||
assert instance1 is instance2, "Should return same singleton instance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no conditions exist."""
|
||||
conditions = await repo.list()
|
||||
assert conditions == [], "Should return empty list when no conditions"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_empty(self, repo):
|
||||
"""Count returns 0 when no conditions exist."""
|
||||
count = await repo.count()
|
||||
assert count == 0, "Should return 0 when no conditions exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_condition(self, repo):
|
||||
"""Create condition with valid data."""
|
||||
|
|
@ -5,7 +5,6 @@ from __future__ import annotations
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
|
@ -35,25 +34,6 @@ async def repo(tmp_path):
|
|||
class TestDLFieldsRepository:
|
||||
"""Test suite for DLFieldsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_singleton(self, repo):
|
||||
"""Verify repository follows singleton pattern."""
|
||||
instance1 = DLFieldsRepository.get_instance()
|
||||
instance2 = DLFieldsRepository.get_instance()
|
||||
assert instance1 is instance2, "Should return same singleton instance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no fields exist."""
|
||||
fields = await repo.list()
|
||||
assert fields == [], "Should return empty list when no fields"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_empty(self, repo):
|
||||
"""Count returns 0 when no fields exist."""
|
||||
count = await repo.count()
|
||||
assert count == 0, "Should return 0 when no fields exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_field(self, repo):
|
||||
"""Create field with valid data."""
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from __future__ import annotations
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
|
@ -35,25 +34,6 @@ async def repo(tmp_path):
|
|||
class TestTasksRepository:
|
||||
"""Test suite for TasksRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_singleton(self, repo):
|
||||
"""Verify repository follows singleton pattern."""
|
||||
instance1 = TasksRepository.get_instance()
|
||||
instance2 = TasksRepository.get_instance()
|
||||
assert instance1 is instance2, "Should return same singleton instance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no tasks exist."""
|
||||
tasks = await repo.list()
|
||||
assert tasks == [], "Should return empty list when no tasks"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_empty(self, repo):
|
||||
"""Count returns 0 when no tasks exist."""
|
||||
count = await repo.count()
|
||||
assert count == 0, "Should return 0 when no tasks exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task(self, repo):
|
||||
"""Create task with valid data."""
|
||||
|
|
|
|||
|
|
@ -107,10 +107,10 @@ class HttpAPI:
|
|||
registered_options: list = []
|
||||
|
||||
base_path: str = self.config.base_path.rstrip("/")
|
||||
from app.routes.api._static import preload_static
|
||||
from app.routes.api._static import setup_static_routes
|
||||
|
||||
load_modules(self.rootPath, self.rootPath / "routes" / "api")
|
||||
preload_static(self.rootPath, self.config)
|
||||
setup_static_routes(self.rootPath, self.config)
|
||||
|
||||
async def options_handler(_: Request) -> Response:
|
||||
return web.Response(status=204)
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
"""
|
||||
status: dict[str, str] = {}
|
||||
deleted_ids: list[str] = []
|
||||
|
||||
for id in ids:
|
||||
try:
|
||||
|
|
@ -349,6 +350,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}")
|
||||
|
||||
await self.done.delete(id)
|
||||
deleted_ids.append(id)
|
||||
|
||||
_status: str = "Removed" if removed_files > 0 else "Cleared"
|
||||
self._notify.emit(
|
||||
|
|
@ -365,6 +367,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.info(msg=msg)
|
||||
status[id] = "ok"
|
||||
|
||||
if deleted_ids:
|
||||
await self.done._connection.flush()
|
||||
|
||||
return status
|
||||
|
||||
async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import magic
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web import Request, StreamResponse
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.router import add_route
|
||||
from app.library.Utils import get_file
|
||||
|
||||
MIME = magic.Magic(mime=True)
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_FILES: dict[str, dict] = {}
|
||||
|
||||
EXT_TO_MIME: dict = {
|
||||
EXT_TO_MIME: dict[str, str] = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
|
|
@ -23,120 +22,167 @@ EXT_TO_MIME: dict = {
|
|||
".m4a": "audio/mp4",
|
||||
}
|
||||
|
||||
FRONTEND_ROUTES: list[str] = [
|
||||
"/console/",
|
||||
"/presets/",
|
||||
"/tasks/",
|
||||
"/task_definitions/",
|
||||
"/notifications/",
|
||||
"/changelog/",
|
||||
"/logs/",
|
||||
"/conditions/",
|
||||
"/browser/",
|
||||
"/simple/",
|
||||
"/browser/{path:.*}",
|
||||
]
|
||||
|
||||
class StaticState:
|
||||
def __init__(self) -> None:
|
||||
self.root: Path | None = None
|
||||
self.index_file: Path | None = None
|
||||
|
||||
|
||||
async def serve_static_file(request: Request, config: Config) -> Response:
|
||||
STATIC_STATE = StaticState()
|
||||
|
||||
|
||||
def get_root(root_path: Path, config: Config) -> Path | None:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
Get the static root directory.
|
||||
|
||||
Args:
|
||||
root_path (Path): The application root path.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Path | None: The static directory, or None when UI is ignored.
|
||||
|
||||
"""
|
||||
search_paths: list[Path] = []
|
||||
if config.static_ui_path:
|
||||
search_paths.append(Path(config.static_ui_path).absolute())
|
||||
|
||||
search_paths.extend(
|
||||
[
|
||||
(root_path / "ui" / "exported").absolute(),
|
||||
(root_path.parent / "ui" / "exported").absolute(),
|
||||
]
|
||||
)
|
||||
|
||||
for path in search_paths:
|
||||
if path.is_dir():
|
||||
return path
|
||||
|
||||
message: str = f"Could not find the frontend assets in '{[str(path) for path in search_paths]=}'."
|
||||
if config.ignore_ui:
|
||||
LOG.warning(message)
|
||||
return None
|
||||
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def normalize_path(path: str, base_path: str) -> str:
|
||||
"""
|
||||
Normalize the request path by stripping the base path.
|
||||
|
||||
Args:
|
||||
path (str): The raw request path.
|
||||
base_path (str): The configured base path.
|
||||
|
||||
Returns:
|
||||
str: The path relative to the static root.
|
||||
|
||||
"""
|
||||
if "/" == base_path:
|
||||
return path or "/"
|
||||
|
||||
base_prefix: str = base_path.rstrip("/")
|
||||
if path == base_prefix:
|
||||
return "/"
|
||||
|
||||
if path.startswith(f"{base_prefix}/"):
|
||||
stripped: str = path[len(base_prefix) :]
|
||||
return stripped or "/"
|
||||
|
||||
return path or "/"
|
||||
|
||||
|
||||
def get_static_file(path: str) -> Path | None:
|
||||
"""
|
||||
Get the static file corresponding to a request path.
|
||||
|
||||
Args:
|
||||
path (str): The normalized request path.
|
||||
|
||||
Returns:
|
||||
Path | None: The resolved file path.
|
||||
|
||||
"""
|
||||
if STATIC_STATE.root is None:
|
||||
return None
|
||||
|
||||
relative_path: str = path.lstrip("/")
|
||||
if not relative_path:
|
||||
return STATIC_STATE.index_file if STATIC_STATE.index_file and STATIC_STATE.index_file.is_file() else None
|
||||
|
||||
file, status = get_file(STATIC_STATE.root, relative_path)
|
||||
if web.HTTPOk.status_code == status and file.is_file():
|
||||
return file
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def serve_static_file(request: Request, config: Config) -> StreamResponse:
|
||||
"""
|
||||
Serve frontend static files with SPA fallback handling.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
StreamResponse: The response object.
|
||||
|
||||
"""
|
||||
path: str = request.path
|
||||
path: str = normalize_path(request.path, config.base_path)
|
||||
file_path: Path | None = get_static_file(path)
|
||||
|
||||
if "/" != config.base_path and path.startswith(config.base_path):
|
||||
path = path.replace(config.base_path[:-1], "", 1)
|
||||
|
||||
if path not in STATIC_FILES:
|
||||
for k in STATIC_FILES:
|
||||
if path.startswith(k):
|
||||
path = k
|
||||
break
|
||||
if file_path is None:
|
||||
if (
|
||||
STATIC_STATE.index_file is not None
|
||||
and not path.startswith("/api/")
|
||||
and not PurePosixPath(path.lstrip("/")).suffix
|
||||
):
|
||||
file_path = STATIC_STATE.index_file
|
||||
else:
|
||||
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
item: dict = STATIC_FILES[path]
|
||||
|
||||
return web.FileResponse(
|
||||
path=item["file"],
|
||||
path=file_path,
|
||||
headers={
|
||||
"Pragma": "public",
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
"Content-Type": item.get("content_type"),
|
||||
"Content-Type": EXT_TO_MIME.get(file_path.suffix, MIME.from_file(str(file_path))),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
def preload_static(root_path: Path, config: Config) -> None:
|
||||
def setup_static_routes(root_path: Path, config: Config) -> None:
|
||||
"""
|
||||
Preload static files from the ui/exported folder.
|
||||
Set up routes for serving frontend static files.
|
||||
|
||||
Args:
|
||||
root_path (Path): The root path of the application.
|
||||
root_path (Path): The application root path.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
"""
|
||||
global STATIC_FILES # noqa: PLW0602
|
||||
static_dir: Path | None = None
|
||||
webui_files: list[Path] = [
|
||||
(root_path / "ui" / "exported").absolute(),
|
||||
(root_path.parent / "ui" / "exported").absolute(),
|
||||
]
|
||||
STATIC_STATE.root = get_root(root_path, config)
|
||||
STATIC_STATE.index_file = None
|
||||
|
||||
if config.static_ui_path:
|
||||
webui_files = [Path(config.static_ui_path).absolute()]
|
||||
if STATIC_STATE.root is None:
|
||||
return
|
||||
|
||||
for p in webui_files:
|
||||
if p.exists():
|
||||
static_dir = p
|
||||
break
|
||||
index_file: Path = (STATIC_STATE.root / "index.html").resolve()
|
||||
if not index_file.is_file():
|
||||
message: str = f"Failed to find frontend entry file at '{index_file}'."
|
||||
if config.ignore_ui:
|
||||
LOG.warning(message)
|
||||
STATIC_STATE.root = None
|
||||
return
|
||||
|
||||
if static_dir is None:
|
||||
webui_files = [str(p) for p in webui_files]
|
||||
msg: str = f"Could not find the frontend UI static assets in '{webui_files=}'."
|
||||
raise ValueError(msg)
|
||||
raise ValueError(message)
|
||||
|
||||
preloaded = 0
|
||||
STATIC_STATE.index_file = index_file
|
||||
|
||||
for file in static_dir.rglob("*.*"):
|
||||
if ".map" == file.suffix:
|
||||
continue
|
||||
|
||||
uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}"
|
||||
contentType = EXT_TO_MIME.get(file.suffix)
|
||||
if not contentType:
|
||||
contentType = MIME.from_file(str(file))
|
||||
|
||||
STATIC_FILES[uri_path] = {
|
||||
"uri": uri_path,
|
||||
"content_type": contentType,
|
||||
"file": file,
|
||||
}
|
||||
|
||||
add_route(method="GET", path=uri_path, handler=serve_static_file)
|
||||
preloaded += 1
|
||||
|
||||
if "/index.html" in STATIC_FILES:
|
||||
for path in FRONTEND_ROUTES:
|
||||
STATIC_FILES[path] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[path.lstrip("/")] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=path, handler=serve_static_file)
|
||||
LOG.debug(f"Preloading frontend static route '{path}'.")
|
||||
preloaded += 1
|
||||
|
||||
# Add main app route
|
||||
STATIC_FILES["/"] = STATIC_FILES["/index.html"]
|
||||
STATIC_FILES[config.base_path] = STATIC_FILES["/index.html"]
|
||||
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
|
||||
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
|
||||
add_route(method="GET", path="/{path:.*}", handler=serve_static_file, name="static_fallback")
|
||||
|
||||
if "/" != config.base_path:
|
||||
|
||||
|
|
@ -145,12 +191,4 @@ def preload_static(root_path: Path, config: Config) -> None:
|
|||
|
||||
add_route(method="GET", path="/", handler=redirect_index, name="index_redirect")
|
||||
|
||||
if preloaded < 1:
|
||||
message: str = f"Failed to find any static files in '{static_dir}'."
|
||||
if config.ignore_ui:
|
||||
LOG.warning(message)
|
||||
return
|
||||
|
||||
raise ValueError(message)
|
||||
|
||||
LOG.info(f"Loaded '{preloaded}' static files.")
|
||||
LOG.info(f"Serving frontend static assets from '{STATIC_STATE.root}'.")
|
||||
|
|
|
|||
|
|
@ -215,7 +215,11 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
|
|||
page += 1
|
||||
|
||||
if not items_to_delete:
|
||||
return web.json_response(data={"error": "No items matched the filter."}, status=web.HTTPBadRequest.status_code)
|
||||
return web.json_response(
|
||||
data={"items": {}, "deleted": 0},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data={
|
||||
|
|
|
|||
|
|
@ -1,18 +1,3 @@
|
|||
"""
|
||||
Tests for ag_utils.py functions.
|
||||
|
||||
This test suite provides comprehensive coverage for all functions in ag_utils.py:
|
||||
- get_value: Tests callable detection and value retrieval
|
||||
- ag_set: Tests nested dictionary path setting with various scenarios
|
||||
- ag: Tests nested dictionary/list/object access with dot notation
|
||||
- ag_sets: Tests bulk setting of multiple paths
|
||||
- ag_exists: Tests existence checking for nested paths
|
||||
- ag_delete: Tests deletion of nested paths and keys
|
||||
|
||||
Total test functions: 53
|
||||
All edge cases, error conditions, and normal operations are covered.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
|
|||
|
|
@ -1,20 +1,3 @@
|
|||
"""
|
||||
Tests for cache.py - Thread-safe caching utilities.
|
||||
|
||||
This test suite provides comprehensive coverage for the Cache class:
|
||||
- Tests basic cache operations (set, get, delete, clear)
|
||||
- Tests TTL (time-to-live) functionality
|
||||
- Tests thread safety
|
||||
- Tests cache expiration
|
||||
- Tests default value handling
|
||||
- Tests key existence checking
|
||||
- Tests hash generation
|
||||
- Tests async methods
|
||||
|
||||
Total test functions: 15
|
||||
All cache operations and edge cases are covered.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""Comprehensive tests for cf_solver_handler yt-dlp request handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.cookiejar
|
||||
|
|
@ -224,34 +222,3 @@ class TestCFSolverRH:
|
|||
new_request = self.module.CFSolverRH._mark_retry(request)
|
||||
|
||||
assert new_request.extensions.get("cf_retry") is True
|
||||
|
||||
|
||||
class TestCfSolverPreference:
|
||||
"""Test cf_solver_preference function."""
|
||||
|
||||
def test_preference_with_flaresolverr(self, cf_handler_module, monkeypatch):
|
||||
"""Test preference when FlareSolverr is configured."""
|
||||
mock_config = Mock()
|
||||
mock_config.flaresolverr_url = "http://localhost:8191/v1"
|
||||
|
||||
import app.library.config
|
||||
|
||||
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)
|
||||
|
||||
def test_preference_without_flaresolverr(self, cf_handler_module, monkeypatch):
|
||||
"""Test preference when FlareSolverr is not configured."""
|
||||
mock_config = Mock()
|
||||
mock_config.flaresolverr_url = None
|
||||
|
||||
import app.library.config
|
||||
|
||||
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)
|
||||
|
||||
def test_preference_with_empty_flaresolverr(self, cf_handler_module, monkeypatch):
|
||||
"""Test preference when FlareSolverr URL is empty."""
|
||||
mock_config = Mock()
|
||||
mock_config.flaresolverr_url = ""
|
||||
|
||||
import app.library.config
|
||||
|
||||
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)
|
||||
|
|
|
|||
|
|
@ -1336,6 +1336,32 @@ class TestQueueManager:
|
|||
item.close.assert_awaited_once()
|
||||
assert status[item.info._id] == "ok", "Regular running cancel should still report success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_flushes_history_deletes_before_returning(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
||||
item = Mock()
|
||||
item.info = make_item(id="done-id", title="Finished clip")
|
||||
item.info._id = "done-id"
|
||||
item.info.status = "finished"
|
||||
item.info.filename = "clip.mp4"
|
||||
item.info.folder = ""
|
||||
|
||||
done_store = Mock()
|
||||
done_store.get = AsyncMock(return_value=item)
|
||||
done_store.delete = AsyncMock()
|
||||
done_store._connection = Mock()
|
||||
done_store._connection.flush = AsyncMock()
|
||||
queue_manager.done = done_store
|
||||
|
||||
status = await DownloadQueue.clear(queue_manager, [item.info._id], remove_file=False)
|
||||
|
||||
done_store.delete.assert_awaited_once_with(item.info._id)
|
||||
done_store._connection.flush.assert_awaited_once()
|
||||
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
|
||||
|
||||
|
||||
class TestPoolManager:
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -8,11 +8,7 @@ from unittest.mock import Mock, patch
|
|||
import pytest
|
||||
|
||||
from app.library.downloads.utils import (
|
||||
BAD_LIVE_STREAM_OPTIONS,
|
||||
DEBUG_MESSAGE_PREFIXES,
|
||||
GENERIC_EXTRACTORS,
|
||||
LIMITS,
|
||||
YTDLP_PROGRESS_FIELDS,
|
||||
create_debug_safe_dict,
|
||||
get_extractor_limit,
|
||||
handle_task_exception,
|
||||
|
|
@ -24,26 +20,6 @@ from app.library.downloads.utils import (
|
|||
)
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_generic_extractors_tuple(self) -> None:
|
||||
assert isinstance(GENERIC_EXTRACTORS, tuple), "Should be a tuple"
|
||||
assert "HTML5MediaEmbed" in GENERIC_EXTRACTORS, "Should contain HTML5MediaEmbed"
|
||||
assert "generic" in GENERIC_EXTRACTORS, "Should contain generic"
|
||||
|
||||
def test_ytdlp_progress_fields_tuple(self) -> None:
|
||||
assert isinstance(YTDLP_PROGRESS_FIELDS, tuple), "Should be a tuple"
|
||||
assert "status" in YTDLP_PROGRESS_FIELDS, "Should contain status field"
|
||||
assert "downloaded_bytes" in YTDLP_PROGRESS_FIELDS, "Should contain downloaded_bytes field"
|
||||
|
||||
def test_bad_live_stream_options_list(self) -> None:
|
||||
assert isinstance(BAD_LIVE_STREAM_OPTIONS, list), "Should be a list"
|
||||
assert "concurrent_fragment_downloads" in BAD_LIVE_STREAM_OPTIONS, "Should contain concurrent option"
|
||||
|
||||
def test_debug_message_prefixes_list(self) -> None:
|
||||
assert isinstance(DEBUG_MESSAGE_PREFIXES, list), "Should be a list"
|
||||
assert "[debug] " in DEBUG_MESSAGE_PREFIXES, "Should contain debug prefix"
|
||||
|
||||
|
||||
class TestPathUtilities:
|
||||
def test_safe_relative_path_success(self) -> None:
|
||||
base = Path("/downloads")
|
||||
|
|
|
|||
|
|
@ -1,21 +1,3 @@
|
|||
"""
|
||||
Tests for encoder.py - JSON encoding utilities.
|
||||
|
||||
This test suite provides comprehensive coverage for the Encoder class:
|
||||
- Tests serialization of various Python types
|
||||
- Tests special handling of Path objects
|
||||
- Tests DateRange serialization
|
||||
- Tests date serialization
|
||||
- Tests ImpersonateTarget serialization
|
||||
- Tests ItemDTO serialization
|
||||
- Tests object serialization with serialize() method
|
||||
- Tests object serialization with __dict__ fallback
|
||||
- Tests fallback to default JSONEncoder behavior
|
||||
|
||||
Total test functions: 10
|
||||
All supported types and edge cases are covered.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""Comprehensive tests for httpx_client Cloudflare challenge solving."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.cookiejar
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||
|
||||
TEST_DIR = Path("var/tmp/tests_nfo")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
"""Tests for the generic operations module."""
|
||||
|
||||
from app.library.operations import (
|
||||
Operation,
|
||||
filter_items,
|
||||
|
|
|
|||
|
|
@ -11,15 +11,7 @@ def reset_routes():
|
|||
ROUTES.clear()
|
||||
|
||||
|
||||
class TestRouteType:
|
||||
def test_all_returns_values(self) -> None:
|
||||
assert set(RouteType.all()) == {"http", "socket"}
|
||||
|
||||
|
||||
class TestMakeRouteName:
|
||||
def test_basic_http_path(self) -> None:
|
||||
assert make_route_name("GET", "/api/test") == "get:api.test"
|
||||
|
||||
def test_trailing_slash_and_root(self) -> None:
|
||||
# Current behavior converts empty part to 'part'
|
||||
assert make_route_name("post", "/") == "post:part"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -13,16 +12,6 @@ class TestServices:
|
|||
"""Clear services before each test."""
|
||||
Services._reset_singleton()
|
||||
|
||||
def test_singleton_behavior(self):
|
||||
"""Test that Services follows singleton pattern."""
|
||||
service1 = Services()
|
||||
service2 = Services()
|
||||
service3 = Services.get_instance()
|
||||
|
||||
assert service1 is service2, "Multiple Services() calls should return same instance"
|
||||
assert service1 is service3, "get_instance() should return same instance"
|
||||
assert id(service1) == id(service2) == id(service3), "All references should point to same object"
|
||||
|
||||
def test_add_and_get_service(self):
|
||||
"""Test adding and retrieving services."""
|
||||
services = Services()
|
||||
|
|
@ -264,29 +253,6 @@ class TestServices:
|
|||
expected = "db:database, cache:redis, args:(), kwargs:{}"
|
||||
assert result == expected
|
||||
|
||||
def test_service_types_preserved(self):
|
||||
"""Test that different service types are preserved correctly."""
|
||||
services = Services()
|
||||
|
||||
# Test various types
|
||||
string_service = "string_value"
|
||||
int_service = 42
|
||||
list_service = [1, 2, 3]
|
||||
dict_service = {"key": "value"}
|
||||
custom_object = MagicMock()
|
||||
|
||||
services.add("string", string_service)
|
||||
services.add("int", int_service)
|
||||
services.add("list", list_service)
|
||||
services.add("dict", dict_service)
|
||||
services.add("object", custom_object)
|
||||
|
||||
assert services.get("string") == string_service
|
||||
assert services.get("int") == int_service
|
||||
assert services.get("list") == list_service
|
||||
assert services.get("dict") == dict_service
|
||||
assert services.get("object") is custom_object
|
||||
|
||||
def test_add_none_service(self):
|
||||
"""Test adding None as a service value."""
|
||||
services = Services()
|
||||
|
|
@ -295,22 +261,6 @@ class TestServices:
|
|||
assert services.has("none_service") is True
|
||||
assert services.get("none_service") is None
|
||||
|
||||
def test_service_name_edge_cases(self):
|
||||
"""Test edge cases for service names."""
|
||||
services = Services()
|
||||
|
||||
# Empty string name
|
||||
services.add("", "empty_name_value")
|
||||
assert services.get("") == "empty_name_value"
|
||||
|
||||
# Numeric string name
|
||||
services.add("123", "numeric_name")
|
||||
assert services.get("123") == "numeric_name"
|
||||
|
||||
# Special characters in name
|
||||
services.add("special-chars_123!@#", "special_value")
|
||||
assert services.get("special-chars_123!@#") == "special_value"
|
||||
|
||||
def test_overwrite_existing_service(self):
|
||||
"""Test overwriting an existing service."""
|
||||
services = Services()
|
||||
|
|
@ -319,22 +269,6 @@ class TestServices:
|
|||
|
||||
assert services.get("service") == "new_value"
|
||||
|
||||
def test_singleton_persistence_across_operations(self):
|
||||
"""Test that singleton behavior persists across various operations."""
|
||||
# Get instance and add a service
|
||||
services1 = Services()
|
||||
services1.add("persistent", "value")
|
||||
|
||||
# Get another instance and verify service exists
|
||||
services2 = Services.get_instance()
|
||||
assert services2.get("persistent") == "value"
|
||||
|
||||
# Clear from one instance
|
||||
services1.clear()
|
||||
|
||||
# Verify cleared in other instance
|
||||
assert services2.get("persistent") is None
|
||||
|
||||
def test_handler_exception_propagation(self):
|
||||
"""Test that exceptions in handlers are properly propagated."""
|
||||
services = Services()
|
||||
|
|
@ -397,14 +331,6 @@ class TestServices:
|
|||
result = services.handle_sync(lambda_handler)
|
||||
assert result == "Lambda: lambda_value"
|
||||
|
||||
def test_logging_configuration(self):
|
||||
"""Test that logging is properly configured."""
|
||||
# This test verifies the module-level logger setup
|
||||
from app.library.Services import LOG
|
||||
|
||||
assert isinstance(LOG, logging.Logger)
|
||||
assert LOG.name == "app.library.Services"
|
||||
|
||||
def test_service_container_isolation(self):
|
||||
"""Test that services don't interfere with each other."""
|
||||
services = Services()
|
||||
|
|
@ -421,27 +347,6 @@ class TestServices:
|
|||
assert services.get("data") is None
|
||||
assert services.get("data_backup")["type"] == "backup"
|
||||
|
||||
def test_large_number_of_services(self):
|
||||
"""Test handling a large number of services."""
|
||||
services = Services()
|
||||
|
||||
# Add many services
|
||||
num_services = 1000
|
||||
for i in range(num_services):
|
||||
services.add(f"service_{i}", f"value_{i}")
|
||||
|
||||
# Verify all exist
|
||||
assert len(services.get_all()) == num_services
|
||||
|
||||
# Verify specific services
|
||||
assert services.get("service_0") == "value_0"
|
||||
assert services.get("service_500") == "value_500"
|
||||
assert services.get("service_999") == "value_999"
|
||||
|
||||
# Clear should work efficiently
|
||||
services.clear()
|
||||
assert len(services.get_all()) == 0
|
||||
|
||||
def test_add_all_overwrites_existing(self):
|
||||
"""Test that add_all overwrites existing services."""
|
||||
services = Services()
|
||||
|
|
@ -465,20 +370,6 @@ class TestServices:
|
|||
assert services.get("existing") == "value"
|
||||
assert len(services.get_all()) == 1
|
||||
|
||||
def test_type_var_generic_behavior(self):
|
||||
"""Test that TypeVar T is handled correctly."""
|
||||
services = Services()
|
||||
|
||||
# Add different types and ensure they're returned correctly
|
||||
services.add("string", "text")
|
||||
services.add("number", 42)
|
||||
services.add("boolean", True) # noqa: FBT003
|
||||
|
||||
# Type should be preserved (runtime check)
|
||||
assert isinstance(services.get("string"), str)
|
||||
assert isinstance(services.get("number"), int)
|
||||
assert isinstance(services.get("boolean"), bool)
|
||||
|
||||
def test_concurrent_access_safety(self):
|
||||
"""Test basic thread safety aspects of singleton."""
|
||||
import threading
|
||||
|
|
@ -504,47 +395,3 @@ class TestServices:
|
|||
|
||||
# All should be the same instance
|
||||
assert len(set(results)) == 1, "All threads should get the same singleton instance"
|
||||
|
||||
def test_method_chaining_possibility(self):
|
||||
"""Test that methods can be potentially chained."""
|
||||
services = Services()
|
||||
|
||||
# While current implementation doesn't return self, test the pattern works
|
||||
services.add("test1", "value1")
|
||||
services.add("test2", "value2")
|
||||
services.remove("test1")
|
||||
|
||||
assert services.get("test1") is None
|
||||
assert services.get("test2") == "value2"
|
||||
|
||||
def test_edge_case_empty_handler_name(self):
|
||||
"""Test handlers with minimal or no names."""
|
||||
services = Services()
|
||||
services.add("param", "value")
|
||||
|
||||
# Anonymous lambda
|
||||
result = services.handle_sync(lambda param: f"anon: {param}")
|
||||
assert result == "anon: value"
|
||||
|
||||
# Function with minimal signature info
|
||||
def minimal(param):
|
||||
return param
|
||||
|
||||
result = services.handle_sync(minimal)
|
||||
assert result == "value"
|
||||
|
||||
def test_services_state_isolation(self):
|
||||
"""Test that different Services instances share state properly."""
|
||||
# This test verifies the singleton behavior more thoroughly
|
||||
s1 = Services()
|
||||
s1.add("shared", "data")
|
||||
|
||||
s2 = Services.get_instance()
|
||||
assert s2.get("shared") == "data"
|
||||
|
||||
s2.add("another", "value")
|
||||
assert s1.get("another") == "value"
|
||||
|
||||
# Clear from s1 affects s2
|
||||
s1.clear()
|
||||
assert len(s2.get_all()) == 0
|
||||
|
|
|
|||
175
app/tests/test_static_routes.py
Normal file
175
app/tests/test_static_routes.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.router import ROUTES
|
||||
from app.routes.api import _static
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_static_routes() -> Generator[None, None, None]:
|
||||
Config._reset_singleton()
|
||||
ROUTES.clear()
|
||||
_static.STATIC_STATE.root = None
|
||||
_static.STATIC_STATE.index_file = None
|
||||
yield
|
||||
_static.STATIC_STATE.root = None
|
||||
_static.STATIC_STATE.index_file = None
|
||||
ROUTES.clear()
|
||||
Config._reset_singleton()
|
||||
|
||||
|
||||
def _make_request(path: str, *, accept: str = "text/html") -> web.Request:
|
||||
return make_mocked_request("GET", path, headers={"Accept": accept})
|
||||
|
||||
|
||||
def _configure_static_root(static_root: Path) -> None:
|
||||
_static.STATIC_STATE.root = static_root.resolve()
|
||||
_static.STATIC_STATE.index_file = (_static.STATIC_STATE.root / "index.html").resolve()
|
||||
|
||||
|
||||
class TestServeStaticFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_document_route_falls_back_to_spa_shell(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
index_file = tmp_path / "index.html"
|
||||
index_file.write_text("<html>root shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
response = await _static.serve_static_file(_make_request("/docs/readme"), config)
|
||||
|
||||
assert isinstance(response, web.FileResponse)
|
||||
assert response._path == index_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_nested_index_is_not_preferred_over_root_shell(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
root_index = tmp_path / "index.html"
|
||||
nested_index = tmp_path / "docs" / "readme" / "index.html"
|
||||
nested_index.parent.mkdir(parents=True)
|
||||
root_index.write_text("<html>root shell</html>", encoding="utf-8")
|
||||
nested_index.write_text("<html>nested shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
response = await _static.serve_static_file(_make_request("/docs/readme"), config)
|
||||
|
||||
assert isinstance(response, web.FileResponse)
|
||||
assert response._path == root_index
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_asset_does_not_fall_back(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
assets_dir = tmp_path / "assets"
|
||||
assets_dir.mkdir()
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
request = make_mocked_request(
|
||||
"GET",
|
||||
"/assets/missing.js",
|
||||
headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"},
|
||||
)
|
||||
response = await _static.serve_static_file(request, config)
|
||||
|
||||
assert isinstance(response, web.Response)
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"missing.js" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_asset_with_unknown_suffix_does_not_fall_back(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
assets_dir = tmp_path / "assets"
|
||||
assets_dir.mkdir()
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
request = make_mocked_request(
|
||||
"GET",
|
||||
"/assets/missing.abcd123",
|
||||
headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"},
|
||||
)
|
||||
response = await _static.serve_static_file(request, config)
|
||||
|
||||
assert isinstance(response, web.Response)
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"missing.abcd123" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_symlink_outside_static_root_does_not_resolve(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
outside_dir = tmp_path.parent / "outside-static-root"
|
||||
outside_dir.mkdir(exist_ok=True)
|
||||
outside_file = outside_dir / "secret.js"
|
||||
outside_file.write_text("console.log('outside')", encoding="utf-8")
|
||||
try:
|
||||
(tmp_path / "leak.js").symlink_to(outside_file)
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
response = await _static.serve_static_file(_make_request("/leak.js"), config)
|
||||
|
||||
assert isinstance(response, web.Response)
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"/leak.js" in body
|
||||
finally:
|
||||
if (tmp_path / "leak.js").exists() or (tmp_path / "leak.js").is_symlink():
|
||||
(tmp_path / "leak.js").unlink()
|
||||
if outside_file.exists():
|
||||
outside_file.unlink()
|
||||
if outside_dir.exists():
|
||||
outside_dir.rmdir()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_api_path_does_not_fall_back(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
response = await _static.serve_static_file(_make_request("/api/missing"), config)
|
||||
|
||||
assert isinstance(response, web.Response)
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"/api/missing" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dotted_browser_path_returns_not_found(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
||||
response = await _static.serve_static_file(_make_request("/browser/foo/bar.txt"), config)
|
||||
|
||||
assert isinstance(response, web.Response)
|
||||
body = response.body
|
||||
assert isinstance(body, bytes)
|
||||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"/browser/foo/bar.txt" in body
|
||||
|
||||
def test_registers_only_root_and_catch_all_routes(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
static_root = tmp_path / "ui-exported"
|
||||
static_root.mkdir()
|
||||
(static_root / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
(static_root / "assets").mkdir()
|
||||
(static_root / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||
|
||||
config.static_ui_path = str(static_root)
|
||||
|
||||
_static.setup_static_routes(tmp_path, config)
|
||||
|
||||
http_routes = ROUTES.get("http", {})
|
||||
assert "index" in http_routes
|
||||
assert "static_fallback" in http_routes
|
||||
assert "/assets/app.js" not in {route.path for route in http_routes.values()}
|
||||
|
|
@ -47,23 +47,6 @@ class TestCheckUpdatesEndpoint:
|
|||
assert "up_to_date" in body, "Response should include up_to_date status"
|
||||
assert mock_check.called, "Should have called check_for_updates"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_returns_current_version(self):
|
||||
"""Test check updates includes current version in response."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.2.3"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = (("up_to_date", None), ("up_to_date", None))
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "1.2.3" in body, "Response should include current version"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_update_available(self):
|
||||
"""Test check updates returns update_available status with new version."""
|
||||
|
|
@ -81,37 +64,3 @@ class TestCheckUpdatesEndpoint:
|
|||
body = response.body.decode("utf-8")
|
||||
assert "v1.0.5" in body, "Response should include new version"
|
||||
assert "update_available" in body, "Response should include update_available status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_null_when_no_new_version(self):
|
||||
"""Test check updates returns null for new_version when none available."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = (("up_to_date", None), ("up_to_date", None))
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "null" in body.lower(), "Response should include null for new_version when not available"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_error_status(self):
|
||||
"""Test check updates handles error status correctly."""
|
||||
config = Config.get_instance()
|
||||
config.check_for_updates = True
|
||||
config.app_version = "v1.0.0"
|
||||
encoder = Encoder()
|
||||
update_checker = UpdateChecker.get_instance()
|
||||
|
||||
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
|
||||
mock_check.return_value = (("error", None), ("error", None))
|
||||
response = await check_updates(config, encoder, update_checker)
|
||||
|
||||
assert 200 == response.status, "Should return 200"
|
||||
body = response.body.decode("utf-8")
|
||||
assert "error" in body, "Response should include error status"
|
||||
|
|
|
|||
|
|
@ -21,26 +21,6 @@ class TestUpdateChecker:
|
|||
EventBus._reset_singleton()
|
||||
Cache._reset_singleton()
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
"""Test that UpdateChecker follows singleton pattern."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
instance1 = UpdateChecker.get_instance()
|
||||
instance2 = UpdateChecker.get_instance()
|
||||
|
||||
assert instance1 is instance2, "Should return same instance"
|
||||
|
||||
def test_initialization_with_defaults(self):
|
||||
"""Test UpdateChecker initializes with default config and scheduler."""
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
checker = UpdateChecker.get_instance()
|
||||
|
||||
assert checker._config is not None, "Should have config instance"
|
||||
assert checker._scheduler is not None, "Should have scheduler instance"
|
||||
assert checker._notify is not None, "Should have EventBus instance"
|
||||
assert checker._job_id is None, "Should have no job ID initially"
|
||||
|
||||
def test_attach_schedules_check_when_enabled(self):
|
||||
"""Test that attach schedules update check when config.check_for_updates is True."""
|
||||
import asyncio
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from pathlib import Path
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.ytdlp.utils import arg_converter
|
||||
from app.library.Utils import (
|
||||
FileLogFormatter,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
size="lg"
|
||||
class="justify-center sm:min-w-28"
|
||||
|
|
@ -248,7 +249,7 @@
|
|||
<div class="flex items-end">
|
||||
<UButton
|
||||
type="button"
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
:disabled="addInProgress"
|
||||
|
|
@ -306,7 +307,8 @@
|
|||
<div class="flex items-end">
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
class="justify-center"
|
||||
:disabled="addInProgress || !newExtraKey || !newExtraValue"
|
||||
|
|
@ -473,7 +475,9 @@
|
|||
: 'i-lucide-circle-help'
|
||||
"
|
||||
title="Filter Status"
|
||||
:description="logicStatusText"
|
||||
:description="
|
||||
testData.data.status === null ? 'Not tested' : logicTest ? 'Matched' : 'Not matched'
|
||||
"
|
||||
/>
|
||||
|
||||
<UFormField :ui="fieldUi">
|
||||
|
|
@ -521,6 +525,7 @@ import { match_str } from '~/utils/ytdlp';
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'cancel'): void;
|
||||
(e: 'dirty-change', dirty: boolean): void;
|
||||
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -533,7 +538,7 @@ const props = defineProps<{
|
|||
const toast = useNotification();
|
||||
const showImport = useStorage('showImport', false);
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const form = reactive<Condition>(normalizeCondition(props.item));
|
||||
const importString = ref('');
|
||||
|
|
@ -555,6 +560,16 @@ const testData = ref<{
|
|||
const showOptions = ref(false);
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
reference: props.reference ?? null,
|
||||
form: normalizeCondition(form),
|
||||
importString: importString.value,
|
||||
showImport: showImport.value,
|
||||
newExtraKey: newExtraKey.value,
|
||||
newExtraValue: newExtraValue.value,
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
const fieldUi = {
|
||||
label: 'font-semibold text-default',
|
||||
container: 'space-y-2',
|
||||
|
|
@ -584,10 +599,20 @@ watch(
|
|||
() => props.item,
|
||||
(value) => {
|
||||
Object.assign(form, normalizeCondition(value));
|
||||
|
||||
importString.value = '';
|
||||
newExtraKey.value = '';
|
||||
newExtraValue.value = '';
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
|
|
@ -650,14 +675,6 @@ const logicTest = computed(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const logicStatusText = computed(() => {
|
||||
if (testData.value.data.status === null) {
|
||||
return 'Not tested';
|
||||
}
|
||||
|
||||
return logicTest.value ? 'Matched' : 'Not matched';
|
||||
});
|
||||
|
||||
const checkInfo = async (): Promise<void> => {
|
||||
for (const key of ['name', 'filter'] as const) {
|
||||
if (!form[key]) {
|
||||
|
|
@ -853,4 +870,9 @@ const updateExtraValue = (key: string, rawValue: string): void => {
|
|||
[key]: rawValue.trim() ? parseValue(rawValue.trim()) : '',
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
size="lg"
|
||||
class="justify-center sm:min-w-28"
|
||||
|
|
@ -247,6 +248,7 @@ import { decode } from '~/utils';
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'cancel'): void;
|
||||
(e: 'dirty-change', dirty: boolean): void;
|
||||
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -258,7 +260,7 @@ const props = defineProps<{
|
|||
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const fieldTypes = ['string', 'text', 'bool'] as const;
|
||||
const fieldTypeItems = [...fieldTypes];
|
||||
|
|
@ -267,6 +269,14 @@ const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
|||
const showImport = useStorage('showDlFieldsImport', false);
|
||||
const importString = ref('');
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
reference: props.reference ?? null,
|
||||
form: normalizeField(form),
|
||||
importString: importString.value,
|
||||
showImport: showImport.value,
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
const fieldUi = {
|
||||
label: 'font-semibold text-default',
|
||||
container: 'space-y-2',
|
||||
|
|
@ -287,10 +297,18 @@ watch(
|
|||
() => props.item,
|
||||
(value) => {
|
||||
Object.assign(form, normalizeField(value));
|
||||
|
||||
importString.value = '';
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
|
|
@ -399,4 +417,9 @@ const checkInfo = (): void => {
|
|||
|
||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="inline-flex min-w-0 items-center gap-2 text-sm font-semibold text-default">
|
||||
<UIcon v-if="resolvedIcon" :name="resolvedIcon" class="size-4 shrink-0 text-toned" />
|
||||
<UIcon v-if="props.icon" :name="props.icon" class="size-4 shrink-0 text-toned" />
|
||||
<UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
|
||||
<span class="truncate" :class="{ 'has-tooltip': field }">
|
||||
{{ label }}
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<span class="inline-flex items-center gap-2 font-semibold">
|
||||
<UIcon v-if="resolvedIcon" :name="resolvedIcon" class="size-4 text-toned" />
|
||||
<UIcon v-if="props.icon" :name="props.icon" class="size-4 text-toned" />
|
||||
<UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
|
||||
<span :class="{ 'has-tooltip': field }">
|
||||
{{ label }}
|
||||
|
|
@ -142,14 +142,6 @@ const boolModel = computed({
|
|||
},
|
||||
});
|
||||
|
||||
const resolvedIcon = computed(() => {
|
||||
if (!props.icon) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return props.icon;
|
||||
});
|
||||
|
||||
const fieldUi = computed(() => ({
|
||||
container: props.compact ? 'space-y-1.5' : 'space-y-2',
|
||||
description: props.compact ? 'text-xs text-toned' : 'text-sm text-toned',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
@after:enter="focusInput"
|
||||
>
|
||||
<template #body>
|
||||
<p v-if="state.current?.opts.message">
|
||||
<p v-if="state.current?.opts.message" class="whitespace-pre-line wrap-break-word">
|
||||
{{ state.current?.opts.message }}
|
||||
</p>
|
||||
|
||||
|
|
@ -23,14 +23,6 @@
|
|||
/>
|
||||
</UFormField>
|
||||
|
||||
<div
|
||||
v-else-if="
|
||||
'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML
|
||||
"
|
||||
class="max-h-[40vh] overflow-auto text-sm text-default"
|
||||
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
'confirm' === state.current?.type &&
|
||||
|
|
@ -51,7 +43,7 @@
|
|||
<template v-if="'alert' === state.current?.type">
|
||||
<UButton
|
||||
id="primaryButton"
|
||||
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
|
||||
:color="state.current?.opts.confirmColor ?? 'primary'"
|
||||
@click="onEnter"
|
||||
>
|
||||
{{ state.current?.opts.confirmText ?? 'OK' }}
|
||||
|
|
@ -61,7 +53,7 @@
|
|||
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
|
||||
<UButton
|
||||
id="primaryButton"
|
||||
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
|
||||
:color="state.current?.opts.confirmColor ?? 'primary'"
|
||||
:disabled="
|
||||
'prompt' === state.current?.type &&
|
||||
localInput === (state.current?.opts as PromptOptions)?.initial
|
||||
|
|
@ -129,8 +121,6 @@ const focusInput = async () => {
|
|||
requestAnimationFrame(focusPrimary);
|
||||
};
|
||||
|
||||
const resolveConfirmColor = (color?: ConfirmOptions['confirmColor']) => color ?? 'primary';
|
||||
|
||||
const onCancel = () => cancel();
|
||||
const onEnter = () =>
|
||||
confirm('confirm' === state.current?.type ? selected.value : localInput.value);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,9 @@ const props = defineProps<{
|
|||
unrenderDelay?: number;
|
||||
}>();
|
||||
|
||||
const ROOT_MARGIN = 600;
|
||||
const nuxtApp = useNuxtApp();
|
||||
|
||||
const shouldRender = ref(false);
|
||||
const targetEl = ref<HTMLElement | null>(null);
|
||||
const fixedMinHeight = ref(0);
|
||||
|
|
@ -21,12 +24,35 @@ const fixedMinHeight = ref(0);
|
|||
let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onIdle(cb: () => void): void {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as any).requestIdleCallback(cb);
|
||||
} else {
|
||||
setTimeout(() => nextTick(cb), 300);
|
||||
function ensureRenderedIfNearViewport(): void {
|
||||
if (!targetEl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = targetEl.value.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
|
||||
if (
|
||||
rect.bottom < -ROOT_MARGIN ||
|
||||
rect.top > viewportHeight + ROOT_MARGIN ||
|
||||
rect.right < 0 ||
|
||||
rect.left > viewportWidth
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unrenderTimer) {
|
||||
clearTimeout(unrenderTimer);
|
||||
unrenderTimer = null;
|
||||
}
|
||||
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = null;
|
||||
}
|
||||
|
||||
shouldRender.value = true;
|
||||
}
|
||||
|
||||
const { stop } = useIntersectionObserver(
|
||||
|
|
@ -43,7 +69,7 @@ const { stop } = useIntersectionObserver(
|
|||
props.unrender ? 200 : 0,
|
||||
);
|
||||
|
||||
shouldRender.value = true;
|
||||
ensureRenderedIfNearViewport();
|
||||
|
||||
if (!props.unrender) {
|
||||
stop();
|
||||
|
|
@ -61,19 +87,55 @@ const { stop } = useIntersectionObserver(
|
|||
}, props.unrenderDelay ?? 6000);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px' },
|
||||
{ rootMargin: `${ROOT_MARGIN}px` },
|
||||
);
|
||||
|
||||
if (props.renderOnIdle) {
|
||||
onIdle(() => {
|
||||
shouldRender.value = true;
|
||||
if (!props.unrender) {
|
||||
stop();
|
||||
}
|
||||
const removePageHooks = [
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
requestAnimationFrame(() => {
|
||||
ensureRenderedIfNearViewport();
|
||||
});
|
||||
}),
|
||||
nuxtApp.hook('page:transition:finish', () => {
|
||||
requestAnimationFrame(() => {
|
||||
ensureRenderedIfNearViewport();
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', ensureRenderedIfNearViewport, { passive: true });
|
||||
requestAnimationFrame(() => {
|
||||
ensureRenderedIfNearViewport();
|
||||
});
|
||||
});
|
||||
|
||||
if (props.renderOnIdle) {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as any).requestIdleCallback(() => {
|
||||
shouldRender.value = true;
|
||||
if (!props.unrender) {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setTimeout(
|
||||
() =>
|
||||
nextTick(() => {
|
||||
shouldRender.value = true;
|
||||
if (!props.unrender) {
|
||||
stop();
|
||||
}
|
||||
}),
|
||||
300,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', ensureRenderedIfNearViewport);
|
||||
removePageHooks.forEach((removeHook) => removeHook());
|
||||
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,24 @@
|
|||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
.docs-markdown img.docs-markdown__image--inline {
|
||||
display: inline-block;
|
||||
max-width: none;
|
||||
vertical-align: middle;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.docs-markdown img.docs-markdown__image--badge {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.docs-markdown img.docs-markdown__image--large {
|
||||
width: auto;
|
||||
min-width: min(100%, 24rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.markdown-alert {
|
||||
--markdown-alert-accent: color-mix(in oklab, var(--ui-border-accented) 70%, transparent);
|
||||
margin-top: 1rem;
|
||||
|
|
@ -294,6 +312,18 @@ const content = ref('');
|
|||
const error = ref('');
|
||||
const isLoading = ref(true);
|
||||
|
||||
const isInlineMarkdownImage = (href: string): boolean => {
|
||||
return /(?:badge\.svg|shields\.io|ghcr-badge|\.svg(?:[?#].*)?$)/i.test(href);
|
||||
};
|
||||
|
||||
const getMarkdownImageClasses = (href: string): string[] => {
|
||||
if (isInlineMarkdownImage(href)) {
|
||||
return ['docs-markdown__image--inline', 'docs-markdown__image--badge'];
|
||||
}
|
||||
|
||||
return ['docs-markdown__image--large'];
|
||||
};
|
||||
|
||||
const createMarkdownParser = () => {
|
||||
const parser = new Marked();
|
||||
|
||||
|
|
@ -370,7 +400,9 @@ const createMarkdownParser = () => {
|
|||
const refPolicy = ' referrerpolicy="no-referrer"';
|
||||
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
|
||||
const loading = ' loading="lazy"';
|
||||
return `<img src="${token.href || ''}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
|
||||
const src = token.href || '';
|
||||
const classes = getMarkdownImageClasses(src).join(' ');
|
||||
return `<img src="${src}" class="${classes}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@
|
|||
base: 'min-h-[7.25rem] bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||
}"
|
||||
@keydown="handleKeyDown"
|
||||
@input="() => void adjustTextareaHeight()"
|
||||
/>
|
||||
<UInput
|
||||
v-else
|
||||
|
|
@ -520,7 +519,7 @@ const emitter = defineEmits<{
|
|||
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void;
|
||||
(e: 'clear_form'): void;
|
||||
}>();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
|
||||
|
|
@ -662,9 +661,10 @@ const is_valid_dl_field = (dl_field: string): boolean => {
|
|||
return false;
|
||||
};
|
||||
|
||||
const adjustTextareaHeight = async (): Promise<void> => {
|
||||
await nextTick();
|
||||
urlTextarea.value?.textareaRef?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const getUrlElement = (): HTMLInputElement | HTMLTextAreaElement | null => {
|
||||
return (
|
||||
urlTextarea.value?.textareaRef || (document.getElementById('url') as HTMLInputElement | null)
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||
|
|
@ -690,10 +690,10 @@ const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
|||
|
||||
await nextTick();
|
||||
|
||||
if (urlTextarea.value) {
|
||||
await adjustTextareaHeight();
|
||||
urlTextarea.value.textareaRef?.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||
urlTextarea.value.textareaRef?.focus();
|
||||
const field = getUrlElement();
|
||||
if (field instanceof HTMLTextAreaElement) {
|
||||
field.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||
field.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -714,11 +714,11 @@ const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
|||
|
||||
await nextTick();
|
||||
|
||||
if (urlTextarea.value) {
|
||||
await adjustTextareaHeight();
|
||||
const field = getUrlElement();
|
||||
if (field instanceof HTMLTextAreaElement) {
|
||||
const newPos = start + pastedText.length;
|
||||
urlTextarea.value.textareaRef?.setSelectionRange(newPos, newPos);
|
||||
urlTextarea.value.textareaRef?.focus();
|
||||
field.setSelectionRange(newPos, newPos);
|
||||
field.focus();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -944,10 +944,6 @@ onMounted(async () => {
|
|||
if (!separators.some((s) => s.value === separator.value)) {
|
||||
separator.value = separators[0]?.value ?? ',';
|
||||
}
|
||||
|
||||
if (isMultiLineInput.value && urlTextarea.value) {
|
||||
await adjustTextareaHeight();
|
||||
}
|
||||
});
|
||||
|
||||
const runCliCommand = async (): Promise<void> => {
|
||||
|
|
@ -1148,11 +1144,9 @@ const hasValidUrl = computed(() => form.value.url && form.value.url.trim().lengt
|
|||
watch(isMultiLineInput, async (newValue) => {
|
||||
await nextTick();
|
||||
if (newValue) {
|
||||
await adjustTextareaHeight();
|
||||
urlTextarea.value?.textareaRef?.focus();
|
||||
return;
|
||||
}
|
||||
const inputElement = document.getElementById('url') as HTMLInputElement;
|
||||
inputElement?.focus();
|
||||
getUrlElement()?.focus();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
size="lg"
|
||||
class="justify-center sm:min-w-28"
|
||||
|
|
@ -382,7 +383,7 @@
|
|||
<div class="flex items-end">
|
||||
<UButton
|
||||
type="button"
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
:disabled="addInProgress"
|
||||
|
|
@ -442,6 +443,7 @@ import type { notification, notificationRequestHeaderItem } from '~/types/notifi
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(event: 'cancel'): void;
|
||||
(event: 'dirty-change', dirty: boolean): void;
|
||||
(event: 'submit', payload: { reference: number | undefined; item: notification }): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -488,14 +490,30 @@ const selectUi = {
|
|||
|
||||
const isAppriseTarget = computed(() => Boolean(form.request.url) && isApprise(form.request.url));
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
reference: props.reference ?? null,
|
||||
form: normalizeNotification(form),
|
||||
importString: importString.value,
|
||||
showImport: showImport.value,
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
(value) => {
|
||||
Object.assign(form, normalizeNotification(value));
|
||||
|
||||
importString.value = '';
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||
|
||||
function createDefaultNotification(): notification {
|
||||
return {
|
||||
name: '',
|
||||
|
|
@ -633,4 +651,9 @@ const importItem = async (): Promise<void> => {
|
|||
toast.error(`Failed to import task. ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -138,9 +138,10 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment';
|
||||
import { useNotificationCenter } from '~/composables/useNotificationCenter';
|
||||
import type { notificationType } from '~/composables/useNotification';
|
||||
|
||||
const store = useNotificationStore();
|
||||
const store = useNotificationCenter();
|
||||
|
||||
const copiedId = ref<string | null>(null);
|
||||
const expandedId = ref<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
v-if="page !== 1"
|
||||
rel="first"
|
||||
aria-label="Go to first page"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-chevrons-left"
|
||||
:disabled="isLoading"
|
||||
:loading="isLoading"
|
||||
square
|
||||
@click="changePage(1)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="page > 1 && page - 1 !== 1"
|
||||
rel="prev"
|
||||
aria-label="Go to previous page"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-chevron-left"
|
||||
:disabled="isLoading"
|
||||
:loading="isLoading"
|
||||
square
|
||||
@click="changePage(page - 1)"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
id="pager_list"
|
||||
v-model="currentPage"
|
||||
:items="paginationItems"
|
||||
value-key="page"
|
||||
label-key="text"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="min-w-52"
|
||||
:disabled="isLoading"
|
||||
:ui="{ base: 'w-full' }"
|
||||
@update:model-value="changePage"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="page !== last_page && page + 1 !== last_page"
|
||||
rel="next"
|
||||
aria-label="Go to next page"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-chevron-right"
|
||||
:disabled="isLoading"
|
||||
:loading="isLoading"
|
||||
square
|
||||
@click="changePage(page + 1)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="page !== last_page"
|
||||
rel="last"
|
||||
aria-label="Go to last page"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-chevrons-right"
|
||||
:disabled="isLoading"
|
||||
:loading="isLoading"
|
||||
square
|
||||
@click="changePage(last_page)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { makePagination } from '~/utils/index';
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'navigate', page: number): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
page: number;
|
||||
last_page: number;
|
||||
isLoading?: boolean;
|
||||
}>();
|
||||
|
||||
const currentPage = ref(props.page);
|
||||
|
||||
const paginationItems = computed(() =>
|
||||
makePagination(props.page, props.last_page).map((item) => ({
|
||||
...item,
|
||||
disabled: item.page === 0,
|
||||
})),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.page,
|
||||
(value) => {
|
||||
currentPage.value = value;
|
||||
},
|
||||
);
|
||||
|
||||
const changePage = (page: number | string): void => {
|
||||
const nextPage = Number(page);
|
||||
if (Number.isNaN(nextPage) || nextPage < 1 || nextPage > props.last_page) {
|
||||
currentPage.value = props.page;
|
||||
return;
|
||||
}
|
||||
|
||||
emitter('navigate', nextPage);
|
||||
currentPage.value = nextPage;
|
||||
};
|
||||
</script>
|
||||
|
|
@ -71,7 +71,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
size="lg"
|
||||
:disabled="!importString"
|
||||
|
|
@ -358,6 +359,7 @@ import { normalizePresetName } from '~/utils';
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(event: 'cancel'): void;
|
||||
(event: 'dirty-change', dirty: boolean): void;
|
||||
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -368,7 +370,7 @@ const props = defineProps<{
|
|||
presets?: Preset[];
|
||||
}>();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets);
|
||||
|
|
@ -419,6 +421,15 @@ const textareaUi = {
|
|||
|
||||
const importPresetItems = computed(() => selectItems.value);
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
reference: props.reference ?? null,
|
||||
form: JSON.parse(JSON.stringify(form)),
|
||||
importString: importString.value,
|
||||
selectedPreset: selectedPreset.value,
|
||||
showImport: showImport.value,
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
watch(
|
||||
() => props.preset,
|
||||
(value) => {
|
||||
|
|
@ -433,10 +444,19 @@ watch(
|
|||
priority: 0,
|
||||
...JSON.parse(JSON.stringify(value || {})),
|
||||
});
|
||||
|
||||
importString.value = '';
|
||||
selectedPreset.value = '';
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
|
|
@ -624,4 +644,9 @@ const importExistingPreset = async (): Promise<void> => {
|
|||
await nextTick();
|
||||
selectedPreset.value = '';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@
|
|||
:side="direction"
|
||||
:dismissible="true"
|
||||
:overlay="true"
|
||||
:ui="{ content: 'w-full sm:max-w-xl' }"
|
||||
:ui="{ content: 'yt-settings-panel w-full sm:max-w-xl' }"
|
||||
@update:open="(open) => !open && emitter('close')"
|
||||
>
|
||||
<template #header>
|
||||
|
|
@ -275,7 +275,6 @@
|
|||
<script setup lang="ts">
|
||||
import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
||||
|
||||
|
|
@ -294,7 +293,7 @@ const props = withDefaults(
|
|||
|
||||
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const notification = useNotification();
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,83 +1,17 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
:disabled="isBusy"
|
||||
@click="showImport = !showImport"
|
||||
>
|
||||
{{ showImport ? 'Hide import' : 'Show import' }}
|
||||
</UButton>
|
||||
|
||||
<div class="inline-flex rounded-md border border-default bg-muted/20 p-1">
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
icon="i-lucide-sliders-horizontal"
|
||||
:color="mode === 'gui' ? 'primary' : 'neutral'"
|
||||
:variant="mode === 'gui' ? 'solid' : 'ghost'"
|
||||
:disabled="!guiSupported || isBusy"
|
||||
@click="switchMode('gui')"
|
||||
>
|
||||
GUI
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
icon="i-lucide-code"
|
||||
:color="mode === 'advanced' ? 'primary' : 'neutral'"
|
||||
:variant="mode === 'advanced' ? 'solid' : 'ghost'"
|
||||
:disabled="isBusy"
|
||||
@click="switchMode('advanced')"
|
||||
>
|
||||
Advanced
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<UButton
|
||||
v-if="mode === 'advanced'"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-wand-sparkles"
|
||||
:disabled="isBusy"
|
||||
@click="beautify"
|
||||
>
|
||||
Format
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-x"
|
||||
:disabled="submitting"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
icon="i-lucide-save"
|
||||
:loading="submitting"
|
||||
:disabled="isBusy"
|
||||
@click="submit"
|
||||
>
|
||||
Save
|
||||
</UButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
:disabled="isBusy"
|
||||
@click="showImport = !showImport"
|
||||
>
|
||||
{{ showImport ? 'Hide import' : 'Show import' }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -132,7 +66,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
class="justify-center sm:min-w-28"
|
||||
:disabled="isBusy || !importString.trim()"
|
||||
|
|
@ -219,7 +154,7 @@
|
|||
<UFormField
|
||||
class="md:col-span-3"
|
||||
:ui="fieldUi"
|
||||
description="Disabled definitions won't match tasks."
|
||||
description="Whether this definition is active or not."
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -364,7 +299,7 @@
|
|||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-12">
|
||||
<UFormField class="md:col-span-4" :ui="fieldUi">
|
||||
<UFormField class="md:col-span-4" :ui="fieldUi" description="Selector type">
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UIcon name="i-lucide-shapes" class="size-4 text-toned" />
|
||||
|
|
@ -383,7 +318,7 @@
|
|||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField class="md:col-span-8" :ui="fieldUi">
|
||||
<UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
|
||||
<template #label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
|
||||
|
|
@ -415,7 +350,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="isBusy"
|
||||
|
|
@ -431,7 +367,9 @@
|
|||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-215 table-fixed w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-40">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-key" class="size-3.5 text-toned" />
|
||||
|
|
@ -473,7 +411,7 @@
|
|||
<tr
|
||||
v-for="(field, index) in guiState.fields"
|
||||
:key="`${index}-${field.key}`"
|
||||
class="align-top"
|
||||
class="align-top [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3">
|
||||
<UInput
|
||||
|
|
@ -517,7 +455,7 @@
|
|||
<td class="px-3 py-3 text-right">
|
||||
<UButton
|
||||
type="button"
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
|
|
@ -571,6 +509,77 @@
|
|||
:description="errorMessage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t border-default pt-5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="inline-flex self-start rounded-md border border-default bg-muted/20 p-1">
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
icon="i-lucide-sliders-horizontal"
|
||||
color="neutral"
|
||||
:variant="mode === 'gui' ? 'soft' : 'ghost'"
|
||||
:disabled="!guiSupported || isBusy"
|
||||
@click="switchMode('gui')"
|
||||
>
|
||||
GUI
|
||||
</UButton>
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
icon="i-lucide-code"
|
||||
color="neutral"
|
||||
:variant="mode === 'advanced' ? 'soft' : 'ghost'"
|
||||
:disabled="isBusy"
|
||||
@click="switchMode('advanced')"
|
||||
>
|
||||
Advanced
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
v-if="mode === 'advanced'"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
icon="i-lucide-wand-sparkles"
|
||||
:disabled="isBusy"
|
||||
class="justify-center"
|
||||
@click="beautify"
|
||||
>
|
||||
Format
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
icon="i-lucide-x"
|
||||
:disabled="submitting"
|
||||
class="justify-center"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
size="lg"
|
||||
icon="i-lucide-save"
|
||||
:loading="submitting"
|
||||
:disabled="isBusy"
|
||||
class="justify-center"
|
||||
@click="submit"
|
||||
>
|
||||
Save
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -615,6 +624,7 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(e: 'submit', payload: TaskDefinitionDocument): void;
|
||||
(e: 'cancel'): void;
|
||||
(e: 'dirty-change', dirty: boolean): void;
|
||||
(e: 'import-existing', id: number): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -708,6 +718,16 @@ const existingDefinitionItems = computed(() => {
|
|||
}));
|
||||
});
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
mode: mode.value,
|
||||
showImport: showImport.value,
|
||||
importString: importString.value,
|
||||
selectedExisting: selectedExisting.value,
|
||||
jsonText: jsonText.value,
|
||||
guiState: JSON.parse(JSON.stringify(guiState)),
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
const resetGuiState = (state: GuiState): void => {
|
||||
guiState.name = state.name;
|
||||
guiState.priority = state.priority;
|
||||
|
|
@ -1027,6 +1047,10 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
|
|||
containerSelector: '',
|
||||
fields: [defaultField()],
|
||||
});
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emit('dirty-change', false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1050,6 +1074,11 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
|
|||
mode.value = 'advanced';
|
||||
errorMessage.value = 'Failed to prepare definition for editing.';
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emit('dirty-change', false);
|
||||
});
|
||||
};
|
||||
|
||||
const importFromString = (): void => {
|
||||
|
|
@ -1088,6 +1117,8 @@ watch(
|
|||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emit('dirty-change', value));
|
||||
|
||||
const switchMode = (next: EditorMode): void => {
|
||||
if (isBusy.value || next === mode.value) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="convertInProgress"
|
||||
|
|
@ -94,7 +94,8 @@
|
|||
|
||||
<UButton
|
||||
type="button"
|
||||
color="primary"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-import"
|
||||
size="lg"
|
||||
:disabled="!import_string"
|
||||
|
|
@ -512,6 +513,7 @@ const props = defineProps<{
|
|||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'cancel'): void;
|
||||
(e: 'dirty-change', dirty: boolean): void;
|
||||
(
|
||||
e: 'submit',
|
||||
payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean },
|
||||
|
|
@ -519,7 +521,7 @@ const emitter = defineEmits<{
|
|||
}>();
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const dialog = useDialog();
|
||||
const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
|
||||
const showImport = useStorage('showTaskImport', false);
|
||||
|
|
@ -554,6 +556,15 @@ const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i;
|
|||
|
||||
const form = reactive<Task>(createDefaultTask(props.task));
|
||||
|
||||
const dirtySource = computed(() => ({
|
||||
reference: props.reference ?? null,
|
||||
form: JSON.parse(JSON.stringify(form)),
|
||||
import_string: import_string.value,
|
||||
showImport: showImport.value,
|
||||
archiveAllAfterAdd: archiveAllAfterAdd.value,
|
||||
}));
|
||||
const { isDirty, markClean } = useDirtyState(dirtySource);
|
||||
|
||||
const fieldUi = {
|
||||
label: 'font-semibold text-default',
|
||||
container: 'space-y-2',
|
||||
|
|
@ -610,6 +621,13 @@ watch(
|
|||
if (!value?.preset) {
|
||||
form.preset = toRaw(config.app.default_preset);
|
||||
}
|
||||
|
||||
import_string.value = '';
|
||||
archiveAllAfterAdd.value = false;
|
||||
nextTick(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
|
@ -636,6 +654,8 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
const isTextarea = target.tagName === 'TEXTAREA';
|
||||
|
|
@ -930,4 +950,9 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
|
|||
|
||||
return getPresetDefault(form.preset, type, ret);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
markClean();
|
||||
emitter('dirty-change', false);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
type="button"
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
:disabled="loading"
|
||||
|
|
@ -127,7 +127,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { request } from '~/utils';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -138,7 +137,7 @@ const props = defineProps<{
|
|||
|
||||
const { selectItems } = usePresetOptions();
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const url = ref(props.url ?? '');
|
||||
const preset = ref(props.preset || config.app.default_preset || '');
|
||||
const handler = ref(props.handler ?? '');
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
|
|||
import type { StoreItem } from '~/types/store';
|
||||
import type { file_info, video_source_element, video_track_element } from '~/types/video';
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const props = defineProps<{ item: StoreItem }>();
|
||||
const emitter = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@
|
|||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-180 w-full table-auto text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-80 whitespace-nowrap">Flags</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
|
@ -130,7 +132,7 @@
|
|||
<tr
|
||||
v-for="opt in group.items"
|
||||
:key="opt.flags.join('|')"
|
||||
class="align-top hover:bg-muted/20"
|
||||
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="w-80 px-3 py-3 align-top">
|
||||
<div class="flex items-start gap-2">
|
||||
|
|
@ -185,7 +187,9 @@
|
|||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-215 w-full table-auto text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-80 whitespace-nowrap">Flags</th>
|
||||
<th class="w-36 whitespace-nowrap">Group</th>
|
||||
<th>Description</th>
|
||||
|
|
@ -195,7 +199,7 @@
|
|||
<tr
|
||||
v-for="opt in visible"
|
||||
:key="opt.flags.join('|')"
|
||||
class="align-top hover:bg-muted/20"
|
||||
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="w-80 px-3 py-3 align-top">
|
||||
<div class="flex items-start gap-2">
|
||||
|
|
|
|||
403
ui/app/composables/useAppSocket.ts
Normal file
403
ui/app/composables/useAppSocket.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import { proxyRefs, readonly, ref } from 'vue';
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import { useQueueState } from '~/composables/useQueueState';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
ConfigUpdatePayload,
|
||||
WebSocketClientEmits,
|
||||
WebSocketEnvelope,
|
||||
WSEP as WSEP,
|
||||
} from '~/types/sockets';
|
||||
|
||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void;
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||
type KnownEvent = keyof WSEP;
|
||||
|
||||
const getRuntimeConfig = () => useRuntimeConfig();
|
||||
const getConfig = () => useYtpConfig();
|
||||
const getQueueState = () => useQueueState();
|
||||
const getToast = () => useNotification();
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref<boolean>(false);
|
||||
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||
const error = ref<string | null>(null);
|
||||
const error_count = ref<number>(0);
|
||||
const wasHidden = ref<boolean>(false);
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const manualDisconnect = ref<boolean>(false);
|
||||
const reconnectAttempts = ref<number>(0);
|
||||
|
||||
const handlers = new Map<string, HandlerRegistry>();
|
||||
|
||||
const emit = <K extends keyof WebSocketClientEmits>(
|
||||
event: K,
|
||||
data: WebSocketClientEmits[K],
|
||||
): void => {
|
||||
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
socket.value.send(JSON.stringify({ event, data }));
|
||||
};
|
||||
|
||||
function on<K extends KnownEvent>(event: K | K[], callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(
|
||||
event: K | K[],
|
||||
callback: (event: K, payload: WSEP[K]) => void,
|
||||
withEvent: true,
|
||||
): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||
const handler =
|
||||
true === withEvent
|
||||
? (payload: unknown) => callback(eventName, payload)
|
||||
: (payload: unknown) => callback(payload);
|
||||
|
||||
registry.set(callback, handler);
|
||||
});
|
||||
}
|
||||
|
||||
function off<K extends KnownEvent>(event: K | K[], callback?: (payload: WSEP[K]) => void): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
registry.clear();
|
||||
handlers.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
registry.delete(callback);
|
||||
if (0 === registry.size) {
|
||||
handlers.delete(eventName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getSessionId = (): string | null => null;
|
||||
|
||||
const dispatch = (eventName: string, payload: unknown): void => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
registry.forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
wasHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === wasHidden.value && false === isConnected.value) {
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
if (false === isConnected.value) {
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||
reconnect();
|
||||
}
|
||||
reconnectTimeout.value = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
wasHidden.value = false;
|
||||
};
|
||||
|
||||
const setupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.value >= 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== reconnectTimeout.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
reconnectAttempts.value += 1;
|
||||
reconnectTimeout.value = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
connectionStatus.value = 'connecting';
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
manualDisconnect.value = true;
|
||||
if (null === socket.value) {
|
||||
return;
|
||||
}
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
cleanupVisibilityListener();
|
||||
};
|
||||
|
||||
const buildWsUrl = (): string => {
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||
|
||||
if (configuredBase) {
|
||||
return new URL(wsPath, configuredBase).toString();
|
||||
}
|
||||
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const runtimeConfig = getRuntimeConfig();
|
||||
|
||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualDisconnect.value = false;
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
socket.value = new WebSocket(buildWsUrl());
|
||||
|
||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||
window.ws = socket.value;
|
||||
}
|
||||
|
||||
socket.value.addEventListener('open', () => {
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
error_count.value = 0;
|
||||
reconnectAttempts.value = 0;
|
||||
dispatch('connect', null);
|
||||
});
|
||||
|
||||
socket.value.addEventListener('close', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Disconnected from server.';
|
||||
dispatch('disconnect', null);
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('error', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Connection error: Unknown error';
|
||||
error_count.value += 1;
|
||||
dispatch('connect_error', { message: 'Unknown error' });
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let payload: WebSocketEnvelope | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload?.event || 'string' != typeof payload.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = payload.data;
|
||||
if ('string' === typeof data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
data = payload.data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(payload.event, data);
|
||||
});
|
||||
|
||||
setupVisibilityListener();
|
||||
};
|
||||
|
||||
on('connect', () => getConfig().loadConfig(false));
|
||||
|
||||
on('connected', () => {
|
||||
error.value = null;
|
||||
getConfig().loadConfig(false);
|
||||
});
|
||||
|
||||
on('item_added', (data: WSEP['item_added']) => {
|
||||
const queueState = getQueueState();
|
||||
const toast = getToast();
|
||||
|
||||
queueState.add(data.data._id, data.data);
|
||||
toast.success(`Item queued: ${ag(queueState.get(data.data._id, {} as StoreItem), 'title')}`);
|
||||
});
|
||||
|
||||
on(
|
||||
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||
(event, data: WSEP['log_info']) => {
|
||||
const toast = getToast();
|
||||
const message =
|
||||
'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra);
|
||||
break;
|
||||
case 'log_success':
|
||||
toast.success(message, extra);
|
||||
break;
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra);
|
||||
break;
|
||||
case 'log_error':
|
||||
toast.error(message, extra);
|
||||
break;
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||
const queueState = getQueueState();
|
||||
const toast = getToast();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== queueState.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning(`Download cancelled: ${ag(queueState.get(id, {} as StoreItem), 'title')}`);
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||
const queueState = getQueueState();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const queueState = getQueueState();
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.update(id, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_moved', (data: WSEP['item_moved']) => {
|
||||
const queueState = getQueueState();
|
||||
const to = data.data.to;
|
||||
const id = data.data.item._id;
|
||||
|
||||
if ('queue' === to) {
|
||||
queueState.add(id, data.data.item);
|
||||
}
|
||||
|
||||
if ('history' === to) {
|
||||
if (true === queueState.has(id)) {
|
||||
queueState.remove(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
on(
|
||||
['paused', 'resumed'],
|
||||
(event, data: WSEP['paused']) => {
|
||||
const config = getConfig();
|
||||
const toast = getToast();
|
||||
const pausedState = Boolean(data.data.paused);
|
||||
config.update('paused', pausedState);
|
||||
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('config_update', (data: WSEP['config_update']) => {
|
||||
const config = getConfig();
|
||||
const configUpdate = data.data as ConfigUpdatePayload;
|
||||
if (!configUpdate) {
|
||||
return;
|
||||
}
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||
});
|
||||
|
||||
const appSocketApi = proxyRefs({
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
isConnected,
|
||||
getSessionId,
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
error: readonly(error),
|
||||
error_count: readonly(error_count),
|
||||
});
|
||||
|
||||
export const useAppSocket = () => appSocketApi;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ref, readonly, computed, toRaw } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { request, parse_api_error, sTrim, encodePath } from '~/utils';
|
||||
import type { FileItem, Pagination } from '~/types/filebrowser';
|
||||
|
||||
|
|
@ -50,7 +49,7 @@ const handleError = (error: unknown): void => {
|
|||
};
|
||||
|
||||
const buildQueryParams = (page?: number): string => {
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page ?? pagination.value.page));
|
||||
params.set('per_page', String(config.app.default_pagination || 50));
|
||||
|
|
|
|||
|
|
@ -52,10 +52,6 @@ export type ConfirmOptions = BaseOptions & {
|
|||
* Text for the confirm button
|
||||
*/
|
||||
cancelText?: string;
|
||||
/**
|
||||
* Raw HTML content to include in the dialog message.
|
||||
*/
|
||||
rawHTML?: string;
|
||||
/**
|
||||
* Optional checkbox-style options to return with the confirmation result.
|
||||
*/
|
||||
|
|
|
|||
105
ui/app/composables/useDirtyCloseGuard.ts
Normal file
105
ui/app/composables/useDirtyCloseGuard.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { onBeforeRouteLeave, onBeforeRouteUpdate, onBeforeUnmount } from '#imports';
|
||||
import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
|
||||
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
|
||||
type DirtyCloseGuardOptions = {
|
||||
dirty: MaybeRefOrGetter<boolean>;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral';
|
||||
onDiscard?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const useDirtyCloseGuard = (open: Ref<boolean>, options: DirtyCloseGuardOptions) => {
|
||||
const dialog = useDialog();
|
||||
let pendingCloseRequest: Promise<boolean> | null = null;
|
||||
|
||||
const isDirty = computed<boolean>(() => Boolean(toValue(options.dirty)));
|
||||
const shouldGuard = computed<boolean>(() => Boolean(open.value) && true === isDirty.value);
|
||||
|
||||
const confirmClose = async (): Promise<boolean> => {
|
||||
if (false === isDirty.value) {
|
||||
open.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: options.title ?? 'Discard changes?',
|
||||
message: options.message ?? 'You have unsaved changes. Do you want to discard them?',
|
||||
confirmText: options.confirmText ?? 'Discard changes',
|
||||
cancelText: options.cancelText ?? 'Keep editing',
|
||||
confirmColor: options.confirmColor ?? 'warning',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await options.onDiscard?.();
|
||||
open.value = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const requestClose = async (): Promise<boolean> => {
|
||||
if (pendingCloseRequest) {
|
||||
return pendingCloseRequest;
|
||||
}
|
||||
|
||||
pendingCloseRequest = confirmClose().finally(() => {
|
||||
pendingCloseRequest = null;
|
||||
});
|
||||
|
||||
return pendingCloseRequest;
|
||||
};
|
||||
|
||||
const handleOpenChange = async (value: boolean): Promise<void> => {
|
||||
if (true === value) {
|
||||
open.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await requestClose();
|
||||
};
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (false === shouldGuard.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await requestClose();
|
||||
});
|
||||
|
||||
onBeforeRouteUpdate(async () => {
|
||||
if (false === shouldGuard.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await requestClose();
|
||||
});
|
||||
|
||||
if ('undefined' !== typeof window) {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (false === shouldGuard.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isDirty,
|
||||
requestClose,
|
||||
handleOpenChange,
|
||||
};
|
||||
};
|
||||
20
ui/app/composables/useDirtyState.ts
Normal file
20
ui/app/composables/useDirtyState.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
const serializeDirtyValue = <T>(value: T): string => JSON.stringify(toValue(value));
|
||||
|
||||
export const useDirtyState = <T>(source: MaybeRefOrGetter<T>) => {
|
||||
const snapshot = ref<string>('');
|
||||
|
||||
const markClean = (): void => {
|
||||
snapshot.value = serializeDirtyValue(toValue(source));
|
||||
};
|
||||
|
||||
const isDirty = computed<boolean>(() => {
|
||||
return snapshot.value !== serializeDirtyValue(toValue(source));
|
||||
});
|
||||
|
||||
return {
|
||||
isDirty,
|
||||
markClean,
|
||||
};
|
||||
};
|
||||
50
ui/app/composables/useExpandableMeta.ts
Normal file
50
ui/app/composables/useExpandableMeta.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export const useExpandableMeta = () => {
|
||||
const expandedItems = ref<Record<string, Set<string>>>({});
|
||||
|
||||
const normalizeId = (itemId: string | number | undefined | null): string | null => {
|
||||
if (itemId === undefined || itemId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(itemId);
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: string | number | undefined | null, field: string): void => {
|
||||
const key = normalizeId(itemId);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expandedItems.value[key]) {
|
||||
expandedItems.value[key] = new Set();
|
||||
}
|
||||
|
||||
if (expandedItems.value[key]?.has(field)) {
|
||||
expandedItems.value[key]?.delete(field);
|
||||
return;
|
||||
}
|
||||
|
||||
expandedItems.value[key]?.add(field);
|
||||
};
|
||||
|
||||
const isExpanded = (itemId: string | number | undefined | null, field: string): boolean => {
|
||||
const key = normalizeId(itemId);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandedItems.value[key]?.has(field) ?? false;
|
||||
};
|
||||
|
||||
const expandClass = (itemId: string | number | undefined | null, field: string): string => {
|
||||
return isExpanded(itemId, field)
|
||||
? 'block max-w-full whitespace-pre-wrap break-words'
|
||||
: 'block max-w-full truncate';
|
||||
};
|
||||
|
||||
return {
|
||||
toggleExpand,
|
||||
isExpanded,
|
||||
expandClass,
|
||||
};
|
||||
};
|
||||
184
ui/app/composables/useHistoryState.ts
Normal file
184
ui/app/composables/useHistoryState.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import { parse_api_error, parse_list_response, request } from '~/utils';
|
||||
import type { Pagination } from '~/types/responses';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
|
||||
type HistoryLoadOptions = {
|
||||
order?: 'ASC' | 'DESC';
|
||||
status?: string;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
const items = ref<StoreItem[]>([]);
|
||||
const pagination = ref<Pagination>({
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
});
|
||||
const isLoading = ref<boolean>(false);
|
||||
const isLoaded = ref<boolean>(false);
|
||||
const lastError = ref<string | null>(null);
|
||||
const throwInstead = ref(false);
|
||||
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone();
|
||||
return await clone.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
useNotification().error(message);
|
||||
};
|
||||
|
||||
const pageSize = computed<number>(() => {
|
||||
const config = useYtpConfig();
|
||||
return Number(config.app.default_pagination || 50);
|
||||
});
|
||||
|
||||
const buildQuery = (
|
||||
page: number,
|
||||
perPage: number,
|
||||
order: 'ASC' | 'DESC' = 'DESC',
|
||||
status?: string,
|
||||
): string => {
|
||||
const params = new URLSearchParams({
|
||||
type: 'done',
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
order,
|
||||
});
|
||||
|
||||
if (status) {
|
||||
params.set('status', status);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
const loadHistory = async (page: number = 1, options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const { order = 'DESC', status, perPage = pageSize.value } = options;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await request(`/api/history?${buildQuery(page, perPage, order, status)}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const data = await response.json();
|
||||
const { items: loadedItems, pagination: paginationData } =
|
||||
await parse_list_response<StoreItem>(data);
|
||||
|
||||
items.value = loadedItems;
|
||||
pagination.value = paginationData;
|
||||
isLoaded.value = true;
|
||||
lastError.value = null;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
if (throwInstead.value) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reloadHistory = async (options: HistoryLoadOptions = {}): Promise<void> => {
|
||||
const targetPage = isLoaded.value ? pagination.value.page : 1;
|
||||
await loadHistory(targetPage, options);
|
||||
};
|
||||
|
||||
const deleteHistoryItems = async (
|
||||
options: {
|
||||
ids?: string[];
|
||||
status?: string;
|
||||
removeFile?: boolean;
|
||||
} = {},
|
||||
): Promise<number> => {
|
||||
const { ids, status, removeFile = true } = options;
|
||||
|
||||
if (!ids && !status) {
|
||||
throw new Error('Either ids or status filter must be provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
type: 'done',
|
||||
ids,
|
||||
status,
|
||||
remove_file: removeFile,
|
||||
}),
|
||||
});
|
||||
|
||||
await ensureSuccess(response);
|
||||
|
||||
const result = (await response.json()) as {
|
||||
deleted?: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.error || result.message) {
|
||||
throw new Error(result.error || result.message);
|
||||
}
|
||||
|
||||
return Number(result.deleted ?? 0);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
if (throwInstead.value) {
|
||||
throw error;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const resetHistory = (): void => {
|
||||
items.value = [];
|
||||
pagination.value = {
|
||||
page: 1,
|
||||
per_page: pageSize.value,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
};
|
||||
isLoaded.value = false;
|
||||
lastError.value = null;
|
||||
};
|
||||
|
||||
export const useHistoryState = () => {
|
||||
return {
|
||||
items,
|
||||
pagination,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
lastError,
|
||||
pageSize,
|
||||
loadHistory,
|
||||
reloadHistory,
|
||||
deleteHistoryItems,
|
||||
resetHistory,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import { useNotificationCenter } from '~/composables/useNotificationCenter';
|
||||
import { getNuxtToastManager } from '~/utils/nuxtToastManager';
|
||||
|
||||
export type notificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
|
|
@ -70,7 +71,7 @@ const sendMessage = (
|
|||
message: string,
|
||||
opts?: notificationOptions,
|
||||
): void => {
|
||||
const notificationStore = useNotificationStore();
|
||||
const notificationStore = useNotificationCenter();
|
||||
|
||||
const useToastNotification =
|
||||
!window.isSecureContext ||
|
||||
|
|
@ -115,7 +116,7 @@ const sendMessage = (
|
|||
};
|
||||
|
||||
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
||||
const notificationStore = useNotificationStore();
|
||||
const notificationStore = useNotificationCenter();
|
||||
|
||||
if (!opts) {
|
||||
opts = {};
|
||||
|
|
|
|||
115
ui/app/composables/useNotificationCenter.ts
Normal file
115
ui/app/composables/useNotificationCenter.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { computed, proxyRefs } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification, notificationType } from '~/composables/useNotification';
|
||||
|
||||
const NOTIFICATION_META: Record<notificationType, { level: number; color: string; icon: string }> =
|
||||
{
|
||||
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
|
||||
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
|
||||
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
|
||||
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
|
||||
};
|
||||
|
||||
const notifications = useStorage<notification[]>('notifications', []);
|
||||
|
||||
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||
|
||||
const severityLevel = computed<notificationType | null>(() => {
|
||||
const unread = notifications.value.filter((n) => !n.seen);
|
||||
|
||||
if (0 === unread.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return unread.reduce((highest, current) =>
|
||||
NOTIFICATION_META[current.level].level > NOTIFICATION_META[highest.level].level
|
||||
? current
|
||||
: highest,
|
||||
).level;
|
||||
});
|
||||
|
||||
const severityColor = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? NOTIFICATION_META[level].color : '';
|
||||
});
|
||||
|
||||
const severityIcon = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? NOTIFICATION_META[level].icon : '';
|
||||
});
|
||||
|
||||
const sortedNotifications = computed<notification[]>(() => {
|
||||
return [...notifications.value].sort((left, right) => {
|
||||
const severityDiff = NOTIFICATION_META[right.level].level - NOTIFICATION_META[left.level].level;
|
||||
|
||||
if (0 !== severityDiff) {
|
||||
return severityDiff;
|
||||
}
|
||||
|
||||
return new Date(right.created).getTime() - new Date(left.created).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: number) =>
|
||||
dec.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
notifications.value.unshift({
|
||||
id,
|
||||
message,
|
||||
level,
|
||||
seen,
|
||||
created: new Date(),
|
||||
});
|
||||
|
||||
if (notifications.value.length > 99) {
|
||||
notifications.value.length = 99;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
const markAllRead = (): void => {
|
||||
notifications.value.forEach((item) => {
|
||||
item.seen = true;
|
||||
});
|
||||
};
|
||||
|
||||
const markRead = (id: string): void => {
|
||||
const entry = notifications.value.find((item) => item.id === id);
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.seen = true;
|
||||
};
|
||||
|
||||
const get = (id: string): notification | undefined =>
|
||||
notifications.value.find((item) => item.id === id);
|
||||
|
||||
const remove = (id: string): void => {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
};
|
||||
|
||||
const notificationCenterApi = proxyRefs({
|
||||
notifications,
|
||||
unreadCount,
|
||||
severityLevel,
|
||||
severityColor,
|
||||
severityIcon,
|
||||
sortedNotifications,
|
||||
add,
|
||||
get,
|
||||
markAllRead,
|
||||
clear,
|
||||
markRead,
|
||||
remove,
|
||||
});
|
||||
|
||||
export const useNotificationCenter = () => notificationCenterApi;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import { cleanObject, prettyName } from '~/utils';
|
||||
|
||||
type EditablePreset = Partial<Preset> & {
|
||||
|
|
@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
|
|||
|
||||
export const usePresetEditor = () => {
|
||||
const presetsStore = usePresets();
|
||||
const dialog = useDialog();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const reference = ref<number | null>(null);
|
||||
const preset = ref<Partial<Preset>>(makeEmptyPreset());
|
||||
const initialSnapshot = ref('');
|
||||
const dirty = ref(false);
|
||||
const sessionId = ref(0);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
dirty.value = false;
|
||||
reference.value = null;
|
||||
preset.value = makeEmptyPreset();
|
||||
};
|
||||
|
||||
const {
|
||||
isDirty,
|
||||
requestClose: requestCloseGuard,
|
||||
handleOpenChange,
|
||||
} = useDirtyCloseGuard(isOpen, {
|
||||
dirty,
|
||||
message: 'You have unsaved preset changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add';
|
||||
});
|
||||
|
|
@ -53,39 +71,27 @@ export const usePresetEditor = () => {
|
|||
const modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
|
||||
|
||||
const reset = (): void => {
|
||||
reference.value = null;
|
||||
preset.value = makeEmptyPreset();
|
||||
initialSnapshot.value = '';
|
||||
discardEditor();
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
dirty.value = false;
|
||||
isOpen.value = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const snapshot = (item: Partial<Preset>): string =>
|
||||
JSON.stringify(sanitizePreset(item as Preset));
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!isOpen.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return snapshot(preset.value) !== initialSnapshot.value;
|
||||
});
|
||||
|
||||
const openCreate = (): void => {
|
||||
reset();
|
||||
dirty.value = false;
|
||||
sessionId.value += 1;
|
||||
initialSnapshot.value = snapshot(preset.value);
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (item: Preset | EditablePreset): void => {
|
||||
dirty.value = false;
|
||||
reference.value = item.id ?? null;
|
||||
preset.value = sanitizePreset(item);
|
||||
sessionId.value += 1;
|
||||
initialSnapshot.value = snapshot(preset.value);
|
||||
isOpen.value = true;
|
||||
};
|
||||
|
||||
|
|
@ -94,22 +100,7 @@ export const usePresetEditor = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isDirty.value) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: 'Discard changes?',
|
||||
message: 'You have unsaved changes. Close the preset editor and discard them?',
|
||||
confirmText: 'Discard',
|
||||
cancelText: 'Keep editing',
|
||||
confirmColor: 'warning',
|
||||
});
|
||||
|
||||
if (status === true) {
|
||||
close();
|
||||
}
|
||||
await requestCloseGuard();
|
||||
};
|
||||
|
||||
const submit = async ({
|
||||
|
|
@ -144,10 +135,12 @@ export const usePresetEditor = () => {
|
|||
modalTitle,
|
||||
modalDescription,
|
||||
isDirty,
|
||||
dirty,
|
||||
addInProgress: presetsStore.addInProgress,
|
||||
openCreate,
|
||||
openEdit,
|
||||
close,
|
||||
handleOpenChange,
|
||||
requestClose,
|
||||
reset,
|
||||
submit,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||
|
||||
import { useYtpConfig } from '~/composables/useYtpConfig';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { prettyName } from '~/utils';
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export const usePresetOptions = (
|
|||
source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>,
|
||||
options: UsePresetOptionsOptions = {},
|
||||
) => {
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
|
||||
const presets = computed<Preset[]>(() => {
|
||||
const items = source ? toValue(source) : config.presets;
|
||||
|
|
|
|||
243
ui/app/composables/useQueueState.ts
Normal file
243
ui/app/composables/useQueueState.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { proxyRefs, reactive, toRefs } from 'vue';
|
||||
import type { item_request } from '~/types/item';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import { request } from '~/utils';
|
||||
|
||||
type KeyType = string;
|
||||
|
||||
interface QueueState {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
}
|
||||
|
||||
const state = reactive<QueueState>({
|
||||
queue: {},
|
||||
});
|
||||
|
||||
const add = (key: KeyType, value: StoreItem): void => {
|
||||
state.queue[key] = value;
|
||||
};
|
||||
|
||||
const update = (key: KeyType, value: StoreItem): void => {
|
||||
state.queue[key] = value;
|
||||
};
|
||||
|
||||
const remove = (key: KeyType): void => {
|
||||
if (!state.queue[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { [key]: _, ...rest } = state.queue;
|
||||
state.queue = rest;
|
||||
};
|
||||
|
||||
const get = (key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
|
||||
return state.queue[key] || defaultValue;
|
||||
};
|
||||
|
||||
const has = (key: KeyType): boolean => {
|
||||
return !!state.queue[key];
|
||||
};
|
||||
|
||||
const clearAll = (): void => {
|
||||
state.queue = {};
|
||||
};
|
||||
|
||||
const addAll = (data: Record<KeyType, StoreItem>): void => {
|
||||
state.queue = data;
|
||||
};
|
||||
|
||||
const count = (): number => {
|
||||
return Object.keys(state.queue).length;
|
||||
};
|
||||
|
||||
const loadQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await request('/api/history/live');
|
||||
const data = (await response.json()) as {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
};
|
||||
|
||||
state.queue = data.queue || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to load queue:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const addDownload = async (data: item_request): Promise<void> => {
|
||||
const socket = useAppSocket();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
socket.emit('add_url', data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to add download');
|
||||
throw new Error(error.error || 'Failed to add download');
|
||||
}
|
||||
|
||||
toast.success('Download added successfully');
|
||||
await loadQueue();
|
||||
} catch (error) {
|
||||
console.error('Failed to add download:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to add download')) {
|
||||
toast.error('Failed to add download');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useAppSocket();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_start', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to start items');
|
||||
throw new Error(error.error || 'Failed to start items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('started' === result[id]) {
|
||||
const item = get(id);
|
||||
if (item) {
|
||||
update(id, { ...item, auto_start: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to start items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to start items')) {
|
||||
toast.error('Failed to start items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const pauseItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useAppSocket();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_pause', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/pause', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to pause items');
|
||||
throw new Error(error.error || 'Failed to pause items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('paused' === result[id]) {
|
||||
const item = get(id);
|
||||
if (item) {
|
||||
update(id, { ...item, auto_start: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
|
||||
toast.error('Failed to pause items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useAppSocket();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_cancel', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to cancel items');
|
||||
throw new Error(error.error || 'Failed to cancel items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('ok' === result[id]) {
|
||||
remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
|
||||
toast.error('Failed to cancel items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const queueStateApi = proxyRefs({
|
||||
...toRefs(state),
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
get,
|
||||
has,
|
||||
clearAll,
|
||||
addAll,
|
||||
count,
|
||||
loadQueue,
|
||||
addDownload,
|
||||
startItems,
|
||||
pauseItems,
|
||||
cancelItems,
|
||||
});
|
||||
|
||||
export const useQueueState = () => queueStateApi;
|
||||
219
ui/app/composables/useYtpConfig.ts
Normal file
219
ui/app/composables/useYtpConfig.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { proxyRefs, reactive, toRefs } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { ConfigState } from '~/types/config';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
|
||||
import { useQueueState } from '~/composables/useQueueState';
|
||||
import { request } from '~/utils';
|
||||
|
||||
let last_reload = 0;
|
||||
const CONFIG_TTL = 10;
|
||||
|
||||
const state = reactive<ConfigState>({
|
||||
showForm: useStorage('showForm', true),
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
remove_files: false,
|
||||
ui_update_title: true,
|
||||
output_template: '',
|
||||
ytdlp_version: '',
|
||||
max_workers: 20,
|
||||
max_workers_per_extractor: 2,
|
||||
default_preset: 'default',
|
||||
instance_title: null,
|
||||
console_enabled: false,
|
||||
browser_control_enabled: false,
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
app_version: '',
|
||||
app_commit_sha: '',
|
||||
app_build_date: '',
|
||||
app_branch: '',
|
||||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
yt_new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Default preset',
|
||||
folder: '',
|
||||
template: '',
|
||||
cookies: '',
|
||||
cli: '',
|
||||
default: true,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
is_loading: false,
|
||||
});
|
||||
|
||||
const loadConfig = async (force: boolean = false) => {
|
||||
if (state.is_loading) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (state.is_loaded && !force && last_reload > 0) {
|
||||
const age = (now - last_reload) / 1000;
|
||||
if (age < CONFIG_TTL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loaded = false;
|
||||
state.is_loading = true;
|
||||
try {
|
||||
const resp = await request('/api/system/configuration', { timeout: 10 });
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const queueState = useQueueState();
|
||||
|
||||
if ('number' === typeof data.history_count) {
|
||||
delete data.history_count;
|
||||
}
|
||||
|
||||
if (data.queue) {
|
||||
queueState.addAll(data.queue);
|
||||
delete data.queue;
|
||||
}
|
||||
|
||||
setAll(data);
|
||||
state.is_loaded = true;
|
||||
last_reload = now;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to load configuration: ${e}`);
|
||||
} finally {
|
||||
state.is_loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = (key: string, value: any) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
state[parentKey][subKey] = value;
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = value;
|
||||
};
|
||||
|
||||
const get = (key: string, defaultValue: any = null): any => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
return parent?.[subKey] ?? defaultValue;
|
||||
}
|
||||
return (state as any)[key] ?? defaultValue;
|
||||
};
|
||||
|
||||
const isLoaded = () => state.is_loaded;
|
||||
|
||||
const update = add;
|
||||
|
||||
const getAll = (): ConfigState => state;
|
||||
|
||||
const setAll = (data: Record<string, any>) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
parent[subKey] = data[key];
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = data[key];
|
||||
});
|
||||
|
||||
state.is_loaded = true;
|
||||
};
|
||||
|
||||
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
||||
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
|
||||
|
||||
if (!supportedFeatures.includes(feature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('presets' === feature) {
|
||||
const item = data as Preset;
|
||||
const current = get(feature, []) as Array<Preset>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('dl_fields' === feature) {
|
||||
const item = data as DLField;
|
||||
const current = get(feature, []) as Array<DLField>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('replace' === action) {
|
||||
state.dl_fields = data as Array<DLField>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ytpConfigApi = proxyRefs({
|
||||
...toRefs(state),
|
||||
add,
|
||||
get,
|
||||
update,
|
||||
getAll,
|
||||
setAll,
|
||||
isLoaded,
|
||||
patch,
|
||||
loadConfig,
|
||||
});
|
||||
|
||||
export const useYtpConfig = () => ytpConfigApi;
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<UApp :toaster="toasterConfig">
|
||||
<Transition name="shell-mode" mode="out-in">
|
||||
<div v-if="simpleMode" key="simple" class="shell-stage flex flex-col">
|
||||
<div
|
||||
v-if="simpleMode"
|
||||
key="simple"
|
||||
class="shell-stage flex flex-col bg-default/95 backdrop-blur-sm"
|
||||
>
|
||||
<UAlert
|
||||
v-if="showConnectionBanner"
|
||||
color="warning"
|
||||
|
|
@ -25,14 +29,14 @@
|
|||
variant="link"
|
||||
size="sm"
|
||||
class="px-0"
|
||||
@click="() => socket.reconnect()"
|
||||
@click="socket.reconnect()"
|
||||
>
|
||||
Reconnect
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<Simple @show_settings="() => (show_settings = true)" />
|
||||
<Simple @show_settings="show_settings = true" />
|
||||
</div>
|
||||
|
||||
<div v-else key="regular" class="shell-stage flex flex-col">
|
||||
|
|
@ -64,6 +68,7 @@
|
|||
:ui="{ base: 'relative flex min-h-full overflow-visible' }"
|
||||
>
|
||||
<UDashboardSidebar
|
||||
v-model:open="showSidebar"
|
||||
side="left"
|
||||
collapsible
|
||||
resizable
|
||||
|
|
@ -169,19 +174,24 @@
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-refresh-cw"
|
||||
@click="reloadPage"
|
||||
@click="$router.go(0)"
|
||||
>
|
||||
<span class="hidden xl:inline">Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UDashboardSearchButton class="shrink-0" />
|
||||
|
||||
<UColorModeButton
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Toggle color mode"
|
||||
/>
|
||||
:icon="colorModeButtonIcon"
|
||||
:aria-label="colorModeButtonTitle"
|
||||
:title="colorModeButtonTitle"
|
||||
@click="colorMode.preference = nextColorModePreference"
|
||||
>
|
||||
<span class="hidden xl:inline">{{ colorModeButtonTitle }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
|
|
@ -248,7 +258,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
||||
@click="reloadPage"
|
||||
@click="$router.go(0)"
|
||||
>
|
||||
click here
|
||||
</button>
|
||||
|
|
@ -261,7 +271,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
||||
@click="() => config.loadConfig(true)"
|
||||
@click="config.loadConfig(true)"
|
||||
>
|
||||
reload configuration
|
||||
</button>
|
||||
|
|
@ -269,7 +279,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="font-semibold text-highlighted underline-offset-2 hover:underline"
|
||||
@click="reloadPage"
|
||||
@click="$router.go(0)"
|
||||
>
|
||||
reload the page
|
||||
</button>
|
||||
|
|
@ -318,7 +328,7 @@
|
|||
variant="link"
|
||||
size="sm"
|
||||
class="px-0"
|
||||
@click="() => socket.reconnect()"
|
||||
@click="socket.reconnect()"
|
||||
>
|
||||
Reconnect
|
||||
</UButton>
|
||||
|
|
@ -329,7 +339,7 @@
|
|||
v-if="config.is_loaded"
|
||||
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col"
|
||||
>
|
||||
<NuxtPage :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||
<NuxtPage :isLoading="loadingImage" @reload_bg="loadImage(true)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -473,8 +483,8 @@
|
|||
<SettingsPanel
|
||||
:isOpen="show_settings"
|
||||
:isLoading="loadingImage"
|
||||
@close="closeSettings()"
|
||||
@reload_bg="() => loadImage(true)"
|
||||
@close="show_settings = false"
|
||||
@reload_bg="loadImage(true)"
|
||||
direction="right"
|
||||
/>
|
||||
</UApp>
|
||||
|
|
@ -482,26 +492,24 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||
import { ref, onMounted, readonly } from 'vue';
|
||||
import { ref, onBeforeUnmount, onMounted, readonly } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import moment from 'moment';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
import type { toastPosition } from '~/composables/useNotification';
|
||||
import { getDocsNavigationEntries } from '~/composables/useDocs';
|
||||
import type { YTDLPOption } from '~/types/ytdlp';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import Dialog from '~/components/Dialog.vue';
|
||||
import Simple from '~/components/Simple.vue';
|
||||
import Shutdown from '~/components/shutdown.vue';
|
||||
import type { version_check } from '~/types';
|
||||
|
||||
type NavEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
to?: string;
|
||||
children?: NavEntry[];
|
||||
};
|
||||
import {
|
||||
getActiveNavItem,
|
||||
getNavItems,
|
||||
getNavSections,
|
||||
isNavItemActive,
|
||||
type NavItem,
|
||||
} from '~/utils/topLevelNavigation';
|
||||
|
||||
type SidebarSection = {
|
||||
id: string;
|
||||
|
|
@ -509,9 +517,16 @@ type SidebarSection = {
|
|||
items: Array<Array<NavigationMenuItem>>;
|
||||
};
|
||||
|
||||
const socket = useSocketStore();
|
||||
const config = useConfigStore();
|
||||
type ColorModePreference = 'system' | 'light' | 'dark';
|
||||
type SwipeMode = 'open' | 'close';
|
||||
|
||||
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
|
||||
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
|
||||
|
||||
const socket = useAppSocket();
|
||||
const config = useYtpConfig();
|
||||
const route = useRoute();
|
||||
const colorMode = useColorMode();
|
||||
const loadedImage = ref();
|
||||
const loadingImage = ref(false);
|
||||
const bg_enable = useStorage('random_bg', true);
|
||||
|
|
@ -522,7 +537,148 @@ const show_settings = ref(false);
|
|||
const checkingUpdates = ref(false);
|
||||
const updateCheckMessage = ref('Up to date - Click to check');
|
||||
const showRouteSearch = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
const { alertDialog, confirmDialog } = useDialog();
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 1023px)' });
|
||||
|
||||
const SwipeState = {
|
||||
mode: null as SwipeMode | null,
|
||||
tracking: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
endX: 0,
|
||||
endY: 0,
|
||||
};
|
||||
|
||||
const colorModePreferences: Array<ColorModePreference> = ['system', 'light', 'dark'];
|
||||
|
||||
const colorModePreference = computed<ColorModePreference>(() => {
|
||||
const preference = colorMode.preference;
|
||||
return colorModePreferences.includes(preference as ColorModePreference)
|
||||
? (preference as ColorModePreference)
|
||||
: 'system';
|
||||
});
|
||||
|
||||
const colorModeButtonIcon = computed(() => {
|
||||
switch (colorModePreference.value) {
|
||||
case 'light':
|
||||
return 'i-lucide-sun';
|
||||
case 'dark':
|
||||
return 'i-lucide-moon';
|
||||
default:
|
||||
return 'i-lucide-monitor';
|
||||
}
|
||||
});
|
||||
|
||||
const nextColorModePreference = computed<ColorModePreference>(() => {
|
||||
const currentIndex = colorModePreferences.indexOf(colorModePreference.value);
|
||||
return colorModePreferences[(currentIndex + 1) % colorModePreferences.length] ?? 'system';
|
||||
});
|
||||
|
||||
const colorModeButtonTitle = computed(() => {
|
||||
switch (colorModePreference.value) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
default:
|
||||
return 'System';
|
||||
}
|
||||
});
|
||||
|
||||
const resetSwipe = (): void => {
|
||||
SwipeState.mode = null;
|
||||
SwipeState.tracking = false;
|
||||
SwipeState.startX = 0;
|
||||
SwipeState.startY = 0;
|
||||
SwipeState.endX = 0;
|
||||
SwipeState.endY = 0;
|
||||
};
|
||||
|
||||
const updateSwipePosition = (touch?: Touch): void => {
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
SwipeState.endX = touch.clientX;
|
||||
SwipeState.endY = touch.clientY;
|
||||
};
|
||||
|
||||
const handleSwipeStart = (event: TouchEvent): void => {
|
||||
if (!isMobile.value || event.touches.length !== 1) {
|
||||
resetSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
|
||||
if (!touch) {
|
||||
resetSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
const swipeMode: SwipeMode | null = showSidebar.value
|
||||
? 'close'
|
||||
: touch.clientX <= MOBILE_SIDEBAR_EDGE_WIDTH
|
||||
? 'open'
|
||||
: null;
|
||||
|
||||
if (!swipeMode) {
|
||||
resetSwipe();
|
||||
return;
|
||||
}
|
||||
|
||||
SwipeState.mode = swipeMode;
|
||||
SwipeState.tracking = true;
|
||||
SwipeState.startX = touch.clientX;
|
||||
SwipeState.startY = touch.clientY;
|
||||
updateSwipePosition(touch);
|
||||
};
|
||||
|
||||
const handleSwipeMove = (event: TouchEvent): void => {
|
||||
if (!SwipeState.tracking || event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSwipePosition(event.touches[0]);
|
||||
};
|
||||
|
||||
const completeSwipe = (): void => {
|
||||
if (!SwipeState.tracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swipeMode = SwipeState.mode;
|
||||
const deltaX = SwipeState.endX - SwipeState.startX;
|
||||
const deltaY = SwipeState.endY - SwipeState.startY;
|
||||
const isHorizontalOpenSwipe =
|
||||
swipeMode === 'open' &&
|
||||
deltaX >= MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
|
||||
deltaX > Math.abs(deltaY);
|
||||
const isHorizontalCloseSwipe =
|
||||
swipeMode === 'close' &&
|
||||
deltaX <= -MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
|
||||
Math.abs(deltaX) > Math.abs(deltaY);
|
||||
|
||||
resetSwipe();
|
||||
|
||||
if (isHorizontalOpenSwipe) {
|
||||
showSidebar.value = true;
|
||||
}
|
||||
|
||||
if (isHorizontalCloseSwipe) {
|
||||
showSidebar.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipeEnd = (event: TouchEvent): void => {
|
||||
updateSwipePosition(event.changedTouches[0]);
|
||||
completeSwipe();
|
||||
};
|
||||
|
||||
const handleSwipeCancel = (): void => {
|
||||
resetSwipe();
|
||||
};
|
||||
|
||||
const dashboardSidebarUi = {
|
||||
root: 'border-r border-default bg-default/95 backdrop-blur-sm',
|
||||
|
|
@ -552,134 +708,51 @@ const navigationUi = (collapsed: boolean) => ({
|
|||
linkLabel: collapsed ? 'hidden' : 'truncate',
|
||||
});
|
||||
|
||||
const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({
|
||||
const makeNavigationItem = (item: NavItem): NavigationMenuItem => ({
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
to: item.to,
|
||||
value: item.id,
|
||||
active: isNavItemActive(item, route),
|
||||
});
|
||||
|
||||
const docsNavigationEntries = getDocsNavigationEntries();
|
||||
const navigationAvailability = computed(() => ({
|
||||
fileLogging: Boolean(config.app?.file_logging),
|
||||
consoleEnabled: Boolean(config.app?.console_enabled),
|
||||
}));
|
||||
|
||||
const allNavItems = computed<NavEntry[]>(() => [
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
description: 'Queued and completed downloads list.',
|
||||
icon: 'i-lucide-download',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
description: 'Browse downloaded files.',
|
||||
icon: 'i-lucide-folder-tree',
|
||||
to: '/browser',
|
||||
},
|
||||
{
|
||||
id: 'presets',
|
||||
label: 'Presets',
|
||||
description:
|
||||
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.',
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
to: '/presets',
|
||||
},
|
||||
{
|
||||
id: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
description: 'Custom fields allow you to add new fields to the download form.',
|
||||
icon: 'i-lucide-braces',
|
||||
to: '/dl_fields',
|
||||
},
|
||||
{
|
||||
id: 'conditions',
|
||||
label: 'Conditions',
|
||||
description: 'Run yt-dlp custom match filter on returned info and apply options.',
|
||||
icon: 'i-lucide-filter',
|
||||
to: '/conditions',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
description: 'Send notifications to your webhooks based on specified events or presets.',
|
||||
icon: 'i-lucide-bell',
|
||||
to: '/notifications',
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
label: 'Tasks',
|
||||
icon: 'i-lucide-list-todo',
|
||||
children: [
|
||||
{
|
||||
id: 'tasks-list',
|
||||
label: 'Tasks',
|
||||
description: 'Queue playlist/channels for automatic download at specified intervals.',
|
||||
icon: 'i-lucide-list-todo',
|
||||
to: '/tasks',
|
||||
},
|
||||
{
|
||||
id: 'task-definitions',
|
||||
label: 'Task Definitions',
|
||||
description: 'Create definitions to turn any website into a downloadable feed of links.',
|
||||
icon: 'i-lucide-workflow',
|
||||
to: '/task_definitions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: 'Tools',
|
||||
icon: 'i-lucide-wrench',
|
||||
children: [
|
||||
...(config.app?.file_logging
|
||||
? [
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Logs',
|
||||
description: 'Scroll near the top to load older logs.',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/logs',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(config.app.console_enabled
|
||||
? [
|
||||
{
|
||||
id: 'console',
|
||||
label: 'Console',
|
||||
description: 'Run yt-dlp commands directly in a non-interactive session.',
|
||||
icon: 'i-lucide-terminal',
|
||||
to: '/console',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
icon: 'i-lucide-book-open',
|
||||
children: [
|
||||
...docsNavigationEntries,
|
||||
{
|
||||
id: 'changelog',
|
||||
label: 'Changelog',
|
||||
description:
|
||||
'Latest project changes, loaded remotely when available and falling back to the bundled changelog file.',
|
||||
icon: 'i-lucide-list',
|
||||
to: '/changelog',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const navItems = computed(() => getNavItems(navigationAvailability.value));
|
||||
|
||||
const groupSectionEntries = (entries: Array<NavItem>): Array<Array<NavItem>> => {
|
||||
const order = [...new Set(entries.map((entry) => entry.group))];
|
||||
return order.map((group) => entries.filter((entry) => entry.group === group));
|
||||
};
|
||||
|
||||
const sidebarItems = computed<
|
||||
Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
items: Array<Array<NavItem>>;
|
||||
}>
|
||||
>(() => {
|
||||
return getNavSections()
|
||||
.map((section) => {
|
||||
const sectionEntries = navItems.value.filter(
|
||||
(entry) => entry.section === section.id && entry.sidebarVisible !== false,
|
||||
);
|
||||
|
||||
return {
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
items: groupSectionEntries(sectionEntries),
|
||||
};
|
||||
})
|
||||
.filter((section) => section.items.some((group) => group.length > 0));
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const path = route.path;
|
||||
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]);
|
||||
const match = flat
|
||||
.filter((item) => item.to && (item.to === '/' ? path === '/' : path.startsWith(item.to)))
|
||||
.sort((left, right) => (right.to?.length || 0) - (left.to?.length || 0))[0];
|
||||
return match?.label || 'YTPTube';
|
||||
const match = getActiveNavItem(route, navigationAvailability.value);
|
||||
return match?.navbarTitle || match?.label || 'YTPTube';
|
||||
});
|
||||
|
||||
const buildTooltip = computed(
|
||||
|
|
@ -703,81 +776,30 @@ const connectionBannerIcon = computed(() =>
|
|||
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
|
||||
);
|
||||
|
||||
const sidebarSections = computed<Array<SidebarSection>>(() => {
|
||||
const topLevelItems = (ids: string[]) =>
|
||||
ids
|
||||
.map((id) => allNavItems.value.find((item) => item.id === id))
|
||||
.filter((item): item is NavEntry => Boolean(item))
|
||||
.map((item) => makeNavigationItem(item));
|
||||
|
||||
const childItems = (id: string) =>
|
||||
(allNavItems.value.find((item) => item.id === id)?.children || []).map((item) =>
|
||||
makeNavigationItem(item),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
items:
|
||||
topLevelItems(['downloads', 'files']).length > 0
|
||||
? [topLevelItems(['downloads', 'files'])]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
items: childItems('tasks').length > 0 ? [childItems('tasks')] : [],
|
||||
},
|
||||
{
|
||||
id: 'configuration',
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
topLevelItems(['presets', 'custom-fields']),
|
||||
topLevelItems(['conditions', 'notifications']),
|
||||
].filter((group) => group.length > 0),
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: 'Tools',
|
||||
items: childItems('tools').length > 0 ? [childItems('tools')] : [],
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
items: childItems('docs').length > 0 ? [childItems('docs')] : [],
|
||||
},
|
||||
].filter((section) => section.items.some((group) => group.length > 0));
|
||||
});
|
||||
const sidebarSections = computed<Array<SidebarSection>>(() =>
|
||||
sidebarItems.value.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
|
||||
})),
|
||||
);
|
||||
|
||||
const routeSearchGroups = computed(() => [
|
||||
{
|
||||
id: 'routes',
|
||||
label: 'Routes',
|
||||
items: allNavItems.value.flatMap((item) => {
|
||||
const self = item.to
|
||||
? [
|
||||
{
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
icon: item.icon,
|
||||
suffix: item.to,
|
||||
onSelect: () => handleRouteSelect(item),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const children = (item.children || []).map((child) => ({
|
||||
label: child.label,
|
||||
description: child.description,
|
||||
icon: child.icon,
|
||||
suffix: child.to || '',
|
||||
onSelect: () => handleRouteSelect(child),
|
||||
}));
|
||||
|
||||
return [...self, ...children];
|
||||
}),
|
||||
},
|
||||
...sidebarItems.value
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
items: section.items
|
||||
.flat()
|
||||
.filter((entry) => entry.searchable !== false)
|
||||
.map((entry) => ({
|
||||
label: entry.label,
|
||||
description: entry.description,
|
||||
icon: entry.icon,
|
||||
suffix: entry.to,
|
||||
onSelect: () => handleRouteSelect(entry),
|
||||
})),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0),
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
|
|
@ -840,7 +862,7 @@ const syncShellModeClass = () => {
|
|||
html.classList.toggle('simple-mode', simpleMode.value);
|
||||
};
|
||||
|
||||
const handleRouteSelect = async (item: NavEntry) => {
|
||||
const handleRouteSelect = async (item: NavItem) => {
|
||||
await closeRouteSearch();
|
||||
|
||||
if (item.to) {
|
||||
|
|
@ -899,6 +921,22 @@ const checkForUpdates = async () => {
|
|||
};
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('touchstart', handleSwipeStart, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener('touchmove', handleSwipeMove, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener('touchend', handleSwipeEnd, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener('touchcancel', handleSwipeCancel, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
syncShellModeClass();
|
||||
|
||||
try {
|
||||
|
|
@ -921,13 +959,29 @@ onMounted(async () => {
|
|||
} catch {}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('touchstart', handleSwipeStart, true);
|
||||
document.removeEventListener('touchmove', handleSwipeMove, true);
|
||||
document.removeEventListener('touchend', handleSwipeEnd, true);
|
||||
document.removeEventListener('touchcancel', handleSwipeCancel, true);
|
||||
});
|
||||
|
||||
watch(bg_enable, async (v) => await handleImage(v));
|
||||
watch(simpleMode, () => syncShellModeClass());
|
||||
watch(bg_opacity, (v) => {
|
||||
watch(isMobile, (v) => {
|
||||
if (v) {
|
||||
return;
|
||||
}
|
||||
|
||||
showSidebar.value = false;
|
||||
resetSwipe();
|
||||
});
|
||||
watch(bg_opacity, () => {
|
||||
if (false === bg_enable.value) {
|
||||
return;
|
||||
}
|
||||
document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
|
||||
|
||||
syncOpacity();
|
||||
});
|
||||
|
||||
watch(loadedImage, () => {
|
||||
|
|
@ -936,7 +990,6 @@ watch(loadedImage, () => {
|
|||
}
|
||||
|
||||
const html = document.documentElement;
|
||||
const body = document.querySelector('body');
|
||||
|
||||
const style = {
|
||||
'background-color': 'unset',
|
||||
|
|
@ -954,7 +1007,7 @@ watch(loadedImage, () => {
|
|||
.trim(),
|
||||
);
|
||||
html.classList.add('bg-fanart');
|
||||
body?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
||||
syncOpacity();
|
||||
});
|
||||
|
||||
const handleImage = async (enabled: boolean) => {
|
||||
|
|
@ -1025,8 +1078,6 @@ const useVersionUpdate = () => {
|
|||
|
||||
const { newVersionIsAvailable } = useVersionUpdate();
|
||||
|
||||
const closeSettings = () => (show_settings.value = false);
|
||||
|
||||
const shutdownApp = async () => {
|
||||
if (false === config.app.is_native) {
|
||||
await alertDialog({
|
||||
|
|
@ -1065,8 +1116,6 @@ const shutdownApp = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
|
||||
const connectionStatusColor = computed(() => {
|
||||
switch (socket.connectionStatus) {
|
||||
case 'connected':
|
||||
|
|
@ -1111,6 +1160,8 @@ const toasterConfig = computed(() => ({
|
|||
expand: true,
|
||||
progress: true,
|
||||
}));
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,50 @@
|
|||
<template>
|
||||
<div class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-toned">
|
||||
<nav aria-label="Breadcrumb" class="min-w-0">
|
||||
<ol class="flex flex-wrap items-center gap-1.5">
|
||||
<template v-for="(item, index) in breadcrumbItems" :key="item.path">
|
||||
<li v-if="index > 0" aria-hidden="true" class="text-muted">/</li>
|
||||
<li>
|
||||
<a
|
||||
v-if="index !== breadcrumbItems.length - 1"
|
||||
:href="buildStateUrl(item.path)"
|
||||
class="rounded px-1 py-0.5 text-left text-default hover:text-highlighted"
|
||||
@click="handleBreadcrumbClick($event, item.path)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<span v-else class="rounded px-1 py-0.5 font-medium text-highlighted">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
<div class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-3">
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
class="flex min-w-0 flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
|
||||
<template v-for="item in breadcrumbTrailItems" :key="item.path">
|
||||
<span>/</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="max-w-full truncate normal-case tracking-normal transition hover:text-highlighted"
|
||||
@click="() => void reloadContent(item.path)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<UIcon
|
||||
v-if="isLoading"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-4 animate-spin text-info"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<UIcon
|
||||
v-if="isLoading"
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-4 animate-spin text-info"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="truncate text-2xl font-semibold text-highlighted">
|
||||
{{ currentDirectoryName }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
{{ browserPath }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="show_filter" class="relative w-full sm:w-72">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="search"
|
||||
ref="searchInput"
|
||||
v-model.lazy="localSearch"
|
||||
type="search"
|
||||
placeholder="Filter"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
:variant="show_filter ? 'soft' : 'outline'"
|
||||
|
|
@ -60,7 +52,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilter"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -71,7 +63,7 @@
|
|||
icon="i-lucide-folder-plus"
|
||||
@click="() => void handleCreateDirectory()"
|
||||
>
|
||||
<span v-if="!isMobile">New Folder</span>
|
||||
<span>New Folder</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -79,9 +71,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false">
|
||||
|
|
@ -92,7 +85,7 @@
|
|||
icon="i-lucide-arrow-up-down"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
<span v-if="!isMobile">Sort</span>
|
||||
<span>Sort</span>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
|
|
@ -105,8 +98,20 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent(browserPath)"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="show_filter"
|
||||
id="search"
|
||||
ref="searchInput"
|
||||
v-model.lazy="localSearch"
|
||||
type="search"
|
||||
placeholder="Filter"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -119,9 +124,9 @@
|
|||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-check'"
|
||||
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
:disabled="isLoading || filteredItems.length < 1"
|
||||
@click="masterSelectAll = !masterSelectAll"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ masterSelectAll ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
|
@ -130,60 +135,56 @@
|
|||
{{ selectedElms.length }}
|
||||
</UBadge>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-arrow-right-left"
|
||||
:disabled="!hasSelected || isLoading"
|
||||
@click="() => void handleMoveSelected()"
|
||||
>
|
||||
Move
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
:disabled="!hasSelected || isLoading"
|
||||
@click="() => void handleDeleteSelected()"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Page {{ pagination.page }} of {{ pagination.total_pages || 1 }}
|
||||
</p>
|
||||
<UPagination
|
||||
v-if="pagination.total_pages > 1"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:items-per-page="pagination.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="handlePageChange"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
v-if="pagination.total_pages > 1"
|
||||
:page="pagination.page"
|
||||
:last_page="pagination.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="handlePageChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="display_style === 'list' && hasItems"
|
||||
v-if="contentStyle === 'list' && hasItems"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-345 w-full text-sm">
|
||||
<table class="min-w-360 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<th v-if="controlEnabled" class="w-16">Select</th>
|
||||
<th :class="controlEnabled ? 'w-20' : 'w-24'">
|
||||
#
|
||||
<UIcon
|
||||
v-if="sort_by === 'type'"
|
||||
:name="sortDirectionIcon"
|
||||
class="ml-1 inline-flex size-3.5"
|
||||
/>
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th v-if="controlEnabled" class="w-16">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer"
|
||||
:aria-label="masterSelectAll ? 'Unselect all items' : 'Select all items'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
<UIcon
|
||||
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<th class="w-full text-left">
|
||||
Name
|
||||
<UIcon
|
||||
v-if="sort_by === 'name'"
|
||||
|
|
@ -207,12 +208,16 @@
|
|||
class="ml-1 inline-flex size-3.5"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="controlEnabled" class="w-80 whitespace-nowrap">Actions</th>
|
||||
<th v-if="controlEnabled" class="w-96 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="item in filteredItems" :key="item.path" class="hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="item in filteredItems"
|
||||
:key="item.path"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td v-if="controlEnabled" class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
|
|
@ -224,16 +229,14 @@
|
|||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<UTooltip :text="item.name">
|
||||
<span class="inline-flex items-center justify-center text-toned">
|
||||
<UIcon :name="itemTypeIcon(item)" class="size-6" />
|
||||
</span>
|
||||
</UTooltip>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<UTooltip :text="itemTypeLabel(item)">
|
||||
<span class="inline-flex shrink-0 items-center justify-center text-toned">
|
||||
<UIcon :name="itemTypeIcon(item)" class="size-5" />
|
||||
</span>
|
||||
</UTooltip>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<UTooltip :text="item.name">
|
||||
<a
|
||||
|
|
@ -245,19 +248,6 @@
|
|||
</a>
|
||||
</UTooltip>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="item.type === 'file'"
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-download"
|
||||
class="shrink-0"
|
||||
external
|
||||
:href="downloadHref(item)"
|
||||
:download="downloadName(item)"
|
||||
square
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
|
|
@ -271,10 +261,23 @@
|
|||
</UTooltip>
|
||||
</td>
|
||||
|
||||
<td v-if="controlEnabled" class="w-80 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td v-if="controlEnabled" class="w-96 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="warning"
|
||||
v-if="item.type === 'file'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-download"
|
||||
class="shrink-0"
|
||||
external
|
||||
:href="downloadHref(item)"
|
||||
:download="downloadName(item)"
|
||||
>
|
||||
Download
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
|
|
@ -284,7 +287,7 @@
|
|||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-arrow-right-left"
|
||||
|
|
@ -294,7 +297,7 @@
|
|||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
|
|
@ -315,7 +318,11 @@
|
|||
v-for="item in filteredItems"
|
||||
:key="item.path"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
|
|
@ -340,18 +347,6 @@
|
|||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<UButton
|
||||
v-if="item.type === 'file'"
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-download"
|
||||
external
|
||||
:href="downloadHref(item)"
|
||||
:download="downloadName(item)"
|
||||
square
|
||||
/>
|
||||
|
||||
<label v-if="controlEnabled" class="inline-flex cursor-pointer items-center px-1">
|
||||
<input
|
||||
v-model="selectedElms"
|
||||
|
|
@ -386,40 +381,56 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="controlEnabled" class="mt-auto flex flex-wrap gap-2 pt-1 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('rename', item)"
|
||||
>
|
||||
Rename
|
||||
</UButton>
|
||||
<template v-if="controlEnabled" #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
v-if="item.type === 'file'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-download"
|
||||
external
|
||||
:href="downloadHref(item)"
|
||||
:download="downloadName(item)"
|
||||
class="w-full justify-center"
|
||||
>
|
||||
Download
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-arrow-right-left"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('move', item)"
|
||||
>
|
||||
Move
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('rename', item)"
|
||||
>
|
||||
Rename
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('delete', item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-arrow-right-left"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('move', item)"
|
||||
>
|
||||
Move
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void handleAction('delete', item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
|
|
@ -431,10 +442,6 @@
|
|||
title="No results"
|
||||
:description="`No results found for '${localSearch}'.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="clearFilter"
|
||||
>Clear filter</UButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -464,13 +471,18 @@
|
|||
description="You can enable rename, delete, move, and create directory controls by setting YTP_BROWSER_CONTROL_ENABLED=true and restarting the application."
|
||||
/>
|
||||
|
||||
<Pager
|
||||
v-if="pagination.total_pages > 1"
|
||||
:page="pagination.page"
|
||||
:last_page="pagination.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="handlePageChange"
|
||||
/>
|
||||
<div v-if="pagination.total_pages > 1" class="flex justify-end">
|
||||
<UPagination
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:items-per-page="pagination.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="handlePageChange"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal
|
||||
v-if="model_item"
|
||||
|
|
@ -515,18 +527,19 @@ import moment from 'moment';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import type { FileItem } from '~/types/filebrowser';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const dialog = useDialog();
|
||||
const browser = useBrowser();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
const display_style = useStorage<string>('browser_display_style', 'list');
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
const show_filter = ref(false);
|
||||
const localSearch = ref('');
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const searchInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
|
||||
const items = browser.items;
|
||||
const browserPath = browser.path;
|
||||
|
|
@ -539,9 +552,17 @@ const sort_order = browser.sort_order;
|
|||
const filteredItems = browser.filteredItems;
|
||||
|
||||
const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled));
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
|
||||
);
|
||||
const pageShell = requirePageShell('files');
|
||||
const hasItems = computed(() => filteredItems.value.length > 0);
|
||||
const hasSelected = computed(() => selectedElms.value.length > 0);
|
||||
const displayedItemPaths = computed(() => filteredItems.value.map((item) => item.path));
|
||||
const currentDirectoryName = computed(
|
||||
() => breadcrumbItems.value[breadcrumbItems.value.length - 1]?.name || 'Home',
|
||||
);
|
||||
const breadcrumbTrailItems = computed(() => breadcrumbItems.value.slice(0, -1));
|
||||
const sortDirectionIcon = computed(() =>
|
||||
sort_order.value === 'asc' ? 'i-lucide-arrow-down' : 'i-lucide-arrow-up',
|
||||
);
|
||||
|
|
@ -567,6 +588,25 @@ const sortGroups = computed<DropdownMenuItem[][]>(() => [
|
|||
})),
|
||||
]);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Move Selected',
|
||||
icon: 'i-lucide-arrow-right-left',
|
||||
color: 'primary',
|
||||
disabled: !hasSelected.value || isLoading.value,
|
||||
onSelect: () => void handleMoveSelected(),
|
||||
},
|
||||
{
|
||||
label: 'Delete Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || isLoading.value,
|
||||
onSelect: () => void handleDeleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const initialPath = (() => {
|
||||
const slug = route.params.slug;
|
||||
if (Array.isArray(slug) && slug.length > 0) {
|
||||
|
|
@ -696,19 +736,6 @@ const clearFilter = (): void => {
|
|||
show_filter.value = false;
|
||||
};
|
||||
|
||||
const isPlainLeftClick = (event: MouseEvent): boolean => {
|
||||
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (event: MouseEvent, path: string): void => {
|
||||
if (!isPlainLeftClick(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void reloadContent(path);
|
||||
};
|
||||
|
||||
const itemHref = (item: FileItem): string => {
|
||||
return item.content_type === 'dir' ? uri(`/browser/${item.path}`) : downloadHref(item);
|
||||
};
|
||||
|
|
@ -892,15 +919,6 @@ const itemTypeLabel = (item: FileItem): string => {
|
|||
return item.type === 'file' ? 'File' : ucFirst(item.type);
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string): string => {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
};
|
||||
|
||||
const toggleFilter = async (): Promise<void> => {
|
||||
show_filter.value = !show_filter.value;
|
||||
|
||||
|
|
@ -910,13 +928,17 @@ const toggleFilter = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
searchInput.value?.focus();
|
||||
searchInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const toggleDisplayStyle = (): void => {
|
||||
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
masterSelectAll.value = !masterSelectAll.value;
|
||||
};
|
||||
|
||||
const handleCreateDirectory = async (): Promise<void> => {
|
||||
if (!controlEnabled.value) {
|
||||
return;
|
||||
|
|
@ -1014,8 +1036,9 @@ const handleDeleteSelected = async (): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
const rawHTML =
|
||||
'<ul>' +
|
||||
const message =
|
||||
'Delete the following items?' +
|
||||
'\n\n' +
|
||||
selectedElms.value
|
||||
.map((selectedPath) => {
|
||||
const item = items.value.find((entry) => entry.path === selectedPath);
|
||||
|
|
@ -1023,15 +1046,14 @@ const handleDeleteSelected = async (): Promise<void> => {
|
|||
return '';
|
||||
}
|
||||
|
||||
return `<li><strong>${escapeHtml(itemTypeLabel(item))}:</strong> ${escapeHtml(item.name)}</li>`;
|
||||
return `${itemTypeLabel(item)}: ${item.name}`;
|
||||
})
|
||||
.join('') +
|
||||
'</ul>';
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: 'Delete Confirmation',
|
||||
message: 'Delete the following items?',
|
||||
rawHTML,
|
||||
message,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
confirmColor: 'error',
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" />
|
||||
<span>CHANGELOG</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Latest project changes, loaded remotely when available and falling back to the bundled
|
||||
changelog file.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="toggleFilter && logs.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
|
||||
<input
|
||||
id="filter"
|
||||
v-model.lazy="query"
|
||||
type="search"
|
||||
placeholder="Filter changelog entries"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="logs.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -38,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilter = !toggleFilter"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<USwitch
|
||||
|
|
@ -48,6 +40,17 @@
|
|||
:label="latestOnly ? 'Latest Only' : 'All Loaded'"
|
||||
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
|
||||
/>
|
||||
|
||||
<UInput
|
||||
v-if="toggleFilter && logs.length > 0"
|
||||
id="filter"
|
||||
v-model.lazy="query"
|
||||
type="search"
|
||||
placeholder="Filter changelog entries"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -79,17 +82,25 @@
|
|||
Installed
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<p v-if="log.date" class="text-xs text-toned">
|
||||
<UTooltip :text="`Release Date: ${log.date}`">
|
||||
<span>{{ moment(log.date).fromNow() }}</span>
|
||||
</UTooltip>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UBadge color="neutral" variant="soft" size="sm">
|
||||
{{ log.commits?.length || 0 }} commits
|
||||
</UBadge>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list" class="size-3.5" />
|
||||
<span>{{ log.commits?.length || 0 }} commits</span>
|
||||
</span>
|
||||
|
||||
<UTooltip v-if="log.date" :text="`Release Date: ${log.date}`">
|
||||
<span
|
||||
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-calendar-days" class="size-3.5" />
|
||||
<span>{{ moment(log.date).fromNow() }}</span>
|
||||
</span>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 border-t border-default pt-4">
|
||||
|
|
@ -98,35 +109,48 @@
|
|||
:key="commit.sha"
|
||||
class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<p class="min-w-0 flex-1 text-sm text-default">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
:to="`${REPO}/commit/${commit.full_sha}`"
|
||||
target="_blank"
|
||||
class="block min-w-0 text-sm text-default hover:underline"
|
||||
>
|
||||
<span class="font-semibold text-highlighted">
|
||||
{{ ucFirst(commit.message).replace(/\.$/, '') }}.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`${REPO}/commit/${commit.full_sha}`"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{{ commit.sha }}
|
||||
</NuxtLink>
|
||||
|
||||
<UIcon
|
||||
v-if="commit.full_sha === app_sha"
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-success"
|
||||
/>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-toned">
|
||||
<span>{{ commit.author }}</span>
|
||||
<UTooltip :text="`SHA: ${commit.full_sha} - Date: ${commit.date}`">
|
||||
<span>{{ moment(commit.date).fromNow() }}</span>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-user" class="size-3.5" />
|
||||
{{ commit.author }}
|
||||
</span>
|
||||
<UTooltip :text="`Date: ${commit.date}`">
|
||||
<span
|
||||
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-clock-3" class="size-3.5" />
|
||||
{{ moment(commit.date).fromNow() }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
<UTooltip :text="`SHA: ${commit.full_sha}`">
|
||||
<span
|
||||
class="inline-flex cursor-help items-center gap-1 rounded-md border border-default px-2 py-1 font-medium"
|
||||
>
|
||||
<UIcon name="i-lucide-git-commit-horizontal" class="size-3.5" />
|
||||
{{ commit.sha }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
<span
|
||||
v-if="commit.full_sha === app_sha"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 font-medium"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5 text-success" />
|
||||
<span>Installed</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -145,10 +169,6 @@
|
|||
:description="`No changelog entries found for the query: ${query}.`"
|
||||
/>
|
||||
|
||||
<UButton v-if="query" color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||
Clear filter
|
||||
</UButton>
|
||||
|
||||
<UAlert
|
||||
v-else
|
||||
color="warning"
|
||||
|
|
@ -167,10 +187,11 @@ import moment from 'moment';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import type { changelogs, changeset } from '~/types/changelogs';
|
||||
import { request, ucFirst, uri } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const config = useYtpConfig();
|
||||
const pageShell = requirePageShell('changelog');
|
||||
|
||||
useHead({ title: 'CHANGELOG' });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-filter" class="size-5 text-toned" />
|
||||
<span>Conditions</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Run yt-dlp custom match filter on returned info. and apply options.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="items.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -37,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -47,7 +40,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span v-if="!isMobile">New Condition</span>
|
||||
<span>New Condition</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -56,9 +49,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -71,111 +65,168 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && items.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
/>
|
||||
<div
|
||||
v-if="!isLoading && filteredItems.length > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ allSelected ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
||||
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
||||
{{ selectedIds.length }}
|
||||
</UBadge>
|
||||
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayStyle === 'list' && filteredItems.length > 0"
|
||||
v-if="contentStyle === 'list' && filteredItems.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-225 w-full text-sm">
|
||||
<table class="min-w-235 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Condition</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-48 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="cond in filteredItems" :key="cond.id" class="hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="cond in filteredItems"
|
||||
:key="cond.id"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="cond.id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-highlighted">{{ cond.name }}</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<UTooltip
|
||||
:text="`Click to ${cond.enabled !== false ? 'disable' : 'enable'} condition`"
|
||||
>
|
||||
<USwitch
|
||||
:model-value="cond.enabled !== false"
|
||||
:disabled="conditions.addInProgress.value"
|
||||
@update:model-value="() => void toggleEnabled(cond)"
|
||||
/>
|
||||
</UTooltip>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
:disabled="conditions.addInProgress.value"
|
||||
@click="() => void toggleEnabled(cond)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span v-if="cond.priority > 0" class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="cond.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority: {{ cond.priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs text-toned">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-filter" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<code class="wrap-break-word">{{ cond.filter }}</code>
|
||||
</div>
|
||||
|
||||
<div v-if="cond.cli" class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<code class="wrap-break-word">{{ cond.cli }}</code>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="([key, value], index) in extrasEntries(cond.extras)"
|
||||
:key="`${cond.id}-${key}-${index}`"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<UIcon name="i-lucide-list" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<code class="wrap-break-word">{{ key }}: {{ value }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
@click="exportItem(cond)"
|
||||
>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="editItem(cond)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteItem(cond)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
<span class="hidden sm:inline">Delete</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -186,135 +237,178 @@
|
|||
</div>
|
||||
|
||||
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<UCard
|
||||
v-for="cond in filteredItems"
|
||||
:key="cond.id"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(cond.id, 'name')"
|
||||
>
|
||||
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span>
|
||||
</button>
|
||||
<div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(cond.id, 'name')"
|
||||
>
|
||||
<span :class="['block', expandClass(cond.id, 'name')]">{{ cond.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
:disabled="conditions.addInProgress.value"
|
||||
@click="() => void toggleEnabled(cond)"
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(cond)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
|
||||
<span>Export Condition</span>
|
||||
</UButton>
|
||||
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="cond.id"
|
||||
/>
|
||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="cond.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority {{ cond.priority }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(cond)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(cond.id, 'filter')"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(cond.id, 'filter')">{{ cond.filter }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="cond.cli"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(cond.id, 'cli')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(cond.id, 'cli')">{{ cond.cli }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="extrasEntries(cond.extras).length > 0"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2 text-toned">
|
||||
<UIcon name="i-lucide-list" class="size-4" />
|
||||
<span class="text-sm font-medium">Extras</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="([key, value], index) in extrasEntries(cond.extras)"
|
||||
:key="`${cond.id}-${key}-${index}`"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
:disabled="conditions.addInProgress.value"
|
||||
@click="() => void toggleEnabled(cond)"
|
||||
>
|
||||
<span class="font-semibold">{{ key }}</span
|
||||
>: {{ value }}
|
||||
</UBadge>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="cond.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority: {{ cond.priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(cond.id, 'filter')"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Filter</div>
|
||||
<span :class="['block', expandClass(cond.id, 'filter')]">{{ cond.filter }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="cond.cli || cond.description" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-if="cond.cli"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!cond.description && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(cond.id, 'cli')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">CLI</div>
|
||||
<span :class="['block', expandClass(cond.id, 'cli')]">{{ cond.cli }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="cond.description"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!cond.cli && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(cond.id, 'description')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-message-square-text"
|
||||
class="mt-0.5 size-4 shrink-0 text-toned"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Description</div>
|
||||
<span :class="['block', expandClass(cond.id, 'description')]">{{
|
||||
cond.description
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="extrasEntries(cond.extras).length > 0"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2 text-toned">
|
||||
<UIcon name="i-lucide-list" class="size-4" />
|
||||
<span class="text-sm font-medium">Extras</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="([key, value], index) in extrasEntries(cond.extras)"
|
||||
:key="`${cond.id}-${key}-${index}`"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
>
|
||||
<span class="font-semibold">{{ key }}</span
|
||||
>: {{ value }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="cond.description"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(cond.id, 'description')"
|
||||
>
|
||||
<UIcon name="i-lucide-message-square-text" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(cond.id, 'description')">{{ cond.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(cond)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(cond)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(cond)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(cond)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -334,10 +428,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||
Clear filter
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -349,6 +439,19 @@
|
|||
description="There are no custom defined conditions yet. Click the New Condition button to add your first condition."
|
||||
/>
|
||||
|
||||
<div v-if="filteredItems.length > 0 && paging?.total_pages > 1" class="flex justify-end">
|
||||
<UPagination
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredItems.length > 0 && !query"
|
||||
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
|
||||
|
|
@ -381,11 +484,11 @@
|
|||
<UModal
|
||||
v-if="editorOpen"
|
||||
:open="editorOpen"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:title="itemRef ? `Edit - ${item.name || 'Condition'}` : 'Add new condition'"
|
||||
description="Run yt-dlp custom match filter on returned info. and apply options."
|
||||
:dismissible="!conditions.addInProgress.value"
|
||||
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && closeEditor()"
|
||||
@update:open="handleEditorOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<ConditionForm
|
||||
|
|
@ -393,7 +496,8 @@
|
|||
:addInProgress="conditions.addInProgress.value"
|
||||
:reference="itemRef"
|
||||
:item="item as Condition"
|
||||
@cancel="closeEditor()"
|
||||
@cancel="() => void requestCloseEditor()"
|
||||
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -402,21 +506,28 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useConditions } from '~/composables/useConditions';
|
||||
import type { Condition } from '~/types/conditions';
|
||||
import type { APIResponse } from '~/types/responses';
|
||||
import { cleanObject, copyText, encode } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
type ConditionItemWithUI = Condition & { raw?: boolean };
|
||||
|
||||
const box = useConfirm();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const pageShell = requirePageShell('conditions');
|
||||
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
const conditions = useConditions();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { confirmDialog } = useDialog();
|
||||
|
||||
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
|
||||
const paging = conditions.pagination;
|
||||
|
|
@ -425,12 +536,14 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
|
|||
const item = ref<Partial<Condition>>({});
|
||||
const itemRef = ref<number | null | undefined>(null);
|
||||
const editorOpen = ref(false);
|
||||
const editorDirty = ref(false);
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const massDelete = ref(false);
|
||||
|
||||
const removeKeys = ['raw', 'toggle_description'];
|
||||
const expandedItems = reactive<Record<number, Set<string>>>({});
|
||||
|
||||
const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
||||
const normalizedQuery = query.value?.toLowerCase();
|
||||
|
|
@ -441,22 +554,69 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
|||
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
|
||||
});
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition',
|
||||
const selectableConditionIds = computed(() =>
|
||||
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
const modalDescription = computed(
|
||||
() => 'Run yt-dlp custom match filter on returned info. and apply options.',
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableConditionIds.value.length > 0 &&
|
||||
selectableConditionIds.value.every((id) => selectedIds.value.includes(id)),
|
||||
);
|
||||
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : displayStyle.value,
|
||||
);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Remove Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || massDelete.value,
|
||||
onSelect: () => void deleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const modalKey = computed(
|
||||
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
||||
);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
editorDirty.value = false;
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
};
|
||||
|
||||
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
||||
useDirtyCloseGuard(editorOpen, {
|
||||
dirty: editorDirty,
|
||||
message: 'You have unsaved condition changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
watch(showFilter, (value) => {
|
||||
if (!value) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredItems,
|
||||
(items) => {
|
||||
const validIds = new Set(
|
||||
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const syncPageQuery = async (pageNumber: number): Promise<void> => {
|
||||
const totalPages = conditions.pagination.value.total_pages;
|
||||
const nextQuery = { ...route.query };
|
||||
|
|
@ -478,7 +638,7 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const loadContent = async (pageNumber = 1): Promise<void> => {
|
||||
|
|
@ -499,6 +659,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
|||
const resetEditor = (): void => {
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
editorDirty.value = false;
|
||||
};
|
||||
|
||||
const closeEditor = (): void => {
|
||||
|
|
@ -515,32 +676,13 @@ const toggleDisplayStyle = (): void => {
|
|||
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: number | undefined, field: string): void => {
|
||||
if (!itemId) {
|
||||
const toggleMasterSelection = (): void => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expandedItems[itemId]) {
|
||||
expandedItems[itemId] = new Set();
|
||||
}
|
||||
|
||||
if (expandedItems[itemId].has(field)) {
|
||||
expandedItems[itemId].delete(field);
|
||||
} else {
|
||||
expandedItems[itemId].add(field);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpanded = (itemId: number | undefined, field: string): boolean => {
|
||||
if (!itemId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandedItems[itemId]?.has(field) ?? false;
|
||||
};
|
||||
|
||||
const expandClass = (itemId: number | undefined, field: string): string => {
|
||||
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
|
||||
selectedIds.value = [...selectableConditionIds.value];
|
||||
};
|
||||
|
||||
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => {
|
||||
|
|
@ -559,6 +701,51 @@ const deleteItem = async (cond: Condition): Promise<void> => {
|
|||
await conditions.deleteCondition(cond.id!);
|
||||
};
|
||||
|
||||
const deleteSelected = async (): Promise<void> => {
|
||||
if (selectedIds.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Conditions',
|
||||
message:
|
||||
`Delete ${selectedIds.value.length} condition/s?` +
|
||||
'\n\n' +
|
||||
selectedIds.value
|
||||
.map((id) => {
|
||||
const item = filteredItems.value.find((cond) => cond.id === id);
|
||||
return item ? `${item.id}: ${item.name}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToDelete = filteredItems.value.filter(
|
||||
(item) => item.id && selectedIds.value.includes(item.id),
|
||||
);
|
||||
if (itemsToDelete.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
massDelete.value = true;
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
await conditions.deleteCondition(item.id);
|
||||
}
|
||||
|
||||
selectedIds.value = [];
|
||||
massDelete.value = false;
|
||||
};
|
||||
|
||||
const updateItem = async ({
|
||||
reference,
|
||||
item: updatedItem,
|
||||
|
|
@ -581,6 +768,7 @@ const updateItem = async ({
|
|||
};
|
||||
|
||||
const editItem = (value: Condition): void => {
|
||||
editorDirty.value = false;
|
||||
item.value = JSON.parse(JSON.stringify(value)) as Condition;
|
||||
itemRef.value = value.id;
|
||||
editorOpen.value = true;
|
||||
|
|
|
|||
|
|
@ -1,44 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-terminal" class="size-5 text-toned" />
|
||||
<span>Console</span>
|
||||
<main class="flex min-h-0 w-full min-w-0 max-w-full flex-1 flex-col gap-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<UBadge :color="sessionStatusColor" variant="soft" size="sm">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="sessionStatusIcon"
|
||||
class="size-3.5"
|
||||
:class="sessionStatusSpinning ? 'animate-spin' : ''"
|
||||
/>
|
||||
<span>{{ sessionStatusLabel }}</span>
|
||||
</span>
|
||||
</UBadge>
|
||||
|
||||
<UBadge v-if="hasActiveSession" color="neutral" variant="outline" size="sm">
|
||||
Session {{ shortSessionId }}
|
||||
</UBadge>
|
||||
|
||||
<UBadge
|
||||
v-if="sessionExitCode !== null && !isLoading"
|
||||
:color="exitCodeBadgeColor"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
Exit {{ sessionExitCode }}
|
||||
</UBadge>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm">
|
||||
{{ commandHistory.length }} saved
|
||||
</UBadge>
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">{{ sessionStatusDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
|
|
@ -62,13 +45,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<UPageCard variant="naked" :ui="pageCardUi">
|
||||
<UPageCard variant="naked" :ui="pageCardUi" class="flex min-h-0 flex-1">
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-hidden rounded-xl border border-default bg-neutral-950/95 shadow-sm">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div
|
||||
class="flex min-h-72 min-w-0 flex-1 overflow-hidden rounded-sm border border-default bg-neutral-950/95 shadow-sm"
|
||||
>
|
||||
<div
|
||||
ref="terminal_window"
|
||||
class="terminal-host min-h-[55vh] max-h-[55vh] overflow-hidden"
|
||||
class="terminal-host h-full min-h-0 w-full overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -79,14 +64,25 @@
|
|||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-2 text-sm font-semibold text-highlighted"
|
||||
class="flex min-w-0 flex-wrap items-center gap-2 text-sm font-semibold text-highlighted"
|
||||
>
|
||||
<UIcon name="i-lucide-send" class="size-4 shrink-0 text-toned" />
|
||||
<span>Command</span>
|
||||
|
||||
<UBadge :color="sessionStatusColor" variant="soft" size="sm">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="sessionStatusIcon"
|
||||
class="size-3.5"
|
||||
:class="sessionStatusSpinning ? 'animate-spin' : ''"
|
||||
/>
|
||||
<span>{{ sessionStatusLabel }}</span>
|
||||
</span>
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<UBadge :color="inputModeColor" variant="soft" size="sm">
|
||||
{{ inputModeLabel }}
|
||||
<UBadge :color="isMultiLineInput ? 'info' : 'neutral'" variant="soft" size="sm">
|
||||
{{ isMultiLineInput ? 'Multi-line' : 'Single-line' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
|
|
@ -107,37 +103,13 @@
|
|||
:description="sessionError"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="hasYtDlpPrefix"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-triangle-alert"
|
||||
title="Remove the yt-dlp prefix"
|
||||
>
|
||||
<template #description>
|
||||
<p class="text-sm text-default">
|
||||
Enter only the URLs and flags. This page already runs commands as
|
||||
<code>yt-dlp <your command></code>.
|
||||
</p>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
v-if="sessionStatus === 'reconnecting'"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-rotate-cw"
|
||||
title="Reconnecting to the live stream"
|
||||
description="The command is still tracked in the background and the page is trying to reattach automatically."
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="showExpiredAlert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-clock-3"
|
||||
title="Session expired"
|
||||
description="The saved transcript has aged out, but the last command was restored so you can run it again."
|
||||
title="Reconnecting to the command stream"
|
||||
description="The connection was lost. Attempting to reconnect and restore the stream."
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -146,16 +118,7 @@
|
|||
variant="soft"
|
||||
icon="i-lucide-circle-off"
|
||||
title="Session interrupted"
|
||||
description="The last command did not finish cleanly. Inspect the transcript above or rerun the command below."
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="showNonZeroExitAlert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-badge-alert"
|
||||
:title="`Command exited with code ${sessionExitCode}`"
|
||||
description="The transcript is preserved so you can review the output or run the command again."
|
||||
description="The command execution was interrupted."
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
||||
|
|
@ -167,8 +130,8 @@
|
|||
class="console-input"
|
||||
:options="ytDlpOptions"
|
||||
:disabled="isStartBlocked"
|
||||
:icon="commandInputIcon"
|
||||
:icon-class="commandInputIconClass"
|
||||
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
|
||||
:icon-class="isLoading ? 'animate-spin' : ''"
|
||||
placeholder="--help"
|
||||
:rows="5"
|
||||
@keydown="handleKeyDown"
|
||||
|
|
@ -181,8 +144,8 @@
|
|||
class="console-input"
|
||||
:options="ytDlpOptions"
|
||||
:disabled="isStartBlocked"
|
||||
:icon="commandInputIcon"
|
||||
:icon-class="commandInputIconClass"
|
||||
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
|
||||
:icon-class="isLoading ? 'animate-spin' : ''"
|
||||
placeholder="--help"
|
||||
:multiple="true"
|
||||
:allowShortFlags="true"
|
||||
|
|
@ -247,7 +210,7 @@
|
|||
<tr
|
||||
v-for="(cmd, index) in historyEntries"
|
||||
:key="`${index}-${cmd}`"
|
||||
class="hover:bg-muted/20"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<button
|
||||
|
|
@ -335,11 +298,19 @@
|
|||
}
|
||||
|
||||
.terminal-host :deep(.xterm) {
|
||||
height: 100%;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.terminal-host :deep(.xterm-viewport) {
|
||||
-ms-overflow-style: none;
|
||||
background-color: transparent !important;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.terminal-host :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -354,6 +325,7 @@ import { useConsoleSession } from '~/composables/useConsoleSession';
|
|||
import { useDialog } from '~/composables/useDialog';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
useHead({ title: 'Console' });
|
||||
|
||||
|
|
@ -366,13 +338,14 @@ let terminalResizeObserver: ResizeObserver | null = null;
|
|||
let didInitialRender = false;
|
||||
let renderedChunkCount = 0;
|
||||
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
const consoleSession = useConsoleSession();
|
||||
const pageShell = requirePageShell('console');
|
||||
|
||||
const terminal = ref<Terminal | null>(null);
|
||||
const terminalFit = ref<FitAddon | null>(null);
|
||||
const terminal = shallowRef<Terminal | null>(null);
|
||||
const terminalFit = shallowRef<FitAddon | null>(null);
|
||||
const command = ref('');
|
||||
const manualReconnectPending = ref(false);
|
||||
const cancelPending = ref(false);
|
||||
|
|
@ -383,10 +356,10 @@ const storedCommand = useStorage<string>('console_command', '');
|
|||
const commandHistory = useStorage<string[]>('console_command_history', []);
|
||||
|
||||
const pageCardUi = {
|
||||
root: 'w-full bg-transparent',
|
||||
container: 'w-full p-0 sm:p-0',
|
||||
wrapper: 'w-full items-stretch',
|
||||
body: 'w-full',
|
||||
root: 'flex min-h-0 flex-1 w-full bg-transparent',
|
||||
container: 'flex min-h-0 flex-1 w-full p-0 sm:p-0',
|
||||
wrapper: 'flex min-h-0 flex-1 w-full items-stretch',
|
||||
body: 'flex min-h-0 flex-1 w-full flex-col',
|
||||
};
|
||||
|
||||
const historyCardUi = {
|
||||
|
|
@ -404,11 +377,9 @@ const sessionStatus = computed(() => consoleSession.state.value.status);
|
|||
const sessionError = computed(() => consoleSession.state.value.error);
|
||||
const sessionExitCode = computed(() => consoleSession.state.value.exitCode);
|
||||
const hasActiveSession = computed(() => Boolean(consoleSession.state.value.sessionId));
|
||||
const shortSessionId = computed(() => consoleSession.state.value.sessionId?.slice(0, 8) ?? '');
|
||||
const displayCommand = computed(() => command.value.trim().replace(/^yt-dlp\b\s*/i, ''));
|
||||
const runnableCommand = computed(() => displayCommand.value.replace(/\n/g, ' ').trim());
|
||||
const hasValidCommand = computed(() => Boolean(runnableCommand.value));
|
||||
const hasYtDlpPrefix = computed(() => /^yt-dlp\b/i.test(command.value.trim()));
|
||||
const isLoading = computed(() => consoleSession.isLoading.value);
|
||||
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
|
||||
const historyEntries = computed(() => commandHistory.value);
|
||||
|
|
@ -418,22 +389,6 @@ const showCancelButton = computed(() =>
|
|||
);
|
||||
const isStartBlocked = computed(() => isLoading.value);
|
||||
const canStartCommand = computed(() => !isStartBlocked.value && hasValidCommand.value);
|
||||
const showExpiredAlert = computed(
|
||||
() => sessionStatus.value === 'expired' && Boolean(consoleSession.state.value.command),
|
||||
);
|
||||
const showNonZeroExitAlert = computed(
|
||||
() =>
|
||||
!isLoading.value && typeof sessionExitCode.value === 'number' && sessionExitCode.value !== 0,
|
||||
);
|
||||
const inputModeLabel = computed(() => (isMultiLineInput.value ? 'Multi-line' : 'Single-line'));
|
||||
const inputModeColor = computed(() => (isMultiLineInput.value ? 'info' : 'neutral'));
|
||||
const commandInputIcon = computed(() =>
|
||||
isLoading.value ? 'i-lucide-loader-circle' : 'i-lucide-terminal',
|
||||
);
|
||||
const commandInputIconClass = computed(() => (isLoading.value ? 'animate-spin' : ''));
|
||||
const exitCodeBadgeColor = computed(() => {
|
||||
return sessionExitCode.value === 0 ? 'success' : 'error';
|
||||
});
|
||||
const runButtonLabel = computed(() => {
|
||||
if (runnableCommand.value === 'clear') {
|
||||
return 'Clear output';
|
||||
|
|
@ -468,7 +423,7 @@ const sessionStatusLabel = computed(() => {
|
|||
return 'Reconnecting';
|
||||
|
||||
case 'finished':
|
||||
return 'Finished';
|
||||
return sessionExitCode.value === 0 ? 'Finished' : 'Failed';
|
||||
|
||||
case 'interrupted':
|
||||
return 'Interrupted';
|
||||
|
|
@ -495,7 +450,7 @@ const sessionStatusColor = computed(() => {
|
|||
return 'warning';
|
||||
|
||||
case 'finished':
|
||||
return 'success';
|
||||
return sessionExitCode.value === 0 ? 'success' : 'error';
|
||||
|
||||
case 'error':
|
||||
return 'error';
|
||||
|
|
@ -512,7 +467,7 @@ const sessionStatusIcon = computed(() => {
|
|||
return 'i-lucide-loader-circle';
|
||||
|
||||
case 'finished':
|
||||
return 'i-lucide-circle-check';
|
||||
return sessionExitCode.value === 0 ? 'i-lucide-circle-check' : 'i-lucide-triangle-alert';
|
||||
|
||||
case 'interrupted':
|
||||
return 'i-lucide-circle-off';
|
||||
|
|
@ -528,38 +483,6 @@ const sessionStatusIcon = computed(() => {
|
|||
}
|
||||
});
|
||||
const sessionStatusSpinning = computed(() => ACTIVE_SESSION_STATUSES.includes(sessionStatus.value));
|
||||
const sessionStatusDescription = computed(() => {
|
||||
switch (sessionStatus.value) {
|
||||
case 'starting':
|
||||
case 'running':
|
||||
return 'The command keeps running even if you reload or navigate away.';
|
||||
|
||||
case 'reconnecting':
|
||||
return 'The command is still tracked in the background while the page reconnects to the stream.';
|
||||
|
||||
case 'finished':
|
||||
return typeof sessionExitCode.value === 'number' && sessionExitCode.value !== 0
|
||||
? `The last command finished with exit code ${sessionExitCode.value}.`
|
||||
: 'The last command completed and its transcript is still available.';
|
||||
|
||||
case 'interrupted':
|
||||
return 'The last command was interrupted before it completed.';
|
||||
|
||||
case 'expired':
|
||||
return 'The transcript expired, but you can rerun the restored command below.';
|
||||
|
||||
case 'error':
|
||||
return typeof sessionExitCode.value === 'number'
|
||||
? `The last command failed with exit code ${sessionExitCode.value}.`
|
||||
: hasActiveSession.value
|
||||
? 'The live stream ran into a problem while the page was attached to the session.'
|
||||
: 'The command request failed before a durable session could be restored.';
|
||||
|
||||
default:
|
||||
return 'Run yt-dlp commands directly in a non-interactive session.';
|
||||
}
|
||||
});
|
||||
|
||||
watch(command, (value) => {
|
||||
storedCommand.value = value;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-braces" class="size-5 text-toned" />
|
||||
<span>Custom Fields</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Custom fields allow you to add new fields to the download form.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="items.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -37,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -47,7 +40,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span v-if="!isMobile">New Field</span>
|
||||
<span>New Field</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -56,9 +49,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -71,91 +65,167 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && items.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
/>
|
||||
<div
|
||||
v-if="!isLoading && filteredItems.length > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ allSelected ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
||||
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
||||
{{ selectedIds.length }}
|
||||
</UBadge>
|
||||
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayStyle === 'list' && filteredItems.length > 0"
|
||||
v-if="contentStyle === 'list' && filteredItems.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-225 w-full text-sm">
|
||||
<table class="min-w-235 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Field</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-48 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="field in filteredItems" :key="field.id" class="hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="field in filteredItems"
|
||||
:key="field.id"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="field.id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="space-y-2">
|
||||
<div class="font-semibold text-highlighted">{{ field.name }}</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<UIcon name="i-lucide-terminal" class="size-3.5" />
|
||||
<span>{{ field.field }}</span>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Order: {{ field.order }}</span>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-shapes" class="size-3.5" />
|
||||
<span>{{ field.kind }}</span>
|
||||
<span>Type: {{ field.kind }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="field.description" class="text-xs text-toned">
|
||||
{{ field.description }}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="size-3.5" />
|
||||
<span>Option: {{ field.field }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
@click="exportItem(field)"
|
||||
>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
Export
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="editItem(field)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteItem(field)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -166,89 +236,131 @@
|
|||
</div>
|
||||
|
||||
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<UCard
|
||||
v-for="field in filteredItems"
|
||||
:key="field.id"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="truncate text-sm font-semibold text-highlighted">{{ field.name }}</div>
|
||||
<div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(field.id, 'title')"
|
||||
>
|
||||
<span :class="['block', expandClass(field.id, 'title')]">{{ field.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(field)"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Order {{ field.order }}</span>
|
||||
</span>
|
||||
<span>Export Field</span>
|
||||
</UButton>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-shapes" class="size-3.5" />
|
||||
<span>{{ field.kind }}</span>
|
||||
</span>
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="field.id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(field)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Order: {{ field.order }}</span>
|
||||
</span>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="wrap-break-word">{{ field.field }}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-shapes" class="size-3.5" />
|
||||
<span>Type: {{ field.kind }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!field.description && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(field.id, 'field')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Associated option</div>
|
||||
<span :class="['block', expandClass(field.id, 'field')]">{{ field.field }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="field.description"
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(field.id, 'description')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-message-square-text"
|
||||
class="mt-0.5 size-4 shrink-0 text-toned"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Description</div>
|
||||
<span :class="['block', expandClass(field.id, 'description')]">
|
||||
{{ field.description }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-message-square-text"
|
||||
class="mt-0.5 size-4 shrink-0 text-toned"
|
||||
/>
|
||||
<span class="wrap-break-word">{{ field.description }}</span>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(field)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(field)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(field)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(field)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -268,10 +380,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||
Clear filter
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -283,14 +391,27 @@
|
|||
description="There are no custom defined fields yet. Click the New Field button to add your first field."
|
||||
/>
|
||||
|
||||
<div v-if="filteredItems.length > 0 && paging?.total_pages > 1" class="flex justify-end">
|
||||
<UPagination
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UModal
|
||||
v-if="editorOpen"
|
||||
:open="editorOpen"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:title="itemRef ? `Edit - ${item.name || 'Field'}` : 'Add new field'"
|
||||
description="Custom fields allow you to add new fields to the download form."
|
||||
:dismissible="!dlFields.addInProgress.value"
|
||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && closeEditor()"
|
||||
@update:open="handleEditorOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<DLFieldForm
|
||||
|
|
@ -298,7 +419,8 @@
|
|||
:addInProgress="dlFields.addInProgress.value"
|
||||
:reference="itemRef"
|
||||
:item="item as DLField"
|
||||
@cancel="closeEditor()"
|
||||
@cancel="() => void requestCloseEditor()"
|
||||
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -307,19 +429,26 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDlFields } from '~/composables/useDlFields';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { APIResponse } from '~/types/responses';
|
||||
import { copyText, encode } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const box = useConfirm();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
const pageShell = requirePageShell('custom-fields');
|
||||
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
const dlFields = useDlFields();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { confirmDialog } = useDialog();
|
||||
|
||||
const items = dlFields.dlFields as Ref<DLField[]>;
|
||||
const paging = dlFields.pagination;
|
||||
|
|
@ -328,9 +457,12 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
|
|||
const item = ref<Partial<DLField>>({});
|
||||
const itemRef = ref<number | null | undefined>(null);
|
||||
const editorOpen = ref(false);
|
||||
const editorDirty = ref(false);
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const massDelete = ref(false);
|
||||
|
||||
const filteredItems = computed<DLField[]>(() => {
|
||||
const normalizedQuery = query.value?.toLowerCase();
|
||||
|
|
@ -341,20 +473,69 @@ const filteredItems = computed<DLField[]>(() => {
|
|||
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field'));
|
||||
const modalDescription = computed(
|
||||
() => 'Custom fields allow you to add new fields to the download form.',
|
||||
const selectableFieldIds = computed(() =>
|
||||
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableFieldIds.value.length > 0 &&
|
||||
selectableFieldIds.value.every((id) => selectedIds.value.includes(id)),
|
||||
);
|
||||
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : displayStyle.value,
|
||||
);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Remove Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || massDelete.value,
|
||||
onSelect: () => void deleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const modalKey = computed(
|
||||
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
||||
);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
editorDirty.value = false;
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
};
|
||||
|
||||
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
||||
useDirtyCloseGuard(editorOpen, {
|
||||
dirty: editorDirty,
|
||||
message: 'You have unsaved custom field changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
watch(showFilter, (value) => {
|
||||
if (!value) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredItems,
|
||||
(items) => {
|
||||
const validIds = new Set(
|
||||
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const syncPageQuery = async (pageNumber: number): Promise<void> => {
|
||||
const totalPages = dlFields.pagination.value.total_pages;
|
||||
const nextQuery = { ...route.query };
|
||||
|
|
@ -376,7 +557,7 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const loadContent = async (pageNumber = 1): Promise<void> => {
|
||||
|
|
@ -397,6 +578,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
|||
const resetEditor = (): void => {
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
editorDirty.value = false;
|
||||
};
|
||||
|
||||
const closeEditor = (): void => {
|
||||
|
|
@ -413,6 +595,15 @@ const toggleDisplayStyle = (): void => {
|
|||
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIds.value = [...selectableFieldIds.value];
|
||||
};
|
||||
|
||||
const deleteItem = async (field: DLField): Promise<void> => {
|
||||
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
|
||||
return;
|
||||
|
|
@ -421,6 +612,51 @@ const deleteItem = async (field: DLField): Promise<void> => {
|
|||
await dlFields.deleteDlField(field.id!);
|
||||
};
|
||||
|
||||
const deleteSelected = async (): Promise<void> => {
|
||||
if (selectedIds.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Fields',
|
||||
message:
|
||||
`Delete ${selectedIds.value.length} field/s?` +
|
||||
'\n\n' +
|
||||
selectedIds.value
|
||||
.map((id) => {
|
||||
const item = filteredItems.value.find((field) => field.id === id);
|
||||
return item ? `${item.id}: ${item.name}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToDelete = filteredItems.value.filter(
|
||||
(item) => item.id && selectedIds.value.includes(item.id),
|
||||
);
|
||||
if (itemsToDelete.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
massDelete.value = true;
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
await dlFields.deleteDlField(item.id);
|
||||
}
|
||||
|
||||
selectedIds.value = [];
|
||||
massDelete.value = false;
|
||||
};
|
||||
|
||||
const updateItem = async ({
|
||||
reference,
|
||||
item: updatedItem,
|
||||
|
|
@ -442,6 +678,7 @@ const updateItem = async ({
|
|||
};
|
||||
|
||||
const editItem = (field: DLField): void => {
|
||||
editorDirty.value = false;
|
||||
item.value = JSON.parse(JSON.stringify(field)) as DLField;
|
||||
itemRef.value = field.id;
|
||||
editorOpen.value = true;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,40 @@
|
|||
<template>
|
||||
<main class="space-y-6">
|
||||
<UPageHeader :title="docEntry.title" :description="docEntry.description" :ui="pageHeaderUi">
|
||||
<template #links>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
v-for="entry in docsEntries"
|
||||
:key="entry.id"
|
||||
:to="entry.route"
|
||||
:color="entry.file === docEntry.file ? 'primary' : 'neutral'"
|
||||
:variant="entry.file === docEntry.file ? 'solid' : 'outline'"
|
||||
size="sm"
|
||||
:icon="entry.icon"
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
{{ entry.navLabel }}
|
||||
</UButton>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</UPageHeader>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-for="entry in docsEntries"
|
||||
:key="entry.id"
|
||||
:to="entry.route"
|
||||
:color="entry.file === docEntry.file ? 'primary' : 'neutral'"
|
||||
:variant="entry.file === docEntry.file ? 'solid' : 'outline'"
|
||||
size="sm"
|
||||
:icon="entry.icon"
|
||||
>
|
||||
{{ entry.navLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UPageCard variant="outline" :ui="pageCardUi">
|
||||
<template #body>
|
||||
|
|
@ -31,6 +49,7 @@
|
|||
<script setup lang="ts">
|
||||
import Markdown from '~/components/Markdown.vue';
|
||||
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
|
@ -49,19 +68,12 @@ const docEntry = computed(() => {
|
|||
return entry;
|
||||
});
|
||||
|
||||
const pageShell = computed(() => requirePageShell(docEntry.value.id));
|
||||
|
||||
useHead(() => ({
|
||||
title: docEntry.value.title,
|
||||
}));
|
||||
|
||||
const pageHeaderUi = {
|
||||
root: 'border-b border-default py-4',
|
||||
headline: 'hidden',
|
||||
title: 'text-2xl font-semibold text-highlighted',
|
||||
description: 'text-sm text-toned',
|
||||
wrapper: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
|
||||
links: 'flex flex-wrap items-center gap-2',
|
||||
};
|
||||
|
||||
const pageCardUi = {
|
||||
root: 'w-full bg-default',
|
||||
container: 'w-full p-0',
|
||||
|
|
|
|||
1561
ui/app/pages/history.vue
Normal file
1561
ui/app/pages/history.vue
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-file-text"
|
||||
:class="[
|
||||
|
|
@ -12,36 +14,22 @@
|
|||
:title="loading ? 'Loading history' : 'Live stream active'"
|
||||
:aria-label="loading ? 'Loading history' : 'Live stream active'"
|
||||
/>
|
||||
<span>Logs</span>
|
||||
</span>
|
||||
|
||||
<UBadge v-if="loading" color="info" variant="soft" size="sm">Loading history</UBadge>
|
||||
|
||||
<UBadge color="neutral" variant="soft" size="sm">
|
||||
{{ filteredLogs.length }} shown
|
||||
</UBadge>
|
||||
|
||||
<UBadge
|
||||
v-if="logs.length !== filteredLogs.length"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
{{ logs.length }} loaded
|
||||
</UBadge>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<UBadge v-if="hasActiveFilter" color="warning" variant="soft" size="sm">
|
||||
{{ matchCount }} matches
|
||||
</UBadge>
|
||||
|
||||
<UBadge v-if="reachedEnd && !hasActiveFilter" color="neutral" variant="soft" size="sm">
|
||||
Start of file loaded
|
||||
</UBadge>
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-toned">Scroll near the top to load older logs.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="!autoScroll"
|
||||
color="neutral"
|
||||
|
|
@ -53,22 +41,6 @@
|
|||
Jump to Live Tail
|
||||
</UButton>
|
||||
|
||||
<div v-if="toggleFilter || query" class="relative w-full sm:w-72">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
|
||||
<input
|
||||
id="filter"
|
||||
v-model.lazy="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
:variant="toggleFilter ? 'soft' : 'outline'"
|
||||
|
|
@ -91,22 +63,23 @@
|
|||
>
|
||||
Wrap lines
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="toggleFilter || query"
|
||||
id="filter"
|
||||
v-model.lazy="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UPageCard variant="naked" :ui="pageCardUi">
|
||||
<template #body>
|
||||
<div class="w-full min-w-0 max-w-full space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
|
||||
<span v-if="searchTerm">
|
||||
Query: <code>{{ searchTerm }}</code>
|
||||
</span>
|
||||
|
||||
<span v-if="filterContext > 0">
|
||||
Context: <code>{{ filterContext }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full min-w-0 max-w-full overflow-hidden">
|
||||
<div
|
||||
ref="logContainer"
|
||||
|
|
@ -148,24 +121,24 @@
|
|||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-default bg-muted/20 px-4 py-6 text-center"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-semibold text-highlighted">
|
||||
{{
|
||||
hasActiveFilter
|
||||
? 'No log lines match the current filter.'
|
||||
: 'No log lines are available yet.'
|
||||
}}
|
||||
</p>
|
||||
<div v-else class="space-y-3 font-sans text-sm leading-normal">
|
||||
<UAlert
|
||||
v-if="query"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-search"
|
||||
title="No Results"
|
||||
:description="`No log lines found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<p v-if="hasActiveFilter" class="text-xs text-toned">
|
||||
Try a different term or clear <code>{{ query }}</code
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<UAlert
|
||||
v-else
|
||||
color="warning"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-alert"
|
||||
title="No log lines"
|
||||
description="No log lines are available yet."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref="bottomMarker" />
|
||||
|
|
@ -185,6 +158,7 @@ import moment from 'moment';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import type { log_line } from '~/types/logs';
|
||||
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
type FilteredLogEntry = {
|
||||
log: log_line;
|
||||
|
|
@ -199,8 +173,9 @@ const FILTER_CONTEXT_REGEX = /context:(\d+)/;
|
|||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const route = useRoute();
|
||||
const pageShell = requirePageShell('logs');
|
||||
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
|
||||
|
|
@ -302,8 +277,6 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
|||
return result;
|
||||
});
|
||||
|
||||
const matchCount = computed(() => filteredLogs.value.filter((entry) => entry.isMatch).length);
|
||||
|
||||
const fetchLogs = async (): Promise<void> => {
|
||||
loading.value = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-bell" class="size-5 text-toned" />
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Send notifications to your webhooks based on specified events or presets.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && notifications.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="notifications.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -37,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -47,7 +40,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span v-if="!isMobile">New Notification</span>
|
||||
<span>New Notification</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -60,7 +53,7 @@
|
|||
:disabled="sendingTest"
|
||||
@click="() => void sendTest()"
|
||||
>
|
||||
<span v-if="!isMobile">Send Test</span>
|
||||
<span>Send Test</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -69,9 +62,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -84,34 +78,108 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && notifications.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pager
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="navigatePage"
|
||||
/>
|
||||
<div
|
||||
v-if="!isLoading && filteredTargets.length > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ allSelected ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
||||
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
||||
{{ selectedIds.length }}
|
||||
</UBadge>
|
||||
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<UPagination
|
||||
v-if="paging?.total_pages > 1"
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayStyle === 'list' && filteredTargets.length > 0"
|
||||
v-if="contentStyle === 'list' && filteredTargets.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-225 w-full text-sm">
|
||||
<table class="min-w-235 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Targets</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-48 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="item in filteredTargets" :key="item.id" class="hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="item in filteredTargets"
|
||||
:key="item.id"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="space-y-2">
|
||||
<div class="min-w-0 text-sm font-semibold text-highlighted">
|
||||
|
|
@ -141,54 +209,61 @@
|
|||
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
|
||||
<span>On: {{ joinEvents(item.on) }}</span>
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
||||
<span>Presets: {{ joinPresets(item.presets) }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="headerKeys(item).length > 0" class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="headerKeys(item).length > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-key" class="size-3.5" />
|
||||
<span>Headers: {{ headerKeys(item).join(', ') }}</span>
|
||||
<span>Headers: {{ headerKeys(item).length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
@click="exportItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
<span class="hidden sm:inline">Delete</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -199,104 +274,191 @@
|
|||
</div>
|
||||
|
||||
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<UCard
|
||||
v-for="item in filteredTargets"
|
||||
:key="item.id"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<span class="text-sm font-semibold text-highlighted">
|
||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }})
|
||||
</span>
|
||||
@
|
||||
<a
|
||||
:href="item.request.url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm text-primary hover:underline"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(item.id, 'title')"
|
||||
>
|
||||
<span :class="['block', expandClass(item.id, 'title')]">
|
||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
:disabled="addInProgress"
|
||||
@click="() => void toggleEnabled(item)"
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(item)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="item.enabled !== false ? 'text-success' : 'text-error'"
|
||||
<span>Export Target</span>
|
||||
</UButton>
|
||||
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
/>
|
||||
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
:disabled="addInProgress"
|
||||
@click="() => void toggleEnabled(item)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="item.enabled !== false ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="wrap-break-word">On: {{ joinEvents(item.on) }}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
|
||||
<span>Events: {{ item.on.length || 'All' }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
||||
<span>Presets: {{ item.presets.length || 'All' }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="headerKeys(item).length > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-key" class="size-3.5" />
|
||||
<span>Headers: {{ headerKeys(item).length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="wrap-break-word">Presets: {{ joinPresets(item.presets) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'url')"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Target URL</div>
|
||||
<a
|
||||
:href="item.request.url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="block text-highlighted hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
<span :class="['block', expandClass(item.id, 'url')]">
|
||||
{{ item.request.url }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'events')"
|
||||
>
|
||||
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Events</div>
|
||||
<span :class="['block', expandClass(item.id, 'events')]">{{
|
||||
joinEvents(item.on)
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'presets')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-sliders-horizontal"
|
||||
class="mt-0.5 size-4 shrink-0 text-toned"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Presets</div>
|
||||
<span :class="['block', expandClass(item.id, 'presets')]">
|
||||
{{ joinPresets(item.presets) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="headerKeys(item).length > 0"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
v-if="headerKeys(item).length > 0"
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'headers')"
|
||||
>
|
||||
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="wrap-break-word">Headers: {{ headerKeys(item).join(', ') }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Headers</div>
|
||||
<span :class="['block', expandClass(item.id, 'headers')]">
|
||||
{{ headerKeys(item).join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -316,10 +478,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||
Clear filter
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -331,6 +489,19 @@
|
|||
description="No notification targets found. Click on the New Notification button to add your first notification target."
|
||||
/>
|
||||
|
||||
<div v-if="filteredTargets.length > 0 && paging?.total_pages > 1" class="flex justify-end">
|
||||
<UPagination
|
||||
:page="paging.page"
|
||||
:total="paging.total"
|
||||
:items-per-page="paging.per_page"
|
||||
:disabled="isLoading"
|
||||
show-edges
|
||||
:sibling-count="0"
|
||||
@update:page="navigatePage"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!query && filteredTargets.length > 0"
|
||||
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
|
||||
|
|
@ -366,11 +537,11 @@
|
|||
<UModal
|
||||
v-if="editorOpen"
|
||||
:open="editorOpen"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
:title="targetRef ? `Edit - ${target.name}` : 'Add new notification target'"
|
||||
description="Send notifications to your webhooks based on specified events or presets."
|
||||
:dismissible="!addInProgress"
|
||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && closeEditor()"
|
||||
@update:open="handleEditorOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<NotificationForm
|
||||
|
|
@ -379,7 +550,8 @@
|
|||
:reference="targetRef"
|
||||
:item="target"
|
||||
:allowedEvents="allowedEvents"
|
||||
@cancel="closeEditor()"
|
||||
@cancel="() => void requestCloseEditor()"
|
||||
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -388,20 +560,27 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useNotifications } from '~/composables/useNotifications';
|
||||
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
|
||||
import type { ImportedItem } from '~/types';
|
||||
import type { notification } from '~/types/notification';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const { confirmDialog } = useDialog();
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
const pageShell = requirePageShell('notifications');
|
||||
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
|
||||
'notification_display_style',
|
||||
'cards',
|
||||
);
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
|
||||
const notificationsStore = useNotifications();
|
||||
const notifications = notificationsStore.notifications;
|
||||
|
|
@ -415,25 +594,40 @@ const page = ref(1);
|
|||
const targetRef = ref<number | undefined>(undefined);
|
||||
const target = ref<notification>(defaultState());
|
||||
const editorOpen = ref(false);
|
||||
const editorDirty = ref(false);
|
||||
const sendingTest = ref(false);
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const massDelete = ref(false);
|
||||
|
||||
const displayStyle = computed<'list' | 'grid'>(() =>
|
||||
displayStyleState.value === 'list' ? 'list' : 'grid',
|
||||
);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : displayStyle.value,
|
||||
);
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
targetRef.value ? `Edit - ${target.value.name}` : 'Add new notification target',
|
||||
);
|
||||
const modalDescription = computed(
|
||||
() => 'Send notifications to your webhooks based on specified events or presets.',
|
||||
);
|
||||
const modalKey = computed(
|
||||
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
||||
);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
editorDirty.value = false;
|
||||
target.value = defaultState();
|
||||
targetRef.value = undefined;
|
||||
};
|
||||
|
||||
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
||||
useDirtyCloseGuard(editorOpen, {
|
||||
dirty: editorDirty,
|
||||
message: 'You have unsaved notification changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
const filteredTargets = computed<notification[]>(() => {
|
||||
const normalizedQuery = query.value?.toLowerCase();
|
||||
const items = notifications.value as notification[];
|
||||
|
|
@ -445,12 +639,47 @@ const filteredTargets = computed<notification[]>(() => {
|
|||
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
|
||||
});
|
||||
|
||||
const selectableNotificationIds = computed(() =>
|
||||
filteredTargets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableNotificationIds.value.length > 0 &&
|
||||
selectableNotificationIds.value.every((id) => selectedIds.value.includes(id)),
|
||||
);
|
||||
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Remove Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || massDelete.value,
|
||||
onSelect: () => void deleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
watch(showFilter, (value) => {
|
||||
if (!value) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredTargets,
|
||||
(items) => {
|
||||
const validIds = new Set(
|
||||
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function defaultState(): notification {
|
||||
return {
|
||||
name: '',
|
||||
|
|
@ -469,7 +698,7 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const loadContent = async (pageNumber = page.value): Promise<void> => {
|
||||
|
|
@ -488,6 +717,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
|||
const resetEditor = (): void => {
|
||||
target.value = defaultState();
|
||||
targetRef.value = undefined;
|
||||
editorDirty.value = false;
|
||||
};
|
||||
|
||||
const closeEditor = (): void => {
|
||||
|
|
@ -500,7 +730,17 @@ const openCreate = (): void => {
|
|||
editorOpen.value = true;
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIds.value = [...selectableNotificationIds.value];
|
||||
};
|
||||
|
||||
const editItem = (item: notification): void => {
|
||||
editorDirty.value = false;
|
||||
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
||||
targetRef.value = item.id ?? undefined;
|
||||
editorOpen.value = true;
|
||||
|
|
@ -519,6 +759,51 @@ const deleteItem = async (item: notification): Promise<void> => {
|
|||
await notificationsStore.deleteNotification(item.id);
|
||||
};
|
||||
|
||||
const deleteSelected = async (): Promise<void> => {
|
||||
if (selectedIds.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Notifications',
|
||||
message:
|
||||
`Delete ${selectedIds.value.length} notification target/s?` +
|
||||
'\n\n' +
|
||||
selectedIds.value
|
||||
.map((id) => {
|
||||
const item = filteredTargets.value.find((target) => target.id === id);
|
||||
return item ? `${item.id}: ${item.name}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToDelete = filteredTargets.value.filter(
|
||||
(item) => item.id && selectedIds.value.includes(item.id),
|
||||
);
|
||||
if (itemsToDelete.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
massDelete.value = true;
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
await notificationsStore.deleteNotification(item.id);
|
||||
}
|
||||
|
||||
selectedIds.value = [];
|
||||
massDelete.value = false;
|
||||
};
|
||||
|
||||
const toggleEnabled = async (item: notification): Promise<void> => {
|
||||
if (!item.id) {
|
||||
toast.error('Notification target not found.');
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" />
|
||||
<span>Presets</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Presets are pre-defined command options for yt-dlp that you want to apply to given
|
||||
download.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && presetsNoDefault.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="presetsNoDefault.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -38,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -48,7 +40,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="editor.openCreate()"
|
||||
>
|
||||
<span v-if="!isMobile">New Preset</span>
|
||||
<span>New Preset</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -56,9 +48,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -71,26 +64,96 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && presetsNoDefault.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="display_style === 'list' && filteredPresets.length > 0"
|
||||
v-if="!isLoading && filteredPresets.length > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ allSelected ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
||||
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
||||
{{ selectedIds.length }}
|
||||
</UBadge>
|
||||
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentStyle === 'list' && filteredPresets.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-190 w-full text-sm">
|
||||
<table class="min-w-200 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Preset</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-48 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="item in filteredPresets" :key="item.id" class="hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="item in filteredPresets"
|
||||
:key="item.id"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold text-highlighted">
|
||||
|
|
@ -99,14 +162,20 @@
|
|||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
:class="item.cookies ? 'text-info' : ''"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-cookie" class="size-3.5" />
|
||||
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-cookie"
|
||||
class="size-3.5"
|
||||
:class="item.cookies ? 'text-success' : ''"
|
||||
/>
|
||||
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="item.priority > 0" class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="item.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority: {{ item.priority }}</span>
|
||||
</span>
|
||||
|
|
@ -114,36 +183,36 @@
|
|||
</div>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
@click="exportItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="editor.openEdit(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
<span class="hidden sm:inline">Delete</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -154,121 +223,169 @@
|
|||
</div>
|
||||
|
||||
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<UCard
|
||||
v-for="item in filteredPresets"
|
||||
:key="item.id"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(item.id, 'title')"
|
||||
>
|
||||
<div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(item.id, 'title')"
|
||||
>
|
||||
<span :class="['block', expandClass(item.id, 'title')]">
|
||||
{{ prettyName(item.name) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(item)"
|
||||
>
|
||||
<span>Export Preset</span>
|
||||
</UButton>
|
||||
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<span
|
||||
:class="
|
||||
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
|
||||
"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
{{ prettyName(item.name) }}
|
||||
<UIcon
|
||||
name="i-lucide-cookie"
|
||||
class="size-3.5"
|
||||
:class="item.cookies ? 'text-success' : ''"
|
||||
/>
|
||||
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="item.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority: {{ item.priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="item.folder"
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'folder')"
|
||||
>
|
||||
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Download path</div>
|
||||
<span :class="['block', expandClass(item.id, 'folder')]">{{
|
||||
calcPath(item.folder)
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="exportItem(item)"
|
||||
/>
|
||||
<div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-if="item.template"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!item.cli && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(item.id, 'template')"
|
||||
>
|
||||
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Output template</div>
|
||||
<span :class="['block', expandClass(item.id, 'template')]">{{
|
||||
item.template
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="item.cli"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!item.template && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(item.id, 'cli')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">CLI options</div>
|
||||
<span :class="['block', expandClass(item.id, 'cli')]">{{ item.cli }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="item.description"
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'description')"
|
||||
>
|
||||
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Description</div>
|
||||
<span :class="['block', expandClass(item.id, 'description')]">{{
|
||||
item.description
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
:class="item.cookies ? 'border-info/40 text-info' : ''"
|
||||
>
|
||||
<UIcon name="i-lucide-cookie" class="size-3.5" />
|
||||
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
|
||||
</span>
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editor.openEdit(item)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<span
|
||||
v-if="item.priority > 0"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority {{ item.priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<button
|
||||
v-if="item.folder"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'folder')"
|
||||
>
|
||||
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(item.id, 'folder')">{{ calcPath(item.folder) }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="item.template"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'template')"
|
||||
>
|
||||
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(item.id, 'template')">{{ item.template }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="item.cli"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'cli')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(item.id, 'cli')">{{ item.cli }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="item.description"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'description')"
|
||||
>
|
||||
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span :class="expandClass(item.id, 'description')">{{ item.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editor.openEdit(item)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -288,10 +405,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
|
||||
>Clear filter</UButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -308,7 +421,8 @@
|
|||
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
|
||||
<li>
|
||||
When you export preset, it doesn't include the cookies field contents for security
|
||||
reasons.
|
||||
reasons. However, there are some CLI options that could contain sensitive data like
|
||||
username or password. Remove them before sharing your preset.
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -321,7 +435,7 @@
|
|||
:description="editor.modalDescription.value"
|
||||
:dismissible="!editor.addInProgress.value"
|
||||
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && void editor.requestClose()"
|
||||
@update:open="(open) => void editor.handleOpenChange(open)"
|
||||
>
|
||||
<template #body>
|
||||
<PresetForm
|
||||
|
|
@ -331,6 +445,7 @@
|
|||
:preset="editor.preset.value"
|
||||
:presets="presets"
|
||||
@cancel="() => void editor.requestClose()"
|
||||
@dirty-change="(dirty) => (editor.dirty.value = dirty)"
|
||||
@submit="editor.submit"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -339,28 +454,36 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { prettyName } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
||||
|
||||
const presetsStore = usePresets();
|
||||
const config = useConfigStore();
|
||||
const config = useYtpConfig();
|
||||
const box = useConfirm();
|
||||
const editor = usePresetEditor();
|
||||
const pageShell = requirePageShell('presets');
|
||||
const { confirmDialog } = useDialog();
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
|
||||
const display_style = useStorage<string>('preset_display_style', 'grid');
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const massDelete = ref(false);
|
||||
|
||||
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
|
||||
const isLoading = presetsStore.isLoading;
|
||||
const expandedItems = ref<Record<string, Set<string>>>({});
|
||||
|
||||
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default));
|
||||
|
||||
|
|
@ -375,12 +498,50 @@ const filteredPresets = computed<PresetWithUI[]>(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const selectablePresetIds = computed(() =>
|
||||
filteredPresets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectablePresetIds.value.length > 0 &&
|
||||
selectablePresetIds.value.every((id) => selectedIds.value.includes(id)),
|
||||
);
|
||||
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
|
||||
);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Remove Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || massDelete.value,
|
||||
onSelect: () => void deleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
watch(showFilter, (value) => {
|
||||
if (!value) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredPresets,
|
||||
(items) => {
|
||||
const validIds = new Set(
|
||||
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const toggleFilterPanel = async (): Promise<void> => {
|
||||
showFilter.value = !showFilter.value;
|
||||
if (!showFilter.value) {
|
||||
|
|
@ -389,13 +550,67 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const reloadContent = async (): Promise<void> => {
|
||||
await presetsStore.loadPresets(1, 1000);
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIds.value = [...selectablePresetIds.value];
|
||||
};
|
||||
|
||||
const deleteSelected = async (): Promise<void> => {
|
||||
if (selectedIds.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Presets',
|
||||
message:
|
||||
`Delete ${selectedIds.value.length} preset/s?` +
|
||||
'\n\n' +
|
||||
selectedIds.value
|
||||
.map((id) => {
|
||||
const item = filteredPresets.value.find((preset) => preset.id === id);
|
||||
return item ? `${item.id}: ${prettyName(item.name)}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToDelete = filteredPresets.value.filter(
|
||||
(item) => item.id && selectedIds.value.includes(item.id),
|
||||
);
|
||||
if (itemsToDelete.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
massDelete.value = true;
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
await presetsStore.deletePreset(item.id);
|
||||
}
|
||||
|
||||
selectedIds.value = [];
|
||||
massDelete.value = false;
|
||||
};
|
||||
|
||||
const deleteItem = async (item: Preset): Promise<void> => {
|
||||
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
|
||||
return;
|
||||
|
|
@ -410,35 +625,6 @@ const toggleDisplayStyle = (): void => {
|
|||
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
||||
};
|
||||
|
||||
const toggleExpand = (itemId: number | string | undefined, field: string): void => {
|
||||
if (itemId === undefined || itemId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = String(itemId);
|
||||
if (!expandedItems.value[key]) {
|
||||
expandedItems.value[key] = new Set();
|
||||
}
|
||||
|
||||
if (expandedItems.value[key]?.has(field)) {
|
||||
expandedItems.value[key]?.delete(field);
|
||||
} else {
|
||||
expandedItems.value[key]?.add(field);
|
||||
}
|
||||
};
|
||||
|
||||
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
|
||||
if (itemId === undefined || itemId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandedItems.value[String(itemId)]?.has(field) ?? false;
|
||||
};
|
||||
|
||||
const expandClass = (itemId: number | string | undefined, field: string): string => {
|
||||
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
|
||||
};
|
||||
|
||||
const exportItem = (item: Preset): void => {
|
||||
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
|
||||
const userData = Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -1,34 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-workflow" class="size-5 text-toned" />
|
||||
<span>Task Definitions</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
Create definitions to turn any website into a downloadable feed of links.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && definitions.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="definitions.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -37,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -47,7 +40,7 @@
|
|||
icon="i-lucide-search"
|
||||
@click="inspect = true"
|
||||
>
|
||||
<span v-if="!isMobile">Inspect</span>
|
||||
<span>Inspect</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -57,7 +50,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span v-if="!isMobile">New Definition</span>
|
||||
<span>New Definition</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -65,9 +58,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -79,8 +73,20 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && definitions.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -94,17 +100,62 @@
|
|||
/>
|
||||
|
||||
<div
|
||||
v-if="display_style === 'list' && filteredDefinitions.length > 0"
|
||||
v-if="!isLoading && filteredDefinitions.length > 0"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
@click="toggleMasterSelection"
|
||||
>
|
||||
{{ allSelected ? 'Unselect' : 'Select' }}
|
||||
</UButton>
|
||||
|
||||
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
|
||||
{{ selectedIds.length }}
|
||||
</UBadge>
|
||||
|
||||
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contentStyle === 'list' && filteredDefinitions.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-245 w-full text-sm">
|
||||
<table class="min-w-255 w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Definition</th>
|
||||
<th class="w-28 whitespace-nowrap">Priority</th>
|
||||
<th class="w-36 whitespace-nowrap">Updated</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-48 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
|
@ -112,8 +163,19 @@
|
|||
<tr
|
||||
v-for="definition in filteredDefinitions"
|
||||
:key="definition.id"
|
||||
class="hover:bg-muted/20"
|
||||
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="definition.id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold text-highlighted">
|
||||
|
|
@ -123,7 +185,7 @@
|
|||
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 transition hover:bg-muted"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggle(definition)"
|
||||
>
|
||||
<UIcon
|
||||
|
|
@ -134,19 +196,19 @@
|
|||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5" />
|
||||
<span
|
||||
>{{ definition.match_url.length }} match pattern{{
|
||||
definition.match_url.length === 1 ? '' : 's'
|
||||
}}</span
|
||||
>
|
||||
<span>Patterns: {{ definition.match_url.length }} match/s</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 text-center align-middle">{{ definition.priority }}</td>
|
||||
<td class="px-3 py-3 text-center align-middle">
|
||||
{{ definition.priority }}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-3 text-center align-middle whitespace-nowrap">
|
||||
<UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')">
|
||||
|
|
@ -158,36 +220,36 @@
|
|||
</UTooltip>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
@click="() => void exportDefinition(definition)"
|
||||
>
|
||||
<span v-if="!isMobile">Export</span>
|
||||
<span class="hidden sm:inline">Export</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="() => void openEdit(definition)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void remove(definition)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
<span class="hidden sm:inline">Delete</span>
|
||||
</UButton>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -201,110 +263,122 @@
|
|||
v-else-if="filteredDefinitions.length > 0"
|
||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<UCard
|
||||
<div
|
||||
v-for="definition in filteredDefinitions"
|
||||
:key="definition.id"
|
||||
class="flex h-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
class="min-w-0 w-full max-w-full"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="text-sm font-semibold text-highlighted wrap-break-word">
|
||||
{{ definition.name || '(Unnamed definition)' }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggle(definition)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="definition.enabled ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority {{ definition.priority }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5" />
|
||||
<span
|
||||
>{{ definition.match_url.length }} match pattern{{
|
||||
definition.match_url.length === 1 ? '' : 's'
|
||||
}}</span
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(definition.id, 'title')"
|
||||
>
|
||||
<span :class="['block', expandClass(definition.id, 'title')]">
|
||||
{{ definition.name || '(Unnamed)' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="() => void exportDefinition(definition)"
|
||||
>
|
||||
<span>Export Definition</span>
|
||||
</UButton>
|
||||
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedIds"
|
||||
class="completed-checkbox size-4 rounded border-default"
|
||||
type="checkbox"
|
||||
:value="definition.id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggle(definition)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
class="size-3.5"
|
||||
:class="definition.enabled ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
>
|
||||
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||
<span>Priority: {{ definition.priority }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(definition.id, 'patterns')"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">URL patterns</div>
|
||||
<span :class="['block', expandClass(definition.id, 'patterns')]">
|
||||
{{ definition.match_url.join('\n') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="info"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="() => void exportDefinition(definition)"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3 text-sm text-default">
|
||||
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5" />
|
||||
<span>Match patterns</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="pattern in definition.match_url.slice(0, 3)"
|
||||
:key="pattern"
|
||||
class="truncate text-default"
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="() => void openEdit(definition)"
|
||||
>
|
||||
{{ pattern }}
|
||||
</div>
|
||||
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
|
||||
+{{ definition.match_url.length - 3 }} more
|
||||
</div>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void remove(definition)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="() => void openEdit(definition)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void remove(definition)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -324,10 +398,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
|
||||
>Clear filter</UButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -342,11 +412,19 @@
|
|||
<UModal
|
||||
v-if="isEditorOpen"
|
||||
:open="isEditorOpen"
|
||||
:title="editorTitle"
|
||||
:description="editorDescription"
|
||||
:title="
|
||||
editorMode === 'create'
|
||||
? 'Create Task Definition'
|
||||
: `Edit - ${currentSummary?.name || 'Task Definition'}`
|
||||
"
|
||||
:description="
|
||||
editorLoading
|
||||
? 'Loading full definition before editing.'
|
||||
: 'Use the GUI editor when it fits, or switch to advanced JSON for full control.'
|
||||
"
|
||||
:dismissible="!editorLoading && !editorSubmitting"
|
||||
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && closeEditor()"
|
||||
@update:open="handleEditorOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<TaskDefinitionEditor
|
||||
|
|
@ -356,7 +434,8 @@
|
|||
:loading="editorLoading"
|
||||
:submitting="editorSubmitting"
|
||||
@submit="submitDefinition"
|
||||
@cancel="closeEditor"
|
||||
@cancel="() => void requestCloseEditor()"
|
||||
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||
@import-existing="importExistingDefinition"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -378,14 +457,17 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import moment from 'moment';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
import { copyText, encode } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
import type {
|
||||
TaskDefinitionDetailed,
|
||||
|
|
@ -412,7 +494,9 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
|||
},
|
||||
};
|
||||
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const pageShell = requirePageShell('task-definitions');
|
||||
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
|
||||
const taskDefs = useTaskDefinitionsComposable();
|
||||
const definitionsRef = taskDefs.definitions;
|
||||
|
|
@ -430,6 +514,7 @@ const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.v
|
|||
const { confirmDialog } = useDialog();
|
||||
|
||||
const isEditorOpen = ref(false);
|
||||
const editorDirty = ref(false);
|
||||
const editorMode = ref<'create' | 'edit'>('create');
|
||||
const editorLoading = ref(false);
|
||||
const editorSubmitting = ref(false);
|
||||
|
|
@ -437,11 +522,14 @@ const workingDefinition = ref<TaskDefinitionDocument | null>(null);
|
|||
const workingId = ref<number | null>(null);
|
||||
const inspect = ref(false);
|
||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const hideImportByDefault = ref(false);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const massDelete = ref(false);
|
||||
|
||||
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
|
||||
const normalizedQuery = query.value.trim().toLowerCase();
|
||||
|
|
@ -463,6 +551,35 @@ const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const selectableDefinitionIds = computed(() =>
|
||||
filteredDefinitions.value
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableDefinitionIds.value.length > 0 &&
|
||||
selectableDefinitionIds.value.every((id) => selectedIds.value.includes(id)),
|
||||
);
|
||||
|
||||
const hasSelected = computed(() => selectedIds.value.length > 0);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : display_style.value,
|
||||
);
|
||||
|
||||
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
label: 'Remove Selected',
|
||||
icon: 'i-lucide-trash',
|
||||
color: 'error',
|
||||
disabled: !hasSelected.value || massDelete.value,
|
||||
onSelect: () => void deleteSelected(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
||||
if (editorMode.value !== 'edit' || !workingId.value) {
|
||||
return undefined;
|
||||
|
|
@ -471,30 +588,45 @@ const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
|||
return definitions.value.find((item) => item.id === workingId.value);
|
||||
});
|
||||
|
||||
const editorTitle = computed(() => {
|
||||
return editorMode.value === 'create'
|
||||
? 'Create Task Definition'
|
||||
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
|
||||
});
|
||||
|
||||
const editorDescription = computed(() => {
|
||||
if (editorLoading.value) {
|
||||
return 'Loading full definition before editing.';
|
||||
}
|
||||
|
||||
return 'Use the GUI editor when it fits, or switch to advanced JSON for full control.';
|
||||
});
|
||||
|
||||
const showImportByDefault = computed(
|
||||
() => editorMode.value === 'create' && !hideImportByDefault.value,
|
||||
);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
editorDirty.value = false;
|
||||
workingDefinition.value = null;
|
||||
workingId.value = null;
|
||||
editorLoading.value = false;
|
||||
editorSubmitting.value = false;
|
||||
hideImportByDefault.value = false;
|
||||
};
|
||||
|
||||
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
||||
useDirtyCloseGuard(isEditorOpen, {
|
||||
dirty: editorDirty,
|
||||
message: 'You have unsaved task definition changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
watch(showFilter, (value) => {
|
||||
if (!value) {
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredDefinitions,
|
||||
(items) => {
|
||||
const validIds = new Set(
|
||||
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
||||
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
|
||||
};
|
||||
|
|
@ -507,7 +639,7 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const reloadContent = async (): Promise<void> => {
|
||||
|
|
@ -518,7 +650,17 @@ const toggleDisplayStyle = (): void => {
|
|||
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIds.value = [...selectableDefinitionIds.value];
|
||||
};
|
||||
|
||||
const openCreate = (): void => {
|
||||
editorDirty.value = false;
|
||||
editorMode.value = 'create';
|
||||
workingId.value = null;
|
||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
||||
|
|
@ -529,6 +671,7 @@ const openCreate = (): void => {
|
|||
};
|
||||
|
||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
editorDirty.value = false;
|
||||
editorMode.value = 'edit';
|
||||
workingId.value = summary.id;
|
||||
workingDefinition.value = null;
|
||||
|
|
@ -559,6 +702,7 @@ const importExistingDefinition = async (id: number): Promise<void> => {
|
|||
return;
|
||||
}
|
||||
|
||||
editorDirty.value = false;
|
||||
editorMode.value = 'create';
|
||||
workingId.value = null;
|
||||
workingDefinition.value = {
|
||||
|
|
@ -578,6 +722,7 @@ const closeEditor = (): void => {
|
|||
return;
|
||||
}
|
||||
|
||||
editorDirty.value = false;
|
||||
isEditorOpen.value = false;
|
||||
workingDefinition.value = null;
|
||||
workingId.value = null;
|
||||
|
|
@ -625,6 +770,48 @@ const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
|
|||
await deleteDefinition(summary.id);
|
||||
};
|
||||
|
||||
const deleteSelected = async (): Promise<void> => {
|
||||
if (selectedIds.value.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Task Definitions',
|
||||
message:
|
||||
`Delete ${selectedIds.value.length} task definition/s?` +
|
||||
'\n\n' +
|
||||
selectedIds.value
|
||||
.map((id) => {
|
||||
const item = filteredDefinitions.value.find((definition) => definition.id === id);
|
||||
return item ? `${item.id}: ${item.name || '(Unnamed definition)'}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToDelete = filteredDefinitions.value.filter(
|
||||
(item) => item.id && selectedIds.value.includes(item.id),
|
||||
);
|
||||
if (itemsToDelete.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
massDelete.value = true;
|
||||
|
||||
for (const item of itemsToDelete) {
|
||||
await deleteDefinition(item.id);
|
||||
}
|
||||
|
||||
selectedIds.value = [];
|
||||
massDelete.value = false;
|
||||
};
|
||||
|
||||
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
await toggleEnabled(summary.id, !summary.enabled);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,35 +1,27 @@
|
|||
<template>
|
||||
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||
<UIcon name="i-lucide-list-todo" class="size-5 text-toned" />
|
||||
<span>Tasks</span>
|
||||
</div>
|
||||
<main class="w-full min-w-0 max-w-full space-y-6">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
||||
>
|
||||
<UIcon :name="pageShell.icon" class="size-5" />
|
||||
</span>
|
||||
|
||||
<p class="text-sm text-toned">
|
||||
The task runner is simple queue system that allows you to poll channels or playlists for
|
||||
new content at specified intervals.
|
||||
</p>
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
||||
>
|
||||
<span>{{ pageShell.sectionLabel }}</span>
|
||||
<span>/</span>
|
||||
<span>{{ pageShell.pageLabel }}</span>
|
||||
</div>
|
||||
|
||||
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div v-if="showFilter && tasks.length > 0" class="relative w-full sm:w-80">
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
||||
>
|
||||
<UIcon name="i-lucide-filter" class="size-4" />
|
||||
</span>
|
||||
<input
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<UButton
|
||||
v-if="tasks.length > 0"
|
||||
color="neutral"
|
||||
|
|
@ -38,7 +30,7 @@
|
|||
icon="i-lucide-filter"
|
||||
@click="toggleFilterPanel"
|
||||
>
|
||||
<span v-if="!isMobile">Filter</span>
|
||||
<span>Filter</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -48,7 +40,7 @@
|
|||
icon="i-lucide-plus"
|
||||
@click="openCreateForm"
|
||||
>
|
||||
<span v-if="!isMobile">New Task</span>
|
||||
<span>New Task</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -56,9 +48,10 @@
|
|||
variant="outline"
|
||||
size="sm"
|
||||
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||
class="hidden sm:inline-flex"
|
||||
@click="toggleDisplayStyle"
|
||||
>
|
||||
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
|
|
@ -71,8 +64,20 @@
|
|||
:disabled="isLoading"
|
||||
@click="() => void reloadContent()"
|
||||
>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
<span>Reload</span>
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
v-if="showFilter && tasks.length > 0"
|
||||
id="filter"
|
||||
ref="filterInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Filter displayed content"
|
||||
icon="i-lucide-filter"
|
||||
size="sm"
|
||||
class="order-last w-full sm:order-first sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -112,13 +117,15 @@
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayStyle === 'list' && filteredTasks.length > 0"
|
||||
v-if="contentStyle === 'list' && filteredTasks.length > 0"
|
||||
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||
>
|
||||
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||
<table class="min-w-190 table-fixed w-full text-sm">
|
||||
<table class="min-w-210 table-fixed w-full text-sm">
|
||||
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||
<tr
|
||||
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
||||
>
|
||||
<th class="w-12">
|
||||
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
|
||||
<UIcon
|
||||
|
|
@ -128,13 +135,17 @@
|
|||
</button>
|
||||
</th>
|
||||
<th class="w-full text-left">Task</th>
|
||||
<th class="w-100 whitespace-nowrap">Timer</th>
|
||||
<th class="w-44 whitespace-nowrap">Actions</th>
|
||||
<th class="w-50 whitespace-nowrap">Timer</th>
|
||||
<th class="w-75 whitespace-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="item in filteredTasks" :key="item.id" class="align-top hover:bg-muted/20">
|
||||
<tr
|
||||
v-for="item in filteredTasks"
|
||||
:key="item.id"
|
||||
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
||||
>
|
||||
<td class="px-3 py-3 text-center align-top">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
|
|
@ -184,7 +195,7 @@
|
|||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggleEnabled(item)"
|
||||
@click="() => void toggleFlag(item, 'enabled')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
|
|
@ -194,27 +205,30 @@
|
|||
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggleFlag(item, 'auto_start')"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'"
|
||||
name="i-lucide-circle-play"
|
||||
class="size-3.5"
|
||||
:class="item.auto_start ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span>
|
||||
</span>
|
||||
<span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggleHandlerEnabled(item)"
|
||||
@click="() => void toggleFlag(item, 'handler_enabled')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-rss"
|
||||
class="size-3.5"
|
||||
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ item.handler_enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
<span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
|
|
@ -222,27 +236,10 @@
|
|||
>
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
||||
<span class="capitalize">
|
||||
{{ item.preset ?? config.app.default_preset }}
|
||||
Preset: {{ item.preset ?? config.app.default_preset }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs text-toned">
|
||||
<div v-if="item.folder" class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span class="break-all">{{ calcPath(item.folder) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item.template" class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span class="break-all">{{ item.template }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item.cli" class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span class="break-all">{{ item.cli }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -273,7 +270,7 @@
|
|||
>
|
||||
<span class="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
<UIcon name="i-lucide-triangle-alert" class="size-3.5" />
|
||||
<span>No timer or handler</span>
|
||||
<span>Not configured</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
|
@ -286,26 +283,26 @@
|
|||
</div>
|
||||
</td>
|
||||
|
||||
<td class="w-44 px-3 py-3 align-top whitespace-nowrap">
|
||||
<td class="w-56 px-3 py-3 align-top whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="warning"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-pencil"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Edit</span>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
<span v-if="!isMobile">Delete</span>
|
||||
Delete
|
||||
</UButton>
|
||||
|
||||
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
|
||||
|
|
@ -316,7 +313,7 @@
|
|||
icon="i-lucide-settings-2"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
<span v-if="!isMobile">Actions</span>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
|
@ -334,7 +331,11 @@
|
|||
<div v-for="item in filteredTasks" :key="item.id" class="min-w-0 w-full max-w-full">
|
||||
<UCard
|
||||
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
|
||||
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||
:ui="{
|
||||
header: 'p-4 pb-3',
|
||||
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
|
||||
footer: 'border-t border-default px-4 py-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
|
|
@ -343,10 +344,20 @@
|
|||
<NuxtLink
|
||||
target="_blank"
|
||||
:href="item.url"
|
||||
class="min-w-0 flex-1 truncate text-sm font-semibold text-highlighted hover:underline"
|
||||
class="mt-0.5 shrink-0 text-toned transition hover:text-highlighted"
|
||||
aria-label="Open source URL"
|
||||
>
|
||||
{{ remove_tags(item.name) }}
|
||||
<UIcon name="i-lucide-external-link" class="size-4" />
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
|
||||
@click="toggleExpand(item.id, 'title')"
|
||||
>
|
||||
<span :class="['block', expandClass(item.id, 'title')]">
|
||||
{{ remove_tags(item.name) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<UIcon
|
||||
v-if="item.id && isTaskInProgress(item.id)"
|
||||
|
|
@ -355,7 +366,10 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-if="get_tags(item.name).length > 0"
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<UBadge
|
||||
v-for="tag in get_tags(item.name)"
|
||||
:key="`${item.id}-${tag}`"
|
||||
|
|
@ -370,14 +384,15 @@
|
|||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
color="info"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-lucide-file-up"
|
||||
square
|
||||
@click="() => void exportItem(item)"
|
||||
/>
|
||||
|
||||
>
|
||||
<span>Export Task</span>
|
||||
</UButton>
|
||||
<label class="inline-flex cursor-pointer items-center justify-center">
|
||||
<input
|
||||
v-model="selectedElms"
|
||||
|
|
@ -391,11 +406,11 @@
|
|||
</template>
|
||||
|
||||
<div class="space-y-2 text-sm text-default">
|
||||
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-toned sm:flex sm:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggleEnabled(item)"
|
||||
class="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
|
||||
@click="() => void toggleFlag(item, 'enabled')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-power"
|
||||
|
|
@ -405,39 +420,55 @@
|
|||
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
|
||||
@click="() => void toggleFlag(item, 'auto_start')"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'"
|
||||
name="i-lucide-circle-play"
|
||||
class="size-3.5"
|
||||
:class="item.auto_start ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span>
|
||||
</span>
|
||||
<span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||
@click="() => void toggleHandlerEnabled(item)"
|
||||
class="flex min-w-0 w-full items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default sm:w-auto sm:flex-none sm:shrink-0 sm:whitespace-nowrap"
|
||||
@click="() => void toggleFlag(item, 'handler_enabled')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-rss"
|
||||
class="size-3.5"
|
||||
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'"
|
||||
/>
|
||||
<span>{{ item.handler_enabled !== false ? 'Handler on' : 'Handler off' }}</span>
|
||||
<span>Handler: {{ item.handler_enabled !== false ? 'On' : 'Off' }}</span>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-1 rounded-md border border-default px-2 py-1 text-left transition hover:border-primary hover:text-default sm:flex-1"
|
||||
@click="toggleExpand(item.id, 'preset')"
|
||||
>
|
||||
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
||||
<span class="capitalize">{{ item.preset ?? config.app.default_preset }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span :class="['min-w-0 capitalize', expandClass(item.id, 'preset')]">
|
||||
Preset: {{ item.preset ?? config.app.default_preset }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!item.folder && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(item.id, 'schedule')"
|
||||
>
|
||||
<UIcon
|
||||
:name="
|
||||
item.timer
|
||||
|
|
@ -451,88 +482,120 @@
|
|||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Schedule</div>
|
||||
<template v-if="item.timer">
|
||||
<a
|
||||
target="_blank"
|
||||
:href="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`"
|
||||
class="break-all text-highlighted hover:underline"
|
||||
class="block text-highlighted hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
{{ item.timer }}
|
||||
<span :class="['block', expandClass(item.id, 'schedule')]">
|
||||
{{ item.timer }} ( {{ tryParse(item.timer) }} )
|
||||
</span>
|
||||
</a>
|
||||
<p
|
||||
class="mt-1 text-xs"
|
||||
:class="tryParse(item.timer) === 'Invalid' ? 'text-error' : 'text-toned'"
|
||||
>
|
||||
{{ tryParse(item.timer) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-else-if="willTaskBeProcessed(item)" class="text-toned whitespace-nowrap">
|
||||
<p
|
||||
v-else-if="willTaskBeProcessed(item)"
|
||||
:class="['text-sm text-default', expandClass(item.id, 'schedule')]"
|
||||
>
|
||||
Handler only
|
||||
</p>
|
||||
<p v-else class="text-error whitespace-nowrap">No timer or handler</p>
|
||||
<p v-else :class="['text-sm text-error', expandClass(item.id, 'schedule')]">
|
||||
Not configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="item.folder" class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
v-if="item.folder"
|
||||
type="button"
|
||||
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
|
||||
@click="toggleExpand(item.id, 'folder')"
|
||||
>
|
||||
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="break-all text-toned">{{ calcPath(item.folder) }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Download path</div>
|
||||
<span :class="['block', expandClass(item.id, 'folder')]">
|
||||
{{ calcPath(item.folder) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.template"
|
||||
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
v-if="item.template"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!item.cli && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(item.id, 'template')"
|
||||
>
|
||||
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="break-all text-toned">{{ item.template }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">Output template</div>
|
||||
<span :class="['block', expandClass(item.id, 'template')]">{{
|
||||
item.template
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="item.cli" class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
v-if="item.cli"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
|
||||
!item.template && 'sm:col-span-2',
|
||||
]"
|
||||
@click="toggleExpand(item.id, 'cli')"
|
||||
>
|
||||
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||
<span class="break-all text-toned">{{ item.cli }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium text-toned">CLI options</div>
|
||||
<span :class="['block', expandClass(item.id, 'cli')]">{{ item.cli }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="warning"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
Edit
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
|
||||
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
|
||||
<template #footer>
|
||||
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-settings-2"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
icon="i-lucide-pencil"
|
||||
class="w-full justify-center"
|
||||
@click="editItem(item)"
|
||||
>
|
||||
Actions
|
||||
Edit
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash"
|
||||
class="w-full justify-center"
|
||||
@click="() => void deleteItem(item)"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
|
||||
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-settings-2"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
class="w-full justify-center"
|
||||
>
|
||||
Actions
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -554,10 +617,6 @@
|
|||
title="No Results"
|
||||
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||
/>
|
||||
|
||||
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||
Clear filter
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
|
|
@ -603,11 +662,15 @@
|
|||
<UModal
|
||||
v-if="toggleForm"
|
||||
:open="toggleForm"
|
||||
:title="editorTitle"
|
||||
:description="editorDescription"
|
||||
:title="taskRef ? `Edit - ${task.name || 'Task'}` : 'Add new task'"
|
||||
:description="
|
||||
taskRef
|
||||
? 'Update the settings of your automated download task'
|
||||
: 'Create an automated download task'
|
||||
"
|
||||
:dismissible="!addInProgress"
|
||||
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||
@update:open="(open) => !open && closeEditor()"
|
||||
@update:open="handleEditorOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<TaskForm
|
||||
|
|
@ -615,7 +678,8 @@
|
|||
:addInProgress="addInProgress"
|
||||
:reference="taskRef"
|
||||
:task="task as Task"
|
||||
@cancel="closeEditor"
|
||||
@cancel="() => void requestCloseEditor()"
|
||||
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -642,6 +706,7 @@ import type { DropdownMenuItem } from '@nuxt/ui';
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
import { useTasks } from '~/composables/useTasks';
|
||||
import TaskInspect from '~/components/TaskInspect.vue';
|
||||
import type { ExportedTask, Task } from '~/types/tasks';
|
||||
|
|
@ -649,16 +714,19 @@ import type { WSEP } from '~/types/sockets';
|
|||
import { sleep } from '~/utils';
|
||||
import { useSessionCache } from '~/utils/cache';
|
||||
import type { item_request } from '~/types/item';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
||||
const box = useConfirm();
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const socket = useSocketStore();
|
||||
const stateStore = useStateStore();
|
||||
const config = useYtpConfig();
|
||||
const socket = useAppSocket();
|
||||
const stateStore = useQueueState();
|
||||
const pageShell = requirePageShell('tasks');
|
||||
const { confirmDialog } = useDialog();
|
||||
const sessionCache = useSessionCache();
|
||||
const { toggleExpand, expandClass } = useExpandableMeta();
|
||||
const display_style = useStorage<'list' | 'grid' | 'cards'>('tasks_display_style', 'grid');
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const isMobile = useMediaQuery({ maxWidth: 639 });
|
||||
|
||||
const tasksComposable = useTasks();
|
||||
const {
|
||||
|
|
@ -686,34 +754,43 @@ const createEmptyTask = (): Partial<Task> => ({
|
|||
const task = ref<Partial<Task>>(createEmptyTask());
|
||||
const taskRef = ref<number | null>(null);
|
||||
const toggleForm = ref(false);
|
||||
const editorDirty = ref(false);
|
||||
const selectedElms = ref<number[]>([]);
|
||||
const massRun = ref(false);
|
||||
const massDelete = ref(false);
|
||||
const inspectTask = ref<Task | null>(null);
|
||||
const query = ref('');
|
||||
const showFilter = ref(false);
|
||||
const filterInput = ref<HTMLInputElement | null>(null);
|
||||
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||
const CACHE_KEY = 'tasks:handler_support';
|
||||
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {});
|
||||
|
||||
const displayStyle = computed<'list' | 'grid'>(() =>
|
||||
display_style.value === 'list' ? 'list' : 'grid',
|
||||
);
|
||||
const contentStyle = computed<'list' | 'grid'>(() =>
|
||||
isMobile.value ? 'grid' : displayStyle.value,
|
||||
);
|
||||
|
||||
const editorSessionId = ref(0);
|
||||
|
||||
const editorTitle = computed(() => {
|
||||
return taskRef.value ? `Edit - ${task.value.name}` : 'Add new task';
|
||||
});
|
||||
|
||||
const editorDescription = computed(() => {
|
||||
return taskRef.value
|
||||
? 'Update the settings of your automated download task'
|
||||
: 'Create an automated download task';
|
||||
});
|
||||
|
||||
const formKey = computed(() => `${taskRef.value ?? 'new'}:${editorSessionId.value}`);
|
||||
|
||||
const discardEditor = (): void => {
|
||||
editorDirty.value = false;
|
||||
task.value = createEmptyTask();
|
||||
taskRef.value = null;
|
||||
};
|
||||
|
||||
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
|
||||
useDirtyCloseGuard(toggleForm, {
|
||||
dirty: editorDirty,
|
||||
message: 'You have unsaved task changes. Do you want to discard them?',
|
||||
onDiscard: async () => {
|
||||
discardEditor();
|
||||
},
|
||||
});
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const normalizedQuery = query.value?.toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
|
|
@ -792,7 +869,7 @@ const toggleFilterPanel = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
await nextTick();
|
||||
filterInput.value?.focus();
|
||||
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const toggleMasterSelection = (): void => {
|
||||
|
|
@ -882,6 +959,7 @@ const reloadContent = async (fromMounted: boolean = false) => {
|
|||
const resetForm = (closeForm: boolean = false) => {
|
||||
task.value = createEmptyTask();
|
||||
taskRef.value = null;
|
||||
editorDirty.value = false;
|
||||
|
||||
if (closeForm) {
|
||||
toggleForm.value = false;
|
||||
|
|
@ -906,15 +984,16 @@ const deleteSelected = async () => {
|
|||
|
||||
const { status } = await confirmDialog({
|
||||
title: 'Delete Selected Tasks',
|
||||
rawHTML:
|
||||
`Delete <strong class="text-red-500">${selectedElms.value.length}</strong> task/s?<ul>` +
|
||||
message:
|
||||
`Delete ${selectedElms.value.length} task/s?` +
|
||||
'\n\n' +
|
||||
selectedElms.value
|
||||
.map((id) => {
|
||||
const item = tasks.value.find((task) => task.id === id);
|
||||
return item ? `<li>${item.id}: ${item.name}</li>` : '';
|
||||
return item ? `${item.id}: ${item.name}` : '';
|
||||
})
|
||||
.join('') +
|
||||
'</ul>',
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
confirmText: 'Delete',
|
||||
confirmColor: 'error',
|
||||
});
|
||||
|
|
@ -956,34 +1035,23 @@ const deleteItem = async (item: Task) => {
|
|||
await tasksComposable.deleteTask(item.id);
|
||||
};
|
||||
|
||||
const toggleEnabled = async (item: Task) => {
|
||||
const toggleFlag = async (item: Task, field: 'enabled' | 'auto_start' | 'handler_enabled') => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await tasksComposable.patchTask(item.id, { enabled: !item.enabled });
|
||||
const currentValue = item[field] !== false;
|
||||
const updated = await tasksComposable.patchTask(item.id, { [field]: !currentValue });
|
||||
|
||||
if (updated) {
|
||||
item.enabled = updated.enabled;
|
||||
if (updated.enabled) {
|
||||
item[field] = updated[field];
|
||||
|
||||
if (field === 'enabled' && updated.enabled) {
|
||||
await checkHandlerSupport(updated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHandlerEnabled = async (item: Task) => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await tasksComposable.patchTask(item.id, {
|
||||
handler_enabled: !item.handler_enabled,
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
item.handler_enabled = updated.handler_enabled;
|
||||
if (updated.handler_enabled) {
|
||||
if (field === 'handler_enabled' && updated.handler_enabled) {
|
||||
await checkHandlerSupport(updated);
|
||||
}
|
||||
}
|
||||
|
|
@ -1036,6 +1104,7 @@ const updateItem = async ({
|
|||
};
|
||||
|
||||
const editItem = (item: Task) => {
|
||||
editorDirty.value = false;
|
||||
task.value = { ...item };
|
||||
taskRef.value = item.id ?? null;
|
||||
editorSessionId.value += 1;
|
||||
|
|
@ -1067,15 +1136,16 @@ const runSelected = async () => {
|
|||
}
|
||||
|
||||
const { status } = await confirmDialog({
|
||||
rawHTML:
|
||||
'Run the following tasks?<ul>' +
|
||||
message:
|
||||
'Run the following tasks?' +
|
||||
'\n\n' +
|
||||
selectedElms.value
|
||||
.map((id) => {
|
||||
const item = tasks.value.find((task) => task.id === id);
|
||||
return item ? `<li>${item.name}</li>` : '';
|
||||
return item ? item.name : '';
|
||||
})
|
||||
.join('') +
|
||||
'</ul>',
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
|
|
@ -1262,24 +1332,13 @@ const generateMeta = async (item: Task) => {
|
|||
|
||||
try {
|
||||
const { status } = await confirmDialog({
|
||||
rawHTML: `
|
||||
<p>
|
||||
Generate '${item.name}' metadata? you will be notified when it is done.
|
||||
</p>
|
||||
<p>
|
||||
<b>This action will generate:</b>
|
||||
<ul>
|
||||
<li><strong>tvshow.nfo</strong> - for media center compatibility</li>
|
||||
<li><strong>title [id].info.json</strong> - yt-dlp metadata file</li>
|
||||
<li>
|
||||
<strong>Thumbnails</strong>: poster.jpg, fanart.jpg, thumb.jpg, banner.jpg, icon.jpg, landscape.jpg
|
||||
<u>if they are available</u>.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p class="text-red-500">
|
||||
<span>Warning</span>: This will overwrite existing metadata files if they exist.
|
||||
</p>`,
|
||||
message:
|
||||
`Generate '${item.name}' metadata? You will be notified when it is done.` +
|
||||
'\n\nThis action will generate:' +
|
||||
'\n- tvshow.nfo - for media center compatibility' +
|
||||
'\n- title [id].info.json - yt-dlp metadata file' +
|
||||
'\n- Thumbnails: poster.jpg, fanart.jpg, thumb.jpg, banner.jpg, icon.jpg, landscape.jpg if they are available.' +
|
||||
'\n\nWarning: This will overwrite existing metadata files if they exist.',
|
||||
});
|
||||
|
||||
if (true !== status) {
|
||||
|
|
@ -1302,7 +1361,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
|
|||
{
|
||||
label: 'Run now',
|
||||
icon: 'i-lucide-square-play',
|
||||
color: 'primary',
|
||||
onSelect: () => void runNow(item),
|
||||
},
|
||||
{
|
||||
|
|
@ -1336,7 +1394,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
|
|||
{
|
||||
label: 'Export Task',
|
||||
icon: 'i-lucide-file-up',
|
||||
color: 'info',
|
||||
onSelect: () => void exportItem(item),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
import { disableOpacity, enableOpacity, syncOpacity } from '~/utils';
|
||||
|
||||
const OVERLAY_SELECTOR = '[data-slot="overlay"]';
|
||||
const SETTINGS_PANEL_SELECTOR = '.yt-settings-panel';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (import.meta.server) {
|
||||
|
|
@ -11,7 +12,19 @@ export default defineNuxtPlugin(() => {
|
|||
let isLocked = false;
|
||||
|
||||
const syncOverlayOpacity = (): void => {
|
||||
const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null;
|
||||
const overlays = Array.from(document.querySelectorAll(OVERLAY_SELECTOR));
|
||||
const hasOverlay = overlays.length > 0;
|
||||
const isSettingsOnlyOverlay =
|
||||
overlays.length === 1 && document.querySelector(SETTINGS_PANEL_SELECTOR) !== null;
|
||||
|
||||
if (isSettingsOnlyOverlay) {
|
||||
if (isLocked) {
|
||||
enableOpacity();
|
||||
isLocked = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOverlay && !isLocked) {
|
||||
disableOpacity();
|
||||
|
|
@ -19,6 +32,11 @@ export default defineNuxtPlugin(() => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (hasOverlay) {
|
||||
syncOpacity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasOverlay && isLocked) {
|
||||
enableOpacity();
|
||||
isLocked = false;
|
||||
|
|
@ -40,18 +58,4 @@ export default defineNuxtPlugin(() => {
|
|||
} else {
|
||||
startObserver();
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
'beforeunload',
|
||||
() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
|
||||
if (isLocked) {
|
||||
enableOpacity();
|
||||
isLocked = false;
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,138 +1,206 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>YTPTube Loading...</title>
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<title>Loading YTPTube</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #edf2f9;
|
||||
--bg-deep: #dbe5f1;
|
||||
--panel: rgb(255 255 255 / 0.84);
|
||||
--border: rgb(15 23 42 / 0.1);
|
||||
--shadow: rgb(15 23 42 / 0.14);
|
||||
--text: #0f172a;
|
||||
--muted: #5b6b80;
|
||||
--accent: #dc2626;
|
||||
--accent-soft: rgb(220 38 38 / 0.18);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #08111d;
|
||||
--bg-deep: #0f172a;
|
||||
--panel: rgb(15 23 42 / 0.84);
|
||||
--border: rgb(148 163 184 / 0.16);
|
||||
--shadow: rgb(2 6 23 / 0.45);
|
||||
--text: #e7eef9;
|
||||
--muted: #9fb0c7;
|
||||
--accent: #f87171;
|
||||
--accent-soft: rgb(248 113 113 / 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
color: #111;
|
||||
user-select: none;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html,
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #eee;
|
||||
}
|
||||
color: var(--text);
|
||||
user-select: none;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, var(--accent-soft), transparent 24%),
|
||||
linear-gradient(180deg, var(--bg-deep), var(--bg));
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
text-align: center;
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
|
||||
background-size: 2rem 2rem;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
width: min(100%, 29rem);
|
||||
position: relative;
|
||||
padding: 1.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.1rem;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1rem 2.5rem var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
svg.ytptube-loading {
|
||||
width: 420px;
|
||||
height: 105px;
|
||||
stroke: #ff0000;
|
||||
stroke-width: 4.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
stroke-dasharray: 700;
|
||||
stroke-dashoffset: 700;
|
||||
animation: stroke-move 3s linear infinite;
|
||||
user-select: none;
|
||||
.brand img {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
flex: none;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
svg.ytptube-loading {
|
||||
stroke: #ff4444;
|
||||
.brand-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 1.55rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin-top: 1.2rem;
|
||||
height: 0.32rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(148 163 184 / 0.22);
|
||||
}
|
||||
|
||||
.loader::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
animation: slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.95rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
0% {
|
||||
transform: translateX(-130%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stroke-move {
|
||||
100% {
|
||||
stroke-dashoffset: -700;
|
||||
transform: translateX(330%);
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin: 20px auto 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 8px solid transparent;
|
||||
border-top-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s linear infinite;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.loading-card {
|
||||
padding: 1.3rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.spinner {
|
||||
border-top-color: #ff4444;
|
||||
filter: drop-shadow(0 0 8px #ff4444);
|
||||
.brand {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 14px;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
||||
<svg
|
||||
class="ytptube-loading"
|
||||
viewBox="0 0 420 105"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<!-- Y -->
|
||||
<path d="M10 10 L25 37.5 L10 65" />
|
||||
<path d="M25 37.5 L40 10" />
|
||||
<!-- T -->
|
||||
<path d="M50 10 L90 10" />
|
||||
<path d="M70 10 L70 65" />
|
||||
<!-- P -->
|
||||
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
||||
<!-- T -->
|
||||
<path d="M160 10 L200 10" />
|
||||
<path d="M180 10 L180 65" />
|
||||
<!-- U -->
|
||||
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
||||
<!-- B -->
|
||||
<path d="M270 10 L270 65" />
|
||||
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
||||
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
||||
<!-- E -->
|
||||
<path d="M320 10 L320 65" />
|
||||
<path d="M320 10 L360 10" />
|
||||
<path d="M320 37.5 L350 37.5" />
|
||||
<path d="M320 65 L360 65" />
|
||||
</svg>
|
||||
<div class="spinner"></div>
|
||||
<div class="subtitle">Loading, please wait...</div>
|
||||
</div>
|
||||
<main class="loading-card" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="brand">
|
||||
<img src="/images/favicon.png" alt="" aria-hidden="true" />
|
||||
|
||||
<div class="brand-copy">
|
||||
<p class="label">YTPTube</p>
|
||||
<h1>Loading your dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="description">Getting the interface ready. Please wait a moment...</p>
|
||||
|
||||
<div class="loader" aria-hidden="true"></div>
|
||||
<p class="hint">Please wait...</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
import { useStorage } from '@vueuse/core';
|
||||
import type { ConfigState } from '~/types/config';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
|
||||
import { request } from '~/utils';
|
||||
|
||||
let last_reload = 0;
|
||||
const CONFIG_TTL = 10;
|
||||
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
const state = reactive<ConfigState>({
|
||||
showForm: useStorage('showForm', true),
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
remove_files: false,
|
||||
ui_update_title: true,
|
||||
output_template: '',
|
||||
ytdlp_version: '',
|
||||
max_workers: 20,
|
||||
max_workers_per_extractor: 2,
|
||||
default_preset: 'default',
|
||||
instance_title: null,
|
||||
console_enabled: false,
|
||||
browser_control_enabled: false,
|
||||
file_logging: false,
|
||||
is_native: false,
|
||||
app_version: '',
|
||||
app_commit_sha: '',
|
||||
app_build_date: '',
|
||||
app_branch: '',
|
||||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
default_pagination: 50,
|
||||
check_for_updates: true,
|
||||
new_version: '',
|
||||
yt_new_version: '',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'default',
|
||||
description: 'Default preset',
|
||||
folder: '',
|
||||
template: '',
|
||||
cookies: '',
|
||||
cli: '',
|
||||
default: true,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
ytdlp_options: [],
|
||||
paused: false,
|
||||
is_loaded: false,
|
||||
is_loading: false,
|
||||
});
|
||||
|
||||
const loadConfig = async (force: boolean = false) => {
|
||||
if (state.is_loading) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (state.is_loaded && !force && last_reload > 0) {
|
||||
const age = (now - last_reload) / 1000;
|
||||
if (age < CONFIG_TTL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.is_loaded = false;
|
||||
state.is_loading = true;
|
||||
try {
|
||||
const resp = await request('/api/system/configuration', { timeout: 10 });
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const stateStore = useStateStore();
|
||||
|
||||
if ('number' === typeof data.history_count) {
|
||||
stateStore.setHistoryCount(data.history_count);
|
||||
delete data.history_count;
|
||||
}
|
||||
|
||||
if (data.queue) {
|
||||
stateStore.addAll('queue', data.queue);
|
||||
delete data.queue;
|
||||
}
|
||||
|
||||
setAll(data);
|
||||
state.is_loaded = true;
|
||||
last_reload = now;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to load configuration: ${e}`);
|
||||
} finally {
|
||||
state.is_loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = (key: string, value: any) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
state[parentKey][subKey] = value;
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = value;
|
||||
};
|
||||
|
||||
const get = (key: string, defaultValue: any = null): any => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
return parent?.[subKey] ?? defaultValue;
|
||||
}
|
||||
return (state as any)[key] ?? defaultValue;
|
||||
};
|
||||
const isLoaded = () => state.is_loaded;
|
||||
|
||||
const update = add;
|
||||
|
||||
const getAll = (): ConfigState => state;
|
||||
|
||||
const setAll = (data: Record<string, any>) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
parent[subKey] = data[key];
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = data[key];
|
||||
});
|
||||
|
||||
state.is_loaded = true;
|
||||
};
|
||||
|
||||
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
||||
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
|
||||
|
||||
if (!supportedFeatures.includes(feature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('presets' === feature) {
|
||||
const item = data as Preset;
|
||||
const current = get(feature, []) as Array<Preset>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('dl_fields' === feature) {
|
||||
const item = data as DLField;
|
||||
const current = get(feature, []) as Array<DLField>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ('replace' === action) {
|
||||
state.dl_fields = data as Array<DLField>;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
get,
|
||||
update,
|
||||
getAll,
|
||||
setAll,
|
||||
isLoaded,
|
||||
patch,
|
||||
loadConfig,
|
||||
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
|
||||
add: typeof add;
|
||||
get: typeof get;
|
||||
update: typeof update;
|
||||
getAll: typeof getAll;
|
||||
setAll: typeof setAll;
|
||||
patch: typeof patch;
|
||||
isLoaded: typeof isLoaded;
|
||||
loadConfig: typeof loadConfig;
|
||||
};
|
||||
});
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification, notificationType } from '~/composables/useNotification';
|
||||
|
||||
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
|
||||
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
|
||||
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
|
||||
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
|
||||
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
|
||||
};
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const notifications = useStorage<notification[]>('notifications', []);
|
||||
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||
|
||||
const severityLevel = computed<notificationType | null>(() => {
|
||||
const unread = notifications.value.filter((n) => !n.seen);
|
||||
if (0 === unread.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return unread.reduce((h, n) => (_map[n.level].level > _map[h.level].level ? n : h)).level;
|
||||
});
|
||||
|
||||
const severityColor = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].color : '';
|
||||
});
|
||||
|
||||
const severityIcon = computed<string>(() => {
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].icon : '';
|
||||
});
|
||||
|
||||
const sortedNotifications = computed<notification[]>(() => {
|
||||
return [...notifications.value].sort((a, b) => {
|
||||
const severityDiff = _map[b.level].level - _map[a.level].level;
|
||||
if (0 !== severityDiff) {
|
||||
return severityDiff;
|
||||
}
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
|
||||
dec.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
notifications.value.unshift({
|
||||
id: id,
|
||||
message,
|
||||
level,
|
||||
seen: seen,
|
||||
created: new Date(),
|
||||
});
|
||||
|
||||
if (notifications.value.length > 99) {
|
||||
notifications.value.length = 99;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const clear = () => (notifications.value = []);
|
||||
const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
|
||||
|
||||
const markRead = (id: string) => {
|
||||
const n = notifications.value.find((n) => n.id === id);
|
||||
if (!n) {
|
||||
return;
|
||||
}
|
||||
n.seen = true;
|
||||
};
|
||||
|
||||
const get = (id: string): notification | undefined =>
|
||||
notifications.value.find((n) => n.id === id);
|
||||
|
||||
const remove = (id: string) =>
|
||||
(notifications.value = notifications.value.filter((n) => n.id !== id));
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
severityLevel,
|
||||
severityColor,
|
||||
severityIcon,
|
||||
sortedNotifications,
|
||||
add,
|
||||
get,
|
||||
markAllRead,
|
||||
clear,
|
||||
markRead,
|
||||
remove,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
import { ref, readonly } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
ConfigUpdatePayload,
|
||||
WebSocketClientEmits,
|
||||
WebSocketEnvelope,
|
||||
WSEP as WSEP,
|
||||
} from '~/types/sockets';
|
||||
|
||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void;
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||
type KnownEvent = keyof WSEP;
|
||||
|
||||
export const useSocketStore = defineStore('socket', () => {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const config = useConfigStore();
|
||||
const stateStore = useStateStore();
|
||||
const toast = useNotification();
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref<boolean>(false);
|
||||
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||
const error = ref<string | null>(null);
|
||||
const error_count = ref<number>(0);
|
||||
const wasHidden = ref<boolean>(false);
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const manualDisconnect = ref<boolean>(false);
|
||||
const reconnectAttempts = ref<number>(0);
|
||||
|
||||
const handlers = new Map<string, HandlerRegistry>();
|
||||
|
||||
const emit = <K extends keyof WebSocketClientEmits>(
|
||||
event: K,
|
||||
data: WebSocketClientEmits[K],
|
||||
): void => {
|
||||
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
socket.value.send(JSON.stringify({ event, data }));
|
||||
};
|
||||
|
||||
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void;
|
||||
function on<K extends KnownEvent>(
|
||||
event: K | K[],
|
||||
callback: (event: K, payload: WSEP[K]) => void,
|
||||
withEvent: true,
|
||||
): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||
const handler =
|
||||
true === withEvent
|
||||
? (payload: unknown) => callback(eventName, payload)
|
||||
: (payload: unknown) => callback(payload);
|
||||
|
||||
registry.set(callback, handler);
|
||||
});
|
||||
}
|
||||
|
||||
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void;
|
||||
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void;
|
||||
function off(event: string | string[], callback?: SocketHandler): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
registry.clear();
|
||||
handlers.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
registry.delete(callback);
|
||||
if (0 === registry.size) {
|
||||
handlers.delete(eventName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getSessionId = (): string | null => null;
|
||||
|
||||
const dispatch = (eventName: string, payload: unknown): void => {
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
|
||||
registry.forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
wasHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === wasHidden.value && false === isConnected.value) {
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
if (false === isConnected.value) {
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||
reconnect();
|
||||
}
|
||||
reconnectTimeout.value = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
wasHidden.value = false;
|
||||
};
|
||||
|
||||
const setupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.value >= 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== reconnectTimeout.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
reconnectAttempts.value += 1;
|
||||
reconnectTimeout.value = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (true === isConnected.value) {
|
||||
return;
|
||||
}
|
||||
connect();
|
||||
connectionStatus.value = 'connecting';
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
manualDisconnect.value = true;
|
||||
if (null === socket.value) {
|
||||
return;
|
||||
}
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
cleanupVisibilityListener();
|
||||
};
|
||||
|
||||
const buildWsUrl = (): string => {
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||
|
||||
if (configuredBase) {
|
||||
return new URL(wsPath, configuredBase).toString();
|
||||
}
|
||||
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualDisconnect.value = false;
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
socket.value = new WebSocket(buildWsUrl());
|
||||
|
||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||
window.ws = socket.value;
|
||||
}
|
||||
|
||||
socket.value.addEventListener('open', () => {
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
error_count.value = 0;
|
||||
reconnectAttempts.value = 0;
|
||||
dispatch('connect', null);
|
||||
});
|
||||
|
||||
socket.value.addEventListener('close', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Disconnected from server.';
|
||||
dispatch('disconnect', null);
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('error', () => {
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Connection error: Unknown error';
|
||||
error_count.value += 1;
|
||||
dispatch('connect_error', { message: 'Unknown error' });
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let payload: WebSocketEnvelope | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload?.event || 'string' != typeof payload.event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = payload.data;
|
||||
if ('string' === typeof data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
data = payload.data;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(payload.event, data);
|
||||
});
|
||||
|
||||
setupVisibilityListener();
|
||||
};
|
||||
|
||||
on('connect', () => config.loadConfig(false));
|
||||
|
||||
on('connected', () => {
|
||||
error.value = null;
|
||||
config.loadConfig(false);
|
||||
});
|
||||
|
||||
on('item_added', (data: WSEP['item_added']) => {
|
||||
stateStore.add('queue', data.data._id, data.data);
|
||||
toast.success(
|
||||
`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
|
||||
);
|
||||
});
|
||||
|
||||
on(
|
||||
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||
(event, data: WSEP['log_info']) => {
|
||||
const message =
|
||||
'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra);
|
||||
break;
|
||||
case 'log_success':
|
||||
toast.success(message, extra);
|
||||
break;
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra);
|
||||
break;
|
||||
case 'log_error':
|
||||
toast.error(message, extra);
|
||||
break;
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== stateStore.has('queue', id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning(
|
||||
`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`,
|
||||
);
|
||||
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.remove('queue', id);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== stateStore.has('history', id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stateStore.remove('history', id);
|
||||
});
|
||||
|
||||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.update('history', id, data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.update('queue', id, data.data);
|
||||
}
|
||||
});
|
||||
|
||||
on('item_moved', (data: WSEP['item_moved']) => {
|
||||
const to = data.data.to;
|
||||
const id = data.data.item._id;
|
||||
|
||||
if ('queue' === to) {
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.remove('history', id);
|
||||
}
|
||||
stateStore.add('queue', id, data.data.item);
|
||||
}
|
||||
|
||||
if ('history' === to) {
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.remove('queue', id);
|
||||
}
|
||||
stateStore.add('history', id, data.data.item);
|
||||
}
|
||||
});
|
||||
|
||||
on(
|
||||
['paused', 'resumed'],
|
||||
(event, data: WSEP['paused']) => {
|
||||
const pausedState = Boolean(data.data.paused);
|
||||
config.update('paused', pausedState);
|
||||
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('config_update', (data: WSEP['config_update']) => {
|
||||
const configUpdate = data.data as ConfigUpdatePayload;
|
||||
if (!configUpdate) {
|
||||
return;
|
||||
}
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||
});
|
||||
|
||||
return {
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
isConnected,
|
||||
getSessionId,
|
||||
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
||||
error: readonly(error) as Readonly<Ref<string | null>>,
|
||||
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import type { item_request } from '~/types/item';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import { request } from '~/utils';
|
||||
|
||||
type StateType = 'queue' | 'history';
|
||||
type KeyType = string;
|
||||
|
||||
interface State {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history: Record<KeyType, StoreItem>;
|
||||
pagination: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useStateStore = defineStore('state', () => {
|
||||
const state = reactive<State>({
|
||||
queue: {},
|
||||
history: {},
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
total: 0,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
isLoaded: false,
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total += 1;
|
||||
}
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const remove = (type: StateType, key: KeyType): void => {
|
||||
if (!state[type][key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total -= 1;
|
||||
}
|
||||
|
||||
const { [key]: _, ...rest } = state[type];
|
||||
state[type] = rest;
|
||||
};
|
||||
|
||||
const get = (
|
||||
type: StateType,
|
||||
key: KeyType,
|
||||
defaultValue: StoreItem | null = null,
|
||||
): StoreItem | null => {
|
||||
return state[type][key] || defaultValue;
|
||||
};
|
||||
|
||||
const has = (type: StateType, key: KeyType): boolean => {
|
||||
return !!state[type][key];
|
||||
};
|
||||
|
||||
const clearAll = (type: StateType): void => {
|
||||
state[type] = {};
|
||||
if ('queue' === type) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pagination.total = 0;
|
||||
state.pagination.page = 1;
|
||||
state.pagination.total_pages = 0;
|
||||
state.pagination.has_next = false;
|
||||
state.pagination.has_prev = false;
|
||||
};
|
||||
|
||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||
state[type] = data;
|
||||
};
|
||||
|
||||
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
||||
if (true === has(fromType, key)) {
|
||||
remove(fromType, key);
|
||||
}
|
||||
|
||||
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem);
|
||||
};
|
||||
|
||||
const count = (type: StateType): number => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
return state.pagination.total;
|
||||
}
|
||||
return Object.keys(state[type]).length;
|
||||
};
|
||||
|
||||
const loadPaginated = async (
|
||||
type: StateType,
|
||||
page: number = 1,
|
||||
per_page: number = 50,
|
||||
order: 'ASC' | 'DESC' = 'DESC',
|
||||
append: boolean = false,
|
||||
status?: string,
|
||||
): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
state.pagination.isLoading = true;
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
type: 'done',
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
order,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(params);
|
||||
|
||||
const response = await request(`/api/history?${search}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.pagination) {
|
||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
|
||||
const items: Record<KeyType, StoreItem> = {};
|
||||
for (const item of data.items || []) {
|
||||
items[item._id] = item;
|
||||
}
|
||||
|
||||
state[type] = append ? { ...state[type], ...items } : items;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${type} page ${page}:`, error);
|
||||
state.pagination.isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_next || state.pagination.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append);
|
||||
};
|
||||
|
||||
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page);
|
||||
};
|
||||
|
||||
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
if (!state.pagination.isLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPaginated(type, state.pagination.page, state.pagination.per_page);
|
||||
};
|
||||
|
||||
const getPagination = () => state.pagination;
|
||||
|
||||
const setHistoryCount = (count: number) => {
|
||||
state.pagination.total = count;
|
||||
if (count > 0 && !state.pagination.isLoaded) {
|
||||
state.pagination.isLoaded = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load queue data from REST API.
|
||||
* Uses the /live endpoint to get real-time in-memory data with live progress.
|
||||
*
|
||||
* @returns Promise that resolves when queue is loaded
|
||||
*/
|
||||
const loadQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await request('/api/history/live');
|
||||
const data = (await response.json()) as {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history_count: number;
|
||||
};
|
||||
|
||||
state.queue = data.queue || {};
|
||||
setHistoryCount(data.history_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to load queue:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a download using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param data - Download data (url, preset, folder, etc.)
|
||||
* @returns Promise that resolves when download is added
|
||||
*/
|
||||
const addDownload = async (data: item_request): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
socket.emit('add_url', data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to add download');
|
||||
throw new Error(error.error || 'Failed to add download');
|
||||
}
|
||||
|
||||
toast.success('Download added successfully');
|
||||
await loadQueue();
|
||||
} catch (error) {
|
||||
console.error('Failed to add download:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to add download')) {
|
||||
toast.error('Failed to add download');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to start
|
||||
* @returns Promise that resolves when items are started
|
||||
*/
|
||||
const startItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_start', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to start items');
|
||||
throw new Error(error.error || 'Failed to start items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('started' === result[id]) {
|
||||
const item = get('queue', id);
|
||||
if (item) {
|
||||
update('queue', id, { ...item, auto_start: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to start items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to start items')) {
|
||||
toast.error('Failed to start items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to pause
|
||||
* @returns Promise that resolves when items are paused
|
||||
*/
|
||||
const pauseItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_pause', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/pause', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to pause items');
|
||||
throw new Error(error.error || 'Failed to pause items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('paused' === result[id]) {
|
||||
const item = get('queue', id);
|
||||
if (item) {
|
||||
update('queue', id, { ...item, auto_start: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
|
||||
toast.error('Failed to pause items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param ids - Array of item IDs to cancel
|
||||
* @returns Promise that resolves when items are cancelled
|
||||
*/
|
||||
const cancelItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_cancel', id));
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToMove: Record<string, StoreItem> = {};
|
||||
for (const id of ids) {
|
||||
const item = get('queue', id);
|
||||
if (item) {
|
||||
itemsToMove[id] = { ...item };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to cancel items');
|
||||
throw new Error(error.error || 'Failed to cancel items');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('ok' === result[id] && itemsToMove[id]) {
|
||||
remove('queue', id);
|
||||
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
|
||||
add('history', id, cancelledItem);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel items:', error);
|
||||
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
|
||||
toast.error('Failed to cancel items');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove items using WebSocket if connected, fallback to REST API.
|
||||
*
|
||||
* @param type - The store type ('queue' or 'history')
|
||||
* @param ids - Array of item IDs to remove
|
||||
* @param removeFile - Whether to remove files from disk (default: false)
|
||||
* @returns Promise that resolves when items are removed
|
||||
*/
|
||||
const removeItems = async (
|
||||
type: StateType,
|
||||
ids: string[],
|
||||
removeFile: boolean = false,
|
||||
): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteItems(type, { ids, removeFile });
|
||||
} catch (error) {
|
||||
console.error('Failed to remove items:', error);
|
||||
toast.error('Failed to remove items');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete items by specific IDs or status filter.
|
||||
*
|
||||
* @param type - The store type ('queue' or 'history')
|
||||
* @param options - Delete options
|
||||
* @param options.ids - Array of item IDs to delete (if provided, status is ignored)
|
||||
* @param options.status - Status filter (e.g., 'finished' or '!finished')
|
||||
* @param options.removeFile - Whether to remove files from disk (default: true)
|
||||
*
|
||||
* @returns Number of items deleted
|
||||
*/
|
||||
const deleteItems = async (
|
||||
type: StateType,
|
||||
options: { ids?: string[]; status?: string; removeFile?: boolean } = {},
|
||||
): Promise<number> => {
|
||||
const { ids, status, removeFile = true } = options;
|
||||
|
||||
if (!ids && !status) {
|
||||
throw new Error('Either ids or status filter must be provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
type: type === 'queue' ? 'queue' : 'done',
|
||||
remove_file: removeFile,
|
||||
};
|
||||
|
||||
if (ids) {
|
||||
body.ids = ids;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body.status = status;
|
||||
}
|
||||
|
||||
const response = await request('/api/history', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as {
|
||||
items: Record<string, string>;
|
||||
deleted: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.error || result.message || !response.ok) {
|
||||
throw new Error(result.error || result.message || 'Failed to delete items.');
|
||||
}
|
||||
|
||||
for (const id of Object.keys(result.items)) {
|
||||
remove(type, id);
|
||||
}
|
||||
|
||||
return result.deleted;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete items:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
update,
|
||||
remove,
|
||||
get,
|
||||
has,
|
||||
clearAll,
|
||||
addAll,
|
||||
move,
|
||||
count,
|
||||
loadPaginated,
|
||||
loadNextPage,
|
||||
loadPreviousPage,
|
||||
reloadCurrentPage,
|
||||
getPagination,
|
||||
setHistoryCount,
|
||||
loadQueue,
|
||||
addDownload,
|
||||
startItems,
|
||||
pauseItems,
|
||||
cancelItems,
|
||||
removeItems,
|
||||
deleteItems,
|
||||
};
|
||||
});
|
||||
|
|
@ -142,45 +142,6 @@ const dEvent = (eventName: string, detail: Record<string, any> = {}): boolean =>
|
|||
return window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a pagination list based on current page, total pages, and delta range.
|
||||
*
|
||||
* @param current - The current active page number.
|
||||
* @param last - The last page number.
|
||||
* @param delta - How many pages to show before/after the current page.
|
||||
* @returns An array of pagination entries including optional gaps.
|
||||
*/
|
||||
const makePagination = (
|
||||
current: number,
|
||||
last: number,
|
||||
delta: number = 5,
|
||||
): Array<{ page: number; text: string; selected: boolean }> => {
|
||||
const pagination: Array<{ page: number; text: string; selected: boolean }> = [];
|
||||
if (last < 2) {
|
||||
return pagination;
|
||||
}
|
||||
|
||||
const strR = '-'.repeat(9 + `${last}`.length);
|
||||
const left = current - delta;
|
||||
const right = current + delta + 1;
|
||||
|
||||
for (let i = 1; i <= last; i++) {
|
||||
if (1 === i || last === i || (i >= left && i < right)) {
|
||||
if (i === left && i > 2) {
|
||||
pagination.push({ page: 0, text: strR, selected: false });
|
||||
}
|
||||
|
||||
pagination.push({ page: i, text: `Page #${i}`, selected: i === current });
|
||||
|
||||
if (i === right - 1 && i < last - 1) {
|
||||
pagination.push({ page: 0, text: strR, selected: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pagination;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely encode a path string for use in a URL.
|
||||
*
|
||||
|
|
@ -742,10 +703,33 @@ const setBodyOpacity = (value: string): boolean => {
|
|||
return false;
|
||||
}
|
||||
|
||||
body.setAttribute('style', `opacity: ${value}`);
|
||||
body.style.opacity = value;
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearBodyOpacity = (): boolean => {
|
||||
const body = document.querySelector('body');
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
body.style.removeProperty('opacity');
|
||||
return true;
|
||||
};
|
||||
|
||||
const syncOpacity = (): boolean => {
|
||||
if (!getStorageValue<boolean>('random_bg', true, false)) {
|
||||
opacityLockCount = 0;
|
||||
return clearBodyOpacity();
|
||||
}
|
||||
|
||||
if (opacityLockCount > 0) {
|
||||
return setBodyOpacity('1.0');
|
||||
}
|
||||
|
||||
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
|
||||
};
|
||||
|
||||
const disableOpacity = (): boolean => {
|
||||
if (!getStorageValue<boolean>('random_bg', true, false)) {
|
||||
opacityLockCount = 0;
|
||||
|
|
@ -763,11 +747,7 @@ const enableOpacity = (): boolean => {
|
|||
}
|
||||
|
||||
opacityLockCount = Math.max(0, opacityLockCount - 1);
|
||||
if (opacityLockCount > 0) {
|
||||
return setBodyOpacity('1.0');
|
||||
}
|
||||
|
||||
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
|
||||
return syncOpacity();
|
||||
};
|
||||
|
||||
const stripPath = (base_path: string, real_path: string): string => {
|
||||
|
|
@ -1008,7 +988,6 @@ export {
|
|||
r,
|
||||
copyText,
|
||||
dEvent,
|
||||
makePagination,
|
||||
encodePath,
|
||||
request,
|
||||
removeANSIColors,
|
||||
|
|
@ -1028,6 +1007,7 @@ export {
|
|||
awaiter,
|
||||
encode,
|
||||
decode,
|
||||
syncOpacity,
|
||||
disableOpacity,
|
||||
enableOpacity,
|
||||
stripPath,
|
||||
|
|
|
|||
310
ui/app/utils/topLevelNavigation.ts
Normal file
310
ui/app/utils/topLevelNavigation.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { DOCS_ENTRIES } from '~/composables/useDocs';
|
||||
|
||||
export type SectionId = 'downloads' | 'automation' | 'configuration' | 'tools' | 'docs';
|
||||
|
||||
type NavSection = {
|
||||
id: SectionId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type NavDefinition = {
|
||||
id: string;
|
||||
section: SectionId;
|
||||
group: string;
|
||||
label: string;
|
||||
pageLabel?: string;
|
||||
breadcrumbSectionLabel?: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
matchPath?: string;
|
||||
sidebarVisible?: boolean;
|
||||
searchable?: boolean;
|
||||
navbarTitle?: string;
|
||||
requires?: 'file_logging' | 'console_enabled';
|
||||
};
|
||||
|
||||
export type NavItem = NavDefinition & {
|
||||
sectionLabel: string;
|
||||
pageLabel: string;
|
||||
matchPath: string;
|
||||
sidebarVisible: boolean;
|
||||
searchable: boolean;
|
||||
};
|
||||
|
||||
export type PageShell = {
|
||||
icon: string;
|
||||
sectionLabel: string;
|
||||
pageLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type LocationPath = {
|
||||
path: string;
|
||||
hash?: string;
|
||||
};
|
||||
|
||||
type NavAvailability = {
|
||||
fileLogging?: boolean;
|
||||
consoleEnabled?: boolean;
|
||||
};
|
||||
|
||||
const SECTIONS: Array<NavSection> = [
|
||||
{ id: 'downloads', label: 'Downloads' },
|
||||
{ id: 'automation', label: 'Automation' },
|
||||
{ id: 'configuration', label: 'Configuration' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'docs', label: 'Docs' },
|
||||
];
|
||||
|
||||
const NavItems: Array<NavDefinition> = [
|
||||
{
|
||||
id: 'downloads',
|
||||
section: 'downloads',
|
||||
group: 'workspace',
|
||||
label: 'Queue',
|
||||
pageLabel: 'Queue',
|
||||
breadcrumbSectionLabel: 'Workspace',
|
||||
description: 'Active and queued downloads.',
|
||||
icon: 'i-lucide-download',
|
||||
to: '/',
|
||||
matchPath: '/',
|
||||
},
|
||||
{
|
||||
id: 'history',
|
||||
section: 'downloads',
|
||||
group: 'workspace',
|
||||
label: 'History',
|
||||
pageLabel: 'History',
|
||||
breadcrumbSectionLabel: 'Workspace',
|
||||
description: 'Completed, skipped, and failed downloads.',
|
||||
icon: 'i-lucide-history',
|
||||
to: '/history',
|
||||
matchPath: '/history',
|
||||
navbarTitle: 'Downloads',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
section: 'downloads',
|
||||
group: 'workspace',
|
||||
label: 'Files',
|
||||
pageLabel: 'Files',
|
||||
breadcrumbSectionLabel: 'Workspace',
|
||||
description: 'Browse downloaded files.',
|
||||
icon: 'i-lucide-folder-tree',
|
||||
to: '/browser',
|
||||
matchPath: '/browser',
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
section: 'automation',
|
||||
group: 'automation',
|
||||
label: 'Tasks',
|
||||
pageLabel: 'Tasks',
|
||||
description: 'Queue playlist/channels for automatic download at specified intervals.',
|
||||
icon: 'i-lucide-list-todo',
|
||||
to: '/tasks',
|
||||
matchPath: '/tasks',
|
||||
},
|
||||
{
|
||||
id: 'task-definitions',
|
||||
section: 'automation',
|
||||
group: 'automation',
|
||||
label: 'Task Definitions',
|
||||
pageLabel: 'Task Definitions',
|
||||
description: 'Create definitions to turn any website into a downloadable feed of links.',
|
||||
icon: 'i-lucide-workflow',
|
||||
to: '/task_definitions',
|
||||
matchPath: '/task_definitions',
|
||||
},
|
||||
{
|
||||
id: 'presets',
|
||||
section: 'configuration',
|
||||
group: 'configuration',
|
||||
label: 'Presets',
|
||||
pageLabel: 'Presets',
|
||||
description:
|
||||
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.',
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
to: '/presets',
|
||||
matchPath: '/presets',
|
||||
},
|
||||
{
|
||||
id: 'custom-fields',
|
||||
section: 'configuration',
|
||||
group: 'configuration',
|
||||
label: 'Custom Fields',
|
||||
pageLabel: 'Custom Fields',
|
||||
description: 'Custom fields allow you to add new fields to the download form.',
|
||||
icon: 'i-lucide-braces',
|
||||
to: '/dl_fields',
|
||||
matchPath: '/dl_fields',
|
||||
},
|
||||
{
|
||||
id: 'conditions',
|
||||
section: 'configuration',
|
||||
group: 'configuration',
|
||||
label: 'Conditions',
|
||||
pageLabel: 'Conditions',
|
||||
description: 'Run yt-dlp custom match filter on returned info and apply options.',
|
||||
icon: 'i-lucide-filter',
|
||||
to: '/conditions',
|
||||
matchPath: '/conditions',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
section: 'configuration',
|
||||
group: 'configuration',
|
||||
label: 'Notifications',
|
||||
pageLabel: 'Notifications',
|
||||
description: 'Send notifications to your webhooks based on specified events or presets.',
|
||||
icon: 'i-lucide-bell',
|
||||
to: '/notifications',
|
||||
matchPath: '/notifications',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
section: 'tools',
|
||||
group: 'tools',
|
||||
label: 'Logs',
|
||||
pageLabel: 'Logs',
|
||||
description: 'Scroll near the top to load older logs.',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/logs',
|
||||
matchPath: '/logs',
|
||||
requires: 'file_logging',
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
section: 'tools',
|
||||
group: 'tools',
|
||||
label: 'Console',
|
||||
pageLabel: 'Console',
|
||||
description: 'Run yt-dlp commands directly in a non-interactive session.',
|
||||
icon: 'i-lucide-terminal',
|
||||
to: '/console',
|
||||
matchPath: '/console',
|
||||
requires: 'console_enabled',
|
||||
},
|
||||
...DOCS_ENTRIES.map<NavDefinition>((entry) => ({
|
||||
id: entry.id,
|
||||
section: 'docs',
|
||||
group: 'docs',
|
||||
label: entry.navLabel,
|
||||
pageLabel: entry.title,
|
||||
description: entry.description,
|
||||
icon: entry.icon,
|
||||
to: entry.route,
|
||||
matchPath: entry.route,
|
||||
})),
|
||||
{
|
||||
id: 'changelog',
|
||||
section: 'docs',
|
||||
group: 'docs',
|
||||
label: 'Changelog',
|
||||
pageLabel: 'Changelog',
|
||||
description:
|
||||
'Latest project changes, loaded remotely when available and falling back to the bundled changelog file.',
|
||||
icon: 'i-lucide-git-commit-horizontal',
|
||||
to: '/changelog',
|
||||
matchPath: '/changelog',
|
||||
},
|
||||
];
|
||||
|
||||
const normalizePath = (value?: string | null): string => {
|
||||
if (!value || value === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const trimmed = value.replace(/\/+$/, '');
|
||||
return trimmed === '' ? '/' : trimmed;
|
||||
};
|
||||
|
||||
const getSectionLabel = (sectionId: SectionId): string => {
|
||||
const section = SECTIONS.find((item) => item.id === sectionId);
|
||||
return section?.label ?? sectionId;
|
||||
};
|
||||
|
||||
const resolveEntry = (entry: NavDefinition): NavItem => ({
|
||||
...entry,
|
||||
sectionLabel: getSectionLabel(entry.section),
|
||||
pageLabel: entry.pageLabel ?? entry.label,
|
||||
matchPath: normalizePath(entry.matchPath ?? (entry.to.split(/[?#]/)[0] || '/')),
|
||||
sidebarVisible: entry.sidebarVisible !== false,
|
||||
searchable: entry.searchable !== false,
|
||||
});
|
||||
|
||||
const resolvedNavigation = NavItems.map((entry) => resolveEntry(entry));
|
||||
|
||||
const matchesAvailability = (entry: NavItem, options: NavAvailability): boolean => {
|
||||
switch (entry.requires) {
|
||||
case 'file_logging':
|
||||
return options.fileLogging === true;
|
||||
|
||||
case 'console_enabled':
|
||||
return options.consoleEnabled === true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const getNavItems = (options?: NavAvailability): Array<NavItem> => {
|
||||
if (!options) {
|
||||
return resolvedNavigation;
|
||||
}
|
||||
|
||||
return resolvedNavigation.filter((entry) => matchesAvailability(entry, options));
|
||||
};
|
||||
|
||||
export const getNavSections = (): Array<NavSection> => {
|
||||
return SECTIONS;
|
||||
};
|
||||
|
||||
export const getNavItemById = (id: string): NavItem | undefined => {
|
||||
return resolvedNavigation.find((entry) => entry.id === id);
|
||||
};
|
||||
|
||||
export const isNavItemActive = (entry: NavItem, route: LocationPath): boolean => {
|
||||
const current = normalizePath(route.path);
|
||||
const target = normalizePath(entry.matchPath);
|
||||
|
||||
if (target === '/') {
|
||||
return current === '/';
|
||||
}
|
||||
|
||||
return current === target || current.startsWith(`${target}/`);
|
||||
};
|
||||
|
||||
export const getActiveNavItem = (
|
||||
route: LocationPath,
|
||||
options?: NavAvailability,
|
||||
): NavItem | undefined => {
|
||||
return getNavItems(options)
|
||||
.filter((entry) => isNavItemActive(entry, route))
|
||||
.sort((left, right) => right.matchPath.length - left.matchPath.length)[0];
|
||||
};
|
||||
|
||||
export const getPageShell = (id: string): PageShell | undefined => {
|
||||
const entry = getNavItemById(id);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: entry.icon,
|
||||
sectionLabel: entry.breadcrumbSectionLabel ?? entry.sectionLabel,
|
||||
pageLabel: entry.pageLabel,
|
||||
description: entry.description,
|
||||
};
|
||||
};
|
||||
|
||||
export const requirePageShell = (id: string): PageShell => {
|
||||
const shell = getPageShell(id);
|
||||
|
||||
if (!shell) {
|
||||
throw new Error(`Missing top-level navigation shell for '${id}'`);
|
||||
}
|
||||
|
||||
return shell;
|
||||
};
|
||||
176
ui/bun.lock
176
ui/bun.lock
|
|
@ -5,41 +5,39 @@
|
|||
"": {
|
||||
"name": "nuxt-app",
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "latest",
|
||||
"@microsoft/fetch-event-source": "latest",
|
||||
"@nuxt/eslint": "latest",
|
||||
"@nuxt/eslint-config": "latest",
|
||||
"@nuxt/ui": "latest",
|
||||
"@pinia/nuxt": "latest",
|
||||
"@vueuse/core": "latest",
|
||||
"@vueuse/nuxt": "latest",
|
||||
"@xterm/addon-fit": "latest",
|
||||
"@xterm/xterm": "latest",
|
||||
"cron-parser": "latest",
|
||||
"cronstrue": "latest",
|
||||
"hls.js": "latest",
|
||||
"marked": "latest",
|
||||
"marked-alert": "latest",
|
||||
"marked-base-url": "latest",
|
||||
"marked-gfm-heading-id": "latest",
|
||||
"moment": "latest",
|
||||
"nuxt": "latest",
|
||||
"pinia": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"@iconify-json/lucide": "^1.2.102",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/eslint-config": "^1.15.2",
|
||||
"@nuxt/ui": "^4.6.1",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"marked": "^18.0.0",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.9",
|
||||
"marked-gfm-heading-id": "^4.1.4",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/jsdom": "latest",
|
||||
"@types/node": "latest",
|
||||
"@typescript-eslint/parser": "latest",
|
||||
"eslint": "latest",
|
||||
"jsdom": "latest",
|
||||
"oxfmt": "latest",
|
||||
"typescript": "latest",
|
||||
"vue-eslint-parser": "latest",
|
||||
"vue-tsc": "latest",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"oxfmt": "^0.45.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -240,7 +238,7 @@
|
|||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.101", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-JUN7uuSLRG3GK/9c5b8cK9e7sL6EAWDaASIwBOd0zUeKS0ACcokJubo2RMQHyVUVpd8mYkrR3Zd2mkH9ghhw1Q=="],
|
||||
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.102", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-Dm3EEqu5NrmzyDMB2U1+8yroEj2/dB9V4KlH0m/szwwF/ofSf0cPaGTZqkd1aExXjCor+vU53ttRMCGuXf+/cg=="],
|
||||
|
||||
"@iconify/collections": ["@iconify/collections@1.0.663", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-B+iGClKW/qkuUunW4j969bajhFwq0EECFH3R+YCjPnlSV+fFJyAyi5U2M64pxEPxfMwWgET8C2N3v77XmkgueQ=="],
|
||||
|
||||
|
|
@ -444,43 +442,43 @@
|
|||
|
||||
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.117.0", "", { "os": "win32", "cpu": "x64" }, "sha512-V7YzavQnYcRJBeJkp0qpb3FKrlm5I57XJetCYB4jsjStuboQmnFMZ/XQH55Szlf/kVyeU9ddQwv72gJJ5BrGjQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="],
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.45.0", "", { "os": "android", "cpu": "arm" }, "sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="],
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.45.0", "", { "os": "android", "cpu": "arm64" }, "sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="],
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.45.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="],
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.45.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="],
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.45.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="],
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="],
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="],
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="],
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="],
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.45.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="],
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="],
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="],
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.45.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="],
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="],
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="],
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.45.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="],
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.45.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="],
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.45.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="],
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.45.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
|
||||
|
|
@ -512,8 +510,6 @@
|
|||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@pinia/nuxt": ["@pinia/nuxt@0.11.3", "", { "dependencies": { "@nuxt/kit": "^4.2.0" }, "peerDependencies": { "pinia": "^3.0.4" } }, "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
|
@ -726,7 +722,7 @@
|
|||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
|
|
@ -742,7 +738,7 @@
|
|||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
|
|
@ -752,23 +748,23 @@
|
|||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="],
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.1.12", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.12" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g=="],
|
||||
|
||||
|
|
@ -838,7 +834,7 @@
|
|||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
"@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"@vue/devtools-core": ["@vue/devtools-core@8.1.0", "", { "dependencies": { "@vue/devtools-kit": "^8.1.0", "@vue/devtools-shared": "^8.1.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ=="],
|
||||
|
||||
|
|
@ -948,7 +944,7 @@
|
|||
|
||||
"builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
|
@ -1280,7 +1276,7 @@
|
|||
|
||||
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
||||
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
|
||||
|
||||
"hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
|
||||
|
||||
|
|
@ -1576,7 +1572,7 @@
|
|||
|
||||
"oxc-walker": ["oxc-walker@0.7.0", "", { "dependencies": { "magic-regexp": "^0.10.0" }, "peerDependencies": { "oxc-parser": ">=0.98.0" } }, "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="],
|
||||
"oxfmt": ["oxfmt@0.45.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.45.0", "@oxfmt/binding-android-arm64": "0.45.0", "@oxfmt/binding-darwin-arm64": "0.45.0", "@oxfmt/binding-darwin-x64": "0.45.0", "@oxfmt/binding-freebsd-x64": "0.45.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", "@oxfmt/binding-linux-arm64-gnu": "0.45.0", "@oxfmt/binding-linux-arm64-musl": "0.45.0", "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-musl": "0.45.0", "@oxfmt/binding-linux-s390x-gnu": "0.45.0", "@oxfmt/binding-linux-x64-gnu": "0.45.0", "@oxfmt/binding-linux-x64-musl": "0.45.0", "@oxfmt/binding-openharmony-arm64": "0.45.0", "@oxfmt/binding-win32-arm64-msvc": "0.45.0", "@oxfmt/binding-win32-ia32-msvc": "0.45.0", "@oxfmt/binding-win32-x64-msvc": "0.45.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
|
|
@ -2208,7 +2204,7 @@
|
|||
|
||||
"@types/jsdom/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
|
||||
|
||||
|
|
@ -2262,7 +2258,7 @@
|
|||
|
||||
"@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
|
|
@ -2290,8 +2286,6 @@
|
|||
|
||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"c12/giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"c12/rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
|
@ -2388,6 +2382,8 @@
|
|||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
|
||||
|
||||
"pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||
|
||||
"rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
|
@ -2450,8 +2446,6 @@
|
|||
|
||||
"vue-eslint-parser/espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
|
||||
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"vue-router/mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="],
|
||||
|
||||
"whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="],
|
||||
|
|
@ -2582,14 +2576,12 @@
|
|||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
|
@ -2598,8 +2590,6 @@
|
|||
|
||||
"archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"c12/giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
|
@ -2632,10 +2622,10 @@
|
|||
|
||||
"nuxt/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"nuxt/vue-router/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"rollup-plugin-visualizer/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
|
@ -2670,8 +2660,6 @@
|
|||
|
||||
"vue-eslint-parser/espree/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"@dxup/nuxt/@nuxt/kit/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
|
@ -2746,8 +2734,6 @@
|
|||
|
||||
"nuxt/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
"nuxt/vue-router/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"nuxt/vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
|
@ -2768,6 +2754,14 @@
|
|||
|
||||
"nuxt/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"pinia/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"unplugin-vue-components/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
|
@ -2790,12 +2784,6 @@
|
|||
|
||||
"vaul-vue/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"vue-router/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
|
|
@ -2852,12 +2840,6 @@
|
|||
|
||||
"nuxt/mlly/pkg-types/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"nuxt/vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"nuxt/vue/@vue/compiler-dom/@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ try {
|
|||
}
|
||||
} catch {}
|
||||
|
||||
const isProd = 'production' === process.env.NODE_ENV;
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
sourcemap: false === isProd,
|
||||
devtools: { enabled: true },
|
||||
devServer: {
|
||||
port: 8082,
|
||||
|
|
@ -57,14 +59,22 @@ export default defineNuxtConfig({
|
|||
},
|
||||
pageTransition: { name: 'page' },
|
||||
},
|
||||
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'],
|
||||
modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'],
|
||||
icon: {
|
||||
serverBundle: 'local',
|
||||
provider: 'none',
|
||||
fallbackToApi: false,
|
||||
clientBundle: {
|
||||
icons: isProd ? [] : ['lucide:book'],
|
||||
scan: {
|
||||
globInclude: ['app/**/*.{vue,ts,js}', 'node_modules/@nuxt/ui/dist/shared/ui*.mjs'],
|
||||
globExclude: ['dist', 'build', 'coverage', 'test', 'tests', '.*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
sourceMap: false === isProd,
|
||||
output: {
|
||||
publicDir:
|
||||
'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
|
||||
publicDir: isProd ? __dirname + '/exported' : __dirname + '/dist',
|
||||
},
|
||||
...extraNitro,
|
||||
},
|
||||
|
|
@ -87,6 +97,15 @@ export default defineNuxtConfig({
|
|||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
if ('SOURCEMAP_BROKEN' === warning.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
warn(warning);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
telemetry: false,
|
||||
|
|
|
|||
|
|
@ -21,26 +21,24 @@
|
|||
},
|
||||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.101",
|
||||
"@iconify-json/lucide": "^1.2.102",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@nuxt/eslint-config": "^1.15.2",
|
||||
"@nuxt/ui": "^4.6.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"hls.js": "^1.6.16",
|
||||
"marked": "^18.0.0",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.9",
|
||||
"marked-gfm-heading-id": "^4.1.4",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
|
|
@ -50,13 +48,13 @@
|
|||
"@parcel/watcher"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "25.5.2",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@typescript-eslint/parser": "^8.58.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"eslint": "^10.2.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"oxfmt": "^0.44.0",
|
||||
"oxfmt": "^0.45.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.6"
|
||||
|
|
|
|||
|
|
@ -133,43 +133,6 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty conditions list', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: {
|
||||
items: [],
|
||||
pagination: { ...mockPagination, total: 0 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const conditions = useConditions()
|
||||
await conditions.loadConditions()
|
||||
|
||||
expect(conditions.conditions.value).toEqual([])
|
||||
expect(conditions.lastError.value).toBeNull()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles errors during load', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const conditions = useConditions()
|
||||
await conditions.loadConditions()
|
||||
|
||||
expect(conditions.lastError.value).toBeTruthy()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCondition', () => {
|
||||
|
|
@ -191,23 +154,6 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles 404 not found', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
jsonData: { error: 'Condition not found' },
|
||||
}),
|
||||
)
|
||||
|
||||
const conditions = useConditions()
|
||||
const result = await conditions.getCondition(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(conditions.lastError.value).toBeTruthy()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCondition', () => {
|
||||
|
|
@ -230,61 +176,10 @@ describe('useConditions', () => {
|
|||
|
||||
expect(result).toEqual(mockCondition)
|
||||
expect(conditions.conditions.value).toContainEqual(mockCondition)
|
||||
expect(conditions.pagination.value.total).toBe(initialTotal + 1)
|
||||
expect(conditions.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockCondition,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
const newCondition = { ...mockCondition }
|
||||
delete newCondition.id
|
||||
|
||||
await conditions.createCondition(newCondition, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: mockCondition,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
const newCondition = { ...mockCondition }
|
||||
delete newCondition.id
|
||||
|
||||
await conditions.createCondition(newCondition, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCondition', () => {
|
||||
|
|
@ -307,31 +202,6 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on success', async () => {
|
||||
const updatedCondition = { ...mockCondition, name: 'Updated' }
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: updatedCondition,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
|
||||
await conditions.updateCondition(1, updatedCondition, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: updatedCondition,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes id field from condition before sending', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
|
|
@ -369,54 +239,6 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on patch success', async () => {
|
||||
const patchedCondition = { ...mockCondition, enabled: false }
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: patchedCondition,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
|
||||
await conditions.patchCondition(1, { enabled: false }, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: patchedCondition,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles validation errors with callback', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
jsonData: { detail: [{ loc: ['priority'], msg: 'Invalid priority', type: 'value_error' }] },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
|
||||
await conditions.patchCondition(1, { priority: -1 }, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteCondition', () => {
|
||||
|
|
@ -440,54 +262,6 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockCondition,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
|
||||
await conditions.deleteCondition(1, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: true,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
jsonData: { error: 'Condition not found' },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const conditions = useConditions()
|
||||
|
||||
await conditions.deleteCondition(999, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
data: false,
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('testCondition', () => {
|
||||
|
|
@ -540,72 +314,4 @@ describe('useConditions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when throwInstead is true', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const conditions = useConditions()
|
||||
conditions.throwInstead.value = true
|
||||
|
||||
await expect(conditions.loadConditions()).rejects.toThrow()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears error on clearError call', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const conditions = useConditions()
|
||||
conditions.throwInstead.value = false
|
||||
|
||||
await conditions.loadConditions()
|
||||
|
||||
expect(conditions.lastError.value).toBeTruthy()
|
||||
|
||||
conditions.clearError()
|
||||
|
||||
expect(conditions.lastError.value).toBeNull()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addInProgress state', () => {
|
||||
it('sets addInProgress during create operation', async () => {
|
||||
let inProgressDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockImplementation(async () => {
|
||||
const conditions = useConditions()
|
||||
inProgressDuringCall = conditions.addInProgress.value
|
||||
return createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockCondition,
|
||||
})
|
||||
})
|
||||
|
||||
const conditions = useConditions()
|
||||
const newCondition = { ...mockCondition }
|
||||
delete newCondition.id
|
||||
|
||||
await conditions.createCondition(newCondition)
|
||||
|
||||
expect(inProgressDuringCall).toBe(true)
|
||||
expect(conditions.addInProgress.value).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
178
ui/tests/composables/useDirtyCloseGuard.test.ts
Normal file
178
ui/tests/composables/useDirtyCloseGuard.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { beforeAll, beforeEach, afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { ref } from 'vue';
|
||||
|
||||
type DialogResult = { status: boolean; value: null };
|
||||
type RouteGuard = () => Promise<boolean> | boolean;
|
||||
|
||||
let beforeRouteLeaveHandler: RouteGuard | null = null;
|
||||
let beforeRouteUpdateHandler: RouteGuard | null = null;
|
||||
let unmountHandlers: Array<() => void> = [];
|
||||
|
||||
const confirmDialogMock = mock<() => Promise<DialogResult>>(() =>
|
||||
Promise.resolve({ status: true, value: null }),
|
||||
);
|
||||
|
||||
mock.module('#imports', () => ({
|
||||
onBeforeRouteLeave: (handler: RouteGuard) => {
|
||||
beforeRouteLeaveHandler = handler;
|
||||
},
|
||||
onBeforeRouteUpdate: (handler: RouteGuard) => {
|
||||
beforeRouteUpdateHandler = handler;
|
||||
},
|
||||
onBeforeUnmount: (handler: () => void) => {
|
||||
unmountHandlers.push(handler);
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('~/composables/useDialog', () => ({
|
||||
useDialog: () => ({
|
||||
confirmDialog: confirmDialogMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
let useDirtyCloseGuard: typeof import('~/composables/useDirtyCloseGuard').useDirtyCloseGuard;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ useDirtyCloseGuard } = await import('~/composables/useDirtyCloseGuard'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeRouteLeaveHandler = null;
|
||||
beforeRouteUpdateHandler = null;
|
||||
unmountHandlers = [];
|
||||
confirmDialogMock.mockReset();
|
||||
confirmDialogMock.mockImplementation(() => Promise.resolve({ status: true, value: null }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const handler of unmountHandlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
beforeRouteLeaveHandler = null;
|
||||
beforeRouteUpdateHandler = null;
|
||||
unmountHandlers = [];
|
||||
});
|
||||
|
||||
describe('useDirtyCloseGuard', () => {
|
||||
it('confirms discard on route leave when the editor is open and dirty', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
||||
useDirtyCloseGuard(open, {
|
||||
dirty,
|
||||
onDiscard,
|
||||
});
|
||||
|
||||
expect(beforeRouteLeaveHandler).not.toBeNull();
|
||||
|
||||
const allowed = await beforeRouteLeaveHandler?.();
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(open.value).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks route updates when the user keeps editing', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
|
||||
confirmDialogMock.mockImplementationOnce(() => Promise.resolve({ status: false, value: null }));
|
||||
|
||||
useDirtyCloseGuard(open, {
|
||||
dirty,
|
||||
});
|
||||
|
||||
expect(beforeRouteUpdateHandler).not.toBeNull();
|
||||
|
||||
const allowed = await beforeRouteUpdateHandler?.();
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
|
||||
expect(open.value).toBe(true);
|
||||
});
|
||||
|
||||
it('only prevents browser unload while the editor is open and dirty, then removes the listener on unmount', () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
|
||||
useDirtyCloseGuard(open, {
|
||||
dirty,
|
||||
});
|
||||
|
||||
const guardedEvent = new window.Event('beforeunload', { cancelable: true });
|
||||
const guardedResult = window.dispatchEvent(guardedEvent);
|
||||
|
||||
expect(guardedResult).toBe(false);
|
||||
expect(guardedEvent.defaultPrevented).toBe(true);
|
||||
|
||||
dirty.value = false;
|
||||
|
||||
const cleanEvent = new window.Event('beforeunload', { cancelable: true });
|
||||
const cleanResult = window.dispatchEvent(cleanEvent);
|
||||
|
||||
expect(cleanResult).toBe(true);
|
||||
expect(cleanEvent.defaultPrevented).toBe(false);
|
||||
|
||||
for (const handler of unmountHandlers) {
|
||||
handler();
|
||||
}
|
||||
unmountHandlers = [];
|
||||
|
||||
dirty.value = true;
|
||||
|
||||
const afterUnmountEvent = new window.Event('beforeunload', { cancelable: true });
|
||||
const afterUnmountResult = window.dispatchEvent(afterUnmountEvent);
|
||||
|
||||
expect(afterUnmountResult).toBe(true);
|
||||
expect(afterUnmountEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes concurrent close requests into one discard dialog', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
||||
let resolveDialog: (result: DialogResult | PromiseLike<DialogResult>) => void = () => {
|
||||
throw new Error('Expected the discard dialog promise to be pending.');
|
||||
};
|
||||
confirmDialogMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<DialogResult>((resolve) => {
|
||||
resolveDialog = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const guard = useDirtyCloseGuard(open, {
|
||||
dirty,
|
||||
onDiscard,
|
||||
});
|
||||
|
||||
const first = guard.requestClose();
|
||||
const second = guard.requestClose();
|
||||
|
||||
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
|
||||
resolveDialog({ status: true, value: null });
|
||||
|
||||
expect(await first).toBe(true);
|
||||
expect(await second).toBe(true);
|
||||
expect(onDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(open.value).toBe(false);
|
||||
});
|
||||
|
||||
it('does not guard route changes when the editor is clean', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(false);
|
||||
|
||||
useDirtyCloseGuard(open, {
|
||||
dirty,
|
||||
});
|
||||
|
||||
expect(await beforeRouteLeaveHandler?.()).toBe(true);
|
||||
expect(await beforeRouteUpdateHandler?.()).toBe(true);
|
||||
expect(confirmDialogMock).not.toHaveBeenCalled();
|
||||
expect(open.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
import { beforeAll, describe, expect, it, mock } from 'bun:test'
|
||||
|
||||
import { usePresetOptions } from '~/composables/usePresetOptions'
|
||||
import type { Preset } from '~/types/presets'
|
||||
|
||||
let configState = {
|
||||
presets: [] as Preset[],
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('~/composables/useYtpConfig', () => ({
|
||||
useYtpConfig: () => configState,
|
||||
}))
|
||||
|
||||
let usePresetOptions: typeof import('~/composables/usePresetOptions').usePresetOptions
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ usePresetOptions } = await import('~/composables/usePresetOptions'))
|
||||
})
|
||||
|
||||
const buildPreset = (name: string, isDefault: boolean): Preset => ({
|
||||
name,
|
||||
default: isDefault,
|
||||
|
|
@ -15,12 +31,12 @@ const buildPreset = (name: string, isDefault: boolean): Preset => ({
|
|||
})
|
||||
|
||||
const setConfigStore = (presets: Preset[]) => {
|
||||
;(globalThis as any).useConfigStore = () => ({
|
||||
configState = {
|
||||
presets,
|
||||
app: {
|
||||
download_path: '/downloads',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('usePresetOptions', () => {
|
||||
|
|
|
|||
|
|
@ -136,43 +136,6 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty presets list', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: {
|
||||
items: [],
|
||||
pagination: { ...mockPagination, total: 0 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const presets = usePresets()
|
||||
await presets.loadPresets()
|
||||
|
||||
expect(presets.presets.value).toEqual([])
|
||||
expect(presets.lastError.value).toBeNull()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles errors during load', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const presets = usePresets()
|
||||
await presets.loadPresets()
|
||||
|
||||
expect(presets.lastError.value).toBeTruthy()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreset', () => {
|
||||
|
|
@ -194,23 +157,6 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles 404 not found', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
jsonData: { error: 'Preset not found' },
|
||||
}),
|
||||
)
|
||||
|
||||
const presets = usePresets()
|
||||
const result = await presets.getPreset(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(presets.lastError.value).toBeTruthy()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPreset', () => {
|
||||
|
|
@ -236,67 +182,10 @@ describe('usePresets', () => {
|
|||
|
||||
expect(result).toEqual(mockPreset)
|
||||
expect(presets.presets.value).toContainEqual(mockPreset)
|
||||
expect(presets.pagination.value.total).toBe(initialTotal + 1)
|
||||
expect(presets.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockPreset,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const presets = usePresets()
|
||||
const newPreset: PresetRequest = {
|
||||
name: 'New Preset',
|
||||
description: 'Desc',
|
||||
cli: '--format best',
|
||||
}
|
||||
|
||||
await presets.createPreset(newPreset, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: mockPreset,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const presets = usePresets()
|
||||
const newPreset: PresetRequest = {
|
||||
name: 'New Preset',
|
||||
description: 'Desc',
|
||||
cli: '--format best',
|
||||
}
|
||||
|
||||
await presets.createPreset(newPreset, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePreset', () => {
|
||||
|
|
@ -396,125 +285,6 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockPreset,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const presets = usePresets()
|
||||
|
||||
await presets.deletePreset(1, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: true,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
jsonData: { error: 'Preset not found' },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const presets = usePresets()
|
||||
|
||||
await presets.deletePreset(999, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
data: false,
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when throwInstead is true', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const presets = usePresets()
|
||||
presets.throwInstead.value = true
|
||||
|
||||
await expect(presets.loadPresets()).rejects.toThrow()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears error on clearError call', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
jsonData: { error: 'Server error' },
|
||||
}),
|
||||
)
|
||||
|
||||
const presets = usePresets()
|
||||
presets.throwInstead.value = false
|
||||
|
||||
await presets.loadPresets()
|
||||
|
||||
expect(presets.lastError.value).toBeTruthy()
|
||||
|
||||
presets.clearError()
|
||||
|
||||
expect(presets.lastError.value).toBeNull()
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addInProgress state', () => {
|
||||
it('sets addInProgress during create operation', async () => {
|
||||
let inProgressDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockImplementation(async () => {
|
||||
const presets = usePresets()
|
||||
inProgressDuringCall = presets.addInProgress.value
|
||||
return createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockPreset,
|
||||
})
|
||||
})
|
||||
|
||||
const presets = usePresets()
|
||||
const newPreset: PresetRequest = {
|
||||
name: 'New Preset',
|
||||
description: 'Desc',
|
||||
cli: '--format best',
|
||||
}
|
||||
|
||||
await presets.createPreset(newPreset)
|
||||
|
||||
expect(inProgressDuringCall).toBe(true)
|
||||
expect(presets.addInProgress.value).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -258,57 +258,6 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockTask,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
const newTask = { ...mockTask }
|
||||
delete (newTask as any).id
|
||||
|
||||
await tasks.createTask(newTask, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: mockTask,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
const newTask = { ...mockTask }
|
||||
delete (newTask as any).id
|
||||
|
||||
await tasks.createTask(newTask, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
|
|
@ -331,31 +280,6 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on success', async () => {
|
||||
const updatedTask = { ...mockTask, name: 'Updated' }
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: updatedTask,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
|
||||
await tasks.updateTask(1, updatedTask, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: updatedTask,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes id field from task before sending', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
|
|
@ -394,54 +318,6 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on patch success', async () => {
|
||||
const patchedTask = { ...mockTask, enabled: false }
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: patchedTask,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
|
||||
await tasks.patchTask(1, { enabled: false }, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: patchedTask,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles validation errors with callback', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
jsonData: { detail: [{ loc: ['timer'], msg: 'Invalid CRON expression', type: 'value_error' }] },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
|
||||
await tasks.patchTask(1, { timer: 'invalid' }, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
|
|
@ -465,54 +341,6 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete success', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
jsonData: mockTask,
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
|
||||
await tasks.deleteTask(1, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
error: null,
|
||||
detail: null,
|
||||
data: true,
|
||||
})
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls callback on delete error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
jsonData: { error: 'Task not found' },
|
||||
}),
|
||||
)
|
||||
|
||||
const callback = mock(() => {})
|
||||
const tasks = useTasks()
|
||||
|
||||
await tasks.deleteTask(999, callback)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
data: false,
|
||||
}),
|
||||
)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inspectTaskHandler', () => {
|
||||
|
|
|
|||
120
ui/tests/plugins/modal-opacity.client.test.ts
Normal file
120
ui/tests/plugins/modal-opacity.client.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const disableOpacityMock = mock(() => {});
|
||||
const enableOpacityMock = mock(() => {});
|
||||
const syncOpacityMock = mock(() => {});
|
||||
|
||||
mock.module('~/utils', () => ({
|
||||
disableOpacity: disableOpacityMock,
|
||||
enableOpacity: enableOpacityMock,
|
||||
syncOpacity: syncOpacityMock,
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { defineNuxtPlugin?: (setup: () => void) => () => void }).defineNuxtPlugin =
|
||||
(setup) => setup;
|
||||
|
||||
let plugin: Awaited<typeof import('../../app/plugins/modal-opacity.client.ts')>['default'];
|
||||
let started = false;
|
||||
|
||||
const flushMutations = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const createOverlay = (): HTMLDivElement => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.setAttribute('data-slot', 'overlay');
|
||||
return overlay;
|
||||
};
|
||||
|
||||
const startPlugin = (): void => {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
plugin();
|
||||
document.dispatchEvent(new window.Event('DOMContentLoaded'));
|
||||
started = true;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
plugin = (await import('../../app/plugins/modal-opacity.client.ts')).default;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.MutationObserver = window.MutationObserver;
|
||||
document.body.innerHTML = '';
|
||||
disableOpacityMock.mockClear();
|
||||
enableOpacityMock.mockClear();
|
||||
syncOpacityMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
await flushMutations();
|
||||
});
|
||||
|
||||
describe('modal opacity plugin', () => {
|
||||
it('ignores a settings-only overlay', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
settingsPanel.className = 'yt-settings-panel';
|
||||
|
||||
document.body.append(settingsPanel, createOverlay());
|
||||
await flushMutations();
|
||||
|
||||
expect(disableOpacityMock).not.toHaveBeenCalled();
|
||||
expect(enableOpacityMock).not.toHaveBeenCalled();
|
||||
expect(syncOpacityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores opacity when a normal overlay closes back to settings-only', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
settingsPanel.className = 'yt-settings-panel';
|
||||
const settingsOverlay = createOverlay();
|
||||
const modalOverlay = createOverlay();
|
||||
|
||||
document.body.append(settingsPanel, settingsOverlay);
|
||||
await flushMutations();
|
||||
|
||||
document.body.append(modalOverlay);
|
||||
await flushMutations();
|
||||
|
||||
expect(disableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
modalOverlay.remove();
|
||||
await flushMutations();
|
||||
|
||||
expect(enableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resyncs opacity when overlays change while already locked', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
await flushMutations();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
await flushMutations();
|
||||
|
||||
expect(disableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
expect(syncOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not unlock opacity on beforeunload when reload is canceled', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
await flushMutations();
|
||||
|
||||
expect(disableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.dispatchEvent(new window.Event('beforeunload'));
|
||||
await flushMutations();
|
||||
|
||||
expect(enableOpacityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -131,19 +131,7 @@ afterEach(() => {
|
|||
}
|
||||
});
|
||||
|
||||
describe('utils/index setup', () => {
|
||||
it('exposes core utilities after mocks initialize', () => {
|
||||
expect(Array.isArray(utils.separators)).toBe(true);
|
||||
expect(typeof utils.getValue).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('object access helpers', () => {
|
||||
it('getValue resolves direct values and callables', () => {
|
||||
expect(utils.getValue(5)).toBe(5);
|
||||
expect(utils.getValue(() => 7)).toBe(7);
|
||||
});
|
||||
|
||||
it('ag returns nested value or default value', () => {
|
||||
const payload = { a: { b: { c: 42 } } };
|
||||
expect(utils.ag(payload, 'a.b.c')).toBe(42);
|
||||
|
|
@ -240,16 +228,6 @@ describe('string manipulation helpers', () => {
|
|||
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
|
||||
});
|
||||
|
||||
it('eTrim and sTrim delegate to iTrim ends', () => {
|
||||
expect(utils.eTrim('##name##', '#')).toBe('##name');
|
||||
expect(utils.sTrim('##name##', '#')).toBe('name##');
|
||||
});
|
||||
|
||||
it('ucFirst capitalizes first character', () => {
|
||||
expect(utils.ucFirst('ytp')).toBe('Ytp');
|
||||
expect(utils.ucFirst('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodePath safely encodes components', () => {
|
||||
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
|
||||
});
|
||||
|
|
@ -309,11 +287,6 @@ describe('string manipulation helpers', () => {
|
|||
expect(utils.removeANSIColors(sample)).toBe('Error');
|
||||
});
|
||||
|
||||
it('dec2hex converts to two character hex strings', () => {
|
||||
expect(utils.dec2hex(15)).toBe('0f');
|
||||
expect(utils.dec2hex(255)).toBe('ff');
|
||||
});
|
||||
|
||||
it('basename returns final segment optionally trimming extension', () => {
|
||||
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
|
||||
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
|
||||
|
|
@ -336,11 +309,6 @@ describe('string manipulation helpers', () => {
|
|||
expect(utils.formatTime(90)).toBe('01:30');
|
||||
expect(utils.formatTime(3661)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('getSeparatorsName returns human readable label', () => {
|
||||
expect(utils.getSeparatorsName(',')).toContain('Comma');
|
||||
expect(utils.getSeparatorsName('*')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data conversion helpers', () => {
|
||||
|
|
@ -358,15 +326,6 @@ describe('data conversion helpers', () => {
|
|||
expect(utils.decode(encoded)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('makePagination builds a ranged pagination list', () => {
|
||||
const pages = utils.makePagination(5, 10, 1);
|
||||
const selected = pages.find((page: any) => page.selected);
|
||||
expect(selected?.page).toBe(5);
|
||||
expect(pages.length).toBeGreaterThan(0);
|
||||
expect(pages[0]?.page).toBe(1);
|
||||
expect(pages[pages.length - 1]?.page).toBe(10);
|
||||
});
|
||||
|
||||
it('getQueryParams parses query strings', () => {
|
||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
|
||||
});
|
||||
|
|
@ -401,29 +360,6 @@ describe('data conversion helpers', () => {
|
|||
});
|
||||
|
||||
describe('dom and browser helpers', () => {
|
||||
it('dEvent dispatches custom event with detail payload', () => {
|
||||
const detail = { foo: 'bar' };
|
||||
let received: unknown = null;
|
||||
const listener = (event: Event) => {
|
||||
received = (event as CustomEvent).detail;
|
||||
};
|
||||
|
||||
window.addEventListener('custom-detail', listener);
|
||||
const dispatched = utils.dEvent('custom-detail', detail);
|
||||
window.removeEventListener('custom-detail', listener);
|
||||
|
||||
expect(dispatched).toBe(true);
|
||||
expect(received).toEqual(detail);
|
||||
});
|
||||
|
||||
it('toggleClass adds and removes classes', () => {
|
||||
const el = document.createElement('div');
|
||||
utils.toggleClass(el, 'active');
|
||||
expect(el.classList.contains('active')).toBe(true);
|
||||
utils.toggleClass(el, 'active');
|
||||
expect(el.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('copyText uses clipboard API and notifies success', async () => {
|
||||
utils.copyText('sample');
|
||||
|
||||
|
|
@ -439,7 +375,7 @@ describe('dom and browser helpers', () => {
|
|||
it('disableOpacity toggles body opacity when enabled', () => {
|
||||
const result = utils.disableOpacity();
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.getAttribute('style')).toBe('opacity: 1.0');
|
||||
expect(document.body.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('disableOpacity returns false when background disabled', () => {
|
||||
|
|
@ -474,7 +410,27 @@ describe('dom and browser helpers', () => {
|
|||
storageMap.set('random_bg_opacity', { value: 0.75 });
|
||||
const result = utils.enableOpacity();
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.getAttribute('style')).toBe('opacity: 0.75');
|
||||
expect(document.body.style.opacity).toBe('0.75');
|
||||
});
|
||||
|
||||
it('syncOpacity keeps full opacity while a lock is active', () => {
|
||||
utils.disableOpacity();
|
||||
storageMap.set('random_bg_opacity', { value: 0.35 });
|
||||
|
||||
const result = utils.syncOpacity();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('syncOpacity clears body opacity when background is disabled', () => {
|
||||
document.body.style.opacity = '0.8';
|
||||
storageMap.set('random_bg', { value: false });
|
||||
|
||||
const result = utils.syncOpacity();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.style.opacity).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -520,14 +476,6 @@ describe('network and id helpers', () => {
|
|||
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail');
|
||||
});
|
||||
|
||||
it('makeId uses crypto random values for deterministic id', () => {
|
||||
const id = utils.makeId(4);
|
||||
expect(id).toBe('0101');
|
||||
expect(getRandomValuesMock).toHaveBeenCalled();
|
||||
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array;
|
||||
expect(typedArray).toBeInstanceOf(Uint8Array);
|
||||
expect(typedArray.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async helpers', () => {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildYtdlpGroupItems,
|
||||
normalizeYtdlpGroupFilter,
|
||||
YTDLP_ALL_GROUPS,
|
||||
} from '~/utils/ytdlpOptions';
|
||||
|
||||
describe('ytdlpOptions helpers', () => {
|
||||
it('uses a non-empty sentinel value for the all-groups option', () => {
|
||||
const items = buildYtdlpGroupItems(['root', 'video']);
|
||||
|
||||
expect(items[0]).toEqual({ label: 'All groups', value: YTDLP_ALL_GROUPS });
|
||||
expect(items.every((item) => item.value !== '')).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes the all-groups sentinel back to an empty filter', () => {
|
||||
expect(normalizeYtdlpGroupFilter(YTDLP_ALL_GROUPS)).toBe('');
|
||||
expect(normalizeYtdlpGroupFilter('root')).toBe('root');
|
||||
});
|
||||
});
|
||||
253
uv.lock
253
uv.lock
|
|
@ -738,64 +738,64 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.3"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/42/149c7747977db9d68faee960c1a3391eb25e94d4bb677f8e2df8328e4098/lxml-6.0.3.tar.gz", hash = "sha256:a1664c5139755df44cab3834f4400b331b02205d62d3fdcb1554f63439bf3372", size = 4237567 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a6/2cdc9c5a634b1b890927f968febc2474fa3eb6fed99db82ea3c008bbbda4/lxml-6.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:83c1d75e9d124ab82a4ddaf59135112f0dc49526b47355e5928ae6126a68e236", size = 8559579 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3c/adfbcdab17f89f72e069c5df5661c81e0511e3cdb353550f778e9ffaa08e/lxml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b683665d0287308adafc90a5617a51a508d8af8c7040693693bb333b5f4474fe", size = 4617332 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/d4/ee1a5c734a5ad79024fa85808f3efc18d5733813141e2bb2726a7d9d8bea/lxml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed31e5852cd938704bc6c7a3822cbf84c7fa00ebfa914a1b4e2392d44f45bdfb", size = 4922821 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/1f/87efcc0b93ba4f95303ec8f80164f3c50db20a3a5612a285133f9ad6cb7e/lxml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8922a30704a4421d69a19e0499db5861da686c0bccc3a79cf3946e3155cf25f9", size = 5081226 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8b/fd0fadd9ec8a6ac9d694014ccdb9504e28705abb2e08c9ca23c609020325/lxml-6.0.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a1adb0e220cb8691202ba9d97646a06292657a122df4b92733861d42f7cf4d2", size = 4992884 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/75/2fb0e534225214c6386496b7847195d7297b913cf563c5ccea394afc346b/lxml-6.0.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:821fd53699eb498990c915ba955a392d07246454c9405e6c1d0692362503013d", size = 5613383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3a/8f560f8fb2f5f092e18ac7a13a94b77e0e5213fe7c424d12e98393dcc7d8/lxml-6.0.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04b7cedf52e125f86d0d426635e7fbe8e353d4cc272a1757888e3c072424381d", size = 5228398 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/d5/6bf993c02a0173eb5883ace61958c55c245d3daf7753fb5f931a9691b440/lxml-6.0.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9d98063e6ae0da5084ec46952bb0a5ccb5e2cad168e32b4d65d1ec84e4b4ebd4", size = 5342198 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/18/637130349ca6aa33b6dc4796732835ede5017a811c5f55763a1c468f7971/lxml-6.0.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:ce01ab3449015358f766a1950b3d818eedf9d4cdec3fa87e4eecaad10c0784db", size = 4699178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/19/239daafcc1cfa42b8aa6384509a9fd2cb1aa281679c6e8395adf9ccbc189/lxml-6.0.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d38c25bad123d6ce30bb37931d90a4e8a167cd796eeae9cd16c2bfce52718f8e", size = 5231869 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/74/db7fcadc651b988502bed00d48acfd8b997ecb5dd52ebcc05f39bf946d9e/lxml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b8e0779780026979f217603385995202f364adc9807bd21210d81b9f562fc4e", size = 5043669 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/99/af795b579182fa04aa87fcb0bd112e22705d982f71eb53874a8d356b4091/lxml-6.0.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8c082ad2398664213a4bb5d133e2eb8bf239220b7d6688f8c8ffa9050057501f", size = 4769745 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4d/10e652edc55d206188a1b738d1033aad3497886d34cb7f5fc753e67ecb49/lxml-6.0.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfc80c74233fe01157ab550fb12b9d07a2f1fa7c5900cefb484e3bf02e856fbc", size = 5635496 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/68/95371835ec15bb46feee27b090bcabbe579f4ad04efbef08e2713bcfea16/lxml-6.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c45bdcdc2ca6cf26fddff3faa5de7a2ed7c7f6016b3de80125313a37f972378", size = 5223564 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a6/0a9e5b63e8959487551be5d5496bb758ed2424c77ed7b25a9b8aae3b60c6/lxml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99457524afd384c330dc51e527976653d543ccadfa815d9f2d92c5911626e536", size = 5250124 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/80/de3d3a790edf6d026c829fe8ccf54845058f57f8bb788e420c3b227eecef/lxml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:c8e3b8a54e65393ce1d5c7d9753fe756f0d96089e7163b20ddec3e5bb56a963e", size = 3596004 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cf/43c9a5926060e39d99593921f37d7e88f129bc32ab6266b8460483abd613/lxml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:724b26a38cef98d6869d00a33cb66083bee967598e44f6a8e53f1dd283c851b0", size = 3994750 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d3/b224dbc282bfef52d2e05645e405b5ed89c6391144dc09864229fe9ce88c/lxml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:f27373113fda6621e4201f529908a24c8a190c2af355aed4711dadca44db4673", size = 3657620 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/40/b637359bacf3813f1174d15b08516020ba5beb355e04377105d561e6e00a/lxml-6.0.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8c08926678852a233bf1ef645c4d683d56107f814482f8f41b21ef2c7659790e", size = 8575318 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/91/d5286a45202ed91f1e428e68c6e1c11bcb2b42715c48424871fc73485b05/lxml-6.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ce76d113a7c3bf42761ec1de7ca615b0cbf9d8ae478eb1d6c20111d9c9fc098", size = 4623084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/5f/7ea1af571ee13ed1e5fba007fd83cd0794723ca76a51eed0ef9513363b1f/lxml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83eca62141314d641ebe8089ffa532bbf572ea07dd6255b58c40130d06bb2509", size = 4948797 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/be/3a9b8d787d9877cbe17e02ef5af2523bd14ecc177ce308397c485c56fe18/lxml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8781d812bb8efd47c35651639da38980383ff0d0c1f3269ade23e3a90799079", size = 5085983 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/2b/645abaef837b11414c81513c31b308a001fb8cd370f665c3ebc854be5ba5/lxml-6.0.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b079e81aa3a31b523a224b0dd46da4f56e1b1e248eef9a599e5c885c788813", size = 5031039 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/4f/561f30b77e9edbb373e2b6b7203a7d6ab219c495abca219536c66f3a44b2/lxml-6.0.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c055bafdcb53e7f9f75e22c009cd183dd410475e21c296d599531d7f03d1bf5", size = 5646718 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ba/2a72e673d109b563c2ab77097f2f4ca64e2927d2f04836ba07aaabe1da0e/lxml-6.0.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f1594a183cee73f9a1dbfd35871c4e04b461f47eeb9bcf80f7d7856b1b136d", size = 5239360 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/98/4e5a4ef87d846af90cc9c1ee2f8af2af34c221e620aad317b3a535361b93/lxml-6.0.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:a6380c5035598e4665272ad3fc86c96ddb2a220d4059cce5ba4b660f78346ad9", size = 5351233 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b8/cff0af5fe48ede6b1949dc2e14171470c0c68a15789037c1fed90602b89d/lxml-6.0.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:143ac903fb6c9be6da613390825c8e8bb8c8d71517d43882031f6b9bc89770ef", size = 4696677 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/6e/0b2a918fb15c30b00ff112df16c548df011db37b58d764bd17f47db74905/lxml-6.0.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4fff7d77f440378cd841e340398edf5dbefee334816efbf521bb6e31651e54e", size = 5250503 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/1b/4697918f9d4c2e643e2c59cedb37c2f3a9f76fb1217d767f6dff476813d8/lxml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:631567ffc3ddb989ccdcd28f6b9fa5aab1ec7fc0e99fe65572b006a6aad347e2", size = 5084563 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/8c/d7ec96246f0632773912c6556288d3b6bb6580f3a967441ca4636ddc3f73/lxml-6.0.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:38acf7171535ffa7fff1fcec8b82ebd4e55cd02e581efe776928108421accaa1", size = 4737407 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/0c/603e35bf77aeb28c972f39eece35e7c0f6579ff33a7bed095cc2f7f942d9/lxml-6.0.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:06b9f3ac459b4565bbaa97aa5512aa7f9a1188c662f0108364f288f6daf35773", size = 5670919 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/6d3f188e6705cf0bfd8b5788055c7381bb3ffa786dfba9fa0b0ed5778506/lxml-6.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2773dbe2cedee81f2769bd5d24ceb4037706cf032e1703513dd0e9476cd9375f", size = 5237771 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/4c/01639533b90e9ff622909c113df2ab2dbdd1d78540eb153d13b66a9c96ba/lxml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:30c437d8bb9a9a9edff27e85b694342e47a26a6abc249abe00584a4824f9d80d", size = 5263862 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/0e/bd1157d7b09d1f5e1d580c124203cee656130a3f8908365760a593b21daf/lxml-6.0.3-cp314-cp314-win32.whl", hash = "sha256:1b60a3a1205f869bd47874787c792087174453b1a869db4837bf5b3ff92be017", size = 3656378 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/cc/d50cbce8cd5687670868bea33bbeefa0866c5e5d02c5e11c4a04c79fc45e/lxml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:5b6913a68d98c58c673667c864500ba31bc9b0f462effac98914e9a92ebacd2e", size = 4062518 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c7/ece11a1e51390502894838aa384e9f98af7bef4d6806a927197153a16972/lxml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:1b36a3c73f2a6d9c2bfae78089ca7aedae5c2ee5fd5214a15f00b2f89e558ba7", size = 3741064 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ae/918d7f89635fb6456cd732c12246c0e504dd9c49e8006f3593c9ecdb90ff/lxml-6.0.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:239e9a6be3a79c03ec200d26f7bb17a4414704a208059e20050bf161e2d8848a", size = 8826590 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/cf/bda0ae583758704719976b9ea69c8b089fa5f92e49683e517386539b21cf/lxml-6.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16e5cbaa1a6351f2abefa4072e9aac1f09103b47fe7ab4496d54e5995b065162", size = 4735028 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/0e/3bfb18778c6f73c7ead2d49a256501fa3052888b899826f5d1df1fbdf83b/lxml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89f8746c206d8cf2c167221831645d6cc2b24464afd9c428a5eb3fd34c584eb1", size = 4969184 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e6/796c77751a682d6d1bb9aa3fe43851b41a21b0377100e246a4a83a81d668/lxml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d559a84b2fd583e5bcf8ec4af1ec895f98811684d5fbd6524ea31a04f92d4ad", size = 5103548 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/5e/a02aee214f657f29d4690d88161de8ffb8f1b5139e792bae313b9479e317/lxml-6.0.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7966fbce2d18fde579d5593933d36ad98cc7c8dc7f2b1916d127057ce0415062", size = 5027775 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e5/65dd25f2c366879d696d1c720af9a96fa0969d2d135a27b6140222fc6f68/lxml-6.0.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1f258e6aa0e6eda2c1199f5582c062c96c7d4a28d96d0c4daa79e39b3f2a764", size = 5595348 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/1f/2f0e80d7fd2ad9755d771af4ad46ea14bf871bc5a1d2d365a3f948940ddf/lxml-6.0.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:738aef404c862d2c3cd951364ee7175c9d50e8290f5726611c4208c0fba8d186", size = 5224217 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/28/e1aaeee7d6a4c9f24a3e4535a4e19ce64b99eefbe7437d325b61623b1817/lxml-6.0.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:5c35e5c3ed300990a46a144d3514465713f812b35dacfa83e928c60db7c90af7", size = 5312245 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/ac/9633cb919124473e03c62862b0494bf0e1705f902fbd9627be4f648bddfb/lxml-6.0.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:4ff774b43712b0cf40d9888a5494ca39aefe990c946511cc947b9fddcf74a29b", size = 4637952 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/aa/135baeea457d41989bafa78e437fe3a370c793aab0d8fb3da73ccae10095/lxml-6.0.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d20af2784c763928d0d0879cbc5a3739e4d81eefa0d68962d3478bff4c13e644", size = 5232782 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/77/d05183ac8440cbc4c6fa386edb7ba9718bee4f097e58485b1cd1f9479d56/lxml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fdb7786ebefaa0dad0d399dfeaf146b370a14591af2f3aea59e06f931a426678", size = 5083889 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/58/e9fda8fb82775491ad0290c7b17252f944b6c3a6974cd820d65910690351/lxml-6.0.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c71a387ea133481e725079cff22de45593bf0b834824de22829365ab1d2386c9", size = 4758658 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/32/4aae9f004f79f9d200efd8343809cfe46077f8e5bd58f08708c320a20fcd/lxml-6.0.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:841b89fc3d910d61c7c267db6bb7dc3a8b3dac240edb66220fcdf96fe70a0552", size = 5619494 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/49/407fa9e3c91e7c6d0762eaeedd50d4695bcd26db817e933ca689eb1f3df4/lxml-6.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ac2d6cdafa29672d6a604c641bf67ace3fd0735ec6885501a94943379219ddbf", size = 5228386 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/92/39982f818acbb1dd67dd5d20c2a06bcb9f1f3b9a8ff0021e367904f82417/lxml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:609bf136a7339aeca2bd4268c7cd190f33d13118975fe9964eda8e5138f42802", size = 5247973 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/68/fcdbb78c8cda81a86e17b31abf103b7e474e474a09fb291a99e7a9b43eb8/lxml-6.0.3-cp314-cp314t-win32.whl", hash = "sha256:bf98f5f87f6484302e7cce4e2ca5af43562902852063d916c3e2f1c115fdce60", size = 3896249 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fb/6292681ac4a4223b700569ce98f71662cb07c5a3ade4f346f5f0d5c574cf/lxml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d3d65e511e4e656ec67b472110f7a72cbf8547ca15f76fe74cffa4e97412a064", size = 4391091 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/a0f486360a6f1b36fd2f5eb62d037652bef503d82b6f853aee6664cdfcac/lxml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:cbc7ce67f85b92db97c92219985432be84dc1ba9a028e68c6933e89551234df2", size = 3816374 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -892,11 +892,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
version = "26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1110,7 +1110,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
|
|
@ -1118,62 +1118,65 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.46.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c", size = 2101762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10", size = 1951814 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133", size = 1977329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab", size = 2051832 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11", size = 2233127 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b", size = 2297418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3", size = 2093735 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2", size = 2127570 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9", size = 2183524 },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80", size = 2185408 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c", size = 2335171 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24", size = 2362743 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c", size = 1958074 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e", size = 2071741 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904 },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922 },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130 },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1635,27 +1638,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.10"
|
||||
version = "0.15.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813 },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887 },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Reference in a new issue