Merge pull request #590 from arabcoders/dev

Refactor: Seperate history items from queue
This commit is contained in:
Abdulmohsen 2026-04-21 23:24:42 +03:00 committed by GitHub
commit ed77ea508c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 9965 additions and 8359 deletions

View file

@ -1,8 +1,9 @@
# YTPTube # YTPTube
![Build Status](https://github.com/ArabCoders/ytptube/actions/workflows/main.yml/badge.svg) ![Build Status](https://github.com/arabcoders/ytptube/actions/workflows/main.yml/badge.svg)
![MIT License](https://img.shields.io/github/license/arabcoders/ytptube.svg) ![MIT License](https://img.shields.io/github/license/arabcoders/ytptube.svg)
![Docker pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube) ![Docker Pull](https://img.shields.io/docker/pulls/arabcoders/ytptube.svg)
![gchr Pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube)
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from **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 video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and

View file

@ -15,6 +15,7 @@ from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__) 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")): if not (cond := params.get("condition")):
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) 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: try:
preset: str = params.get("preset", config.default_preset) preset: str = params.get("preset", config.default_preset)
key: str = cache.hash(url + str(preset)) key: str = cache.hash(url + str(preset))

View file

@ -4,8 +4,14 @@ from __future__ import annotations
import pytest import pytest
import pytest_asyncio 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.features.conditions.repository import ConditionsRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
@ -34,28 +40,43 @@ async def repo():
SqliteStore._reset_singleton() 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: class TestConditionsRepository:
"""Test suite for ConditionsRepository database operations.""" """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 @pytest.mark.asyncio
async def test_create_condition(self, repo): async def test_create_condition(self, repo):
"""Create condition with valid data.""" """Create condition with valid data."""

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from app.features.dl_fields.models import DLFieldModel
from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.repository import DLFieldsRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
@ -35,25 +34,6 @@ async def repo(tmp_path):
class TestDLFieldsRepository: class TestDLFieldsRepository:
"""Test suite for DLFieldsRepository database operations.""" """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 @pytest.mark.asyncio
async def test_create_field(self, repo): async def test_create_field(self, repo):
"""Create field with valid data.""" """Create field with valid data."""

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from app.features.tasks.models import TaskModel
from app.features.tasks.repository import TasksRepository from app.features.tasks.repository import TasksRepository
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
@ -35,25 +34,6 @@ async def repo(tmp_path):
class TestTasksRepository: class TestTasksRepository:
"""Test suite for TasksRepository database operations.""" """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 @pytest.mark.asyncio
async def test_create_task(self, repo): async def test_create_task(self, repo):
"""Create task with valid data.""" """Create task with valid data."""

View file

@ -107,10 +107,10 @@ class HttpAPI:
registered_options: list = [] registered_options: list = []
base_path: str = self.config.base_path.rstrip("/") 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") 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: async def options_handler(_: Request) -> Response:
return web.Response(status=204) return web.Response(status=204)

View file

@ -300,6 +300,7 @@ class DownloadQueue(metaclass=Singleton):
""" """
status: dict[str, str] = {} status: dict[str, str] = {}
deleted_ids: list[str] = []
for id in ids: for id in ids:
try: try:
@ -349,6 +350,7 @@ class DownloadQueue(metaclass=Singleton):
LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}") LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}")
await self.done.delete(id) await self.done.delete(id)
deleted_ids.append(id)
_status: str = "Removed" if removed_files > 0 else "Cleared" _status: str = "Removed" if removed_files > 0 else "Cleared"
self._notify.emit( self._notify.emit(
@ -365,6 +367,9 @@ class DownloadQueue(metaclass=Singleton):
LOG.info(msg=msg) LOG.info(msg=msg)
status[id] = "ok" status[id] = "ok"
if deleted_ids:
await self.done._connection.flush()
return status return status
async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]: async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:

View file

@ -1,19 +1,18 @@
import logging import logging
from pathlib import Path from pathlib import Path, PurePosixPath
import magic import magic
from aiohttp import web 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.config import Config
from app.library.router import add_route from app.library.router import add_route
from app.library.Utils import get_file
MIME = magic.Magic(mime=True) MIME = magic.Magic(mime=True)
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
STATIC_FILES: dict[str, dict] = {} EXT_TO_MIME: dict[str, str] = {
EXT_TO_MIME: dict = {
".html": "text/html", ".html": "text/html",
".css": "text/css", ".css": "text/css",
".js": "application/javascript", ".js": "application/javascript",
@ -23,120 +22,167 @@ EXT_TO_MIME: dict = {
".m4a": "audio/mp4", ".m4a": "audio/mp4",
} }
FRONTEND_ROUTES: list[str] = [
"/console/", class StaticState:
"/presets/", def __init__(self) -> None:
"/tasks/", self.root: Path | None = None
"/task_definitions/", self.index_file: Path | None = None
"/notifications/",
"/changelog/",
"/logs/",
"/conditions/",
"/browser/",
"/simple/",
"/browser/{path:.*}",
]
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: Args:
request (Request): The request object. request (Request): The request object.
config (Config): The configuration instance. config (Config): The configuration instance.
Returns: 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): if file_path is None:
path = path.replace(config.base_path[:-1], "", 1) if (
STATIC_STATE.index_file is not None
if path not in STATIC_FILES: and not path.startswith("/api/")
for k in STATIC_FILES: and not PurePosixPath(path.lstrip("/")).suffix
if path.startswith(k): ):
path = k file_path = STATIC_STATE.index_file
break
else: else:
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code) return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
item: dict = STATIC_FILES[path]
return web.FileResponse( return web.FileResponse(
path=item["file"], path=file_path,
headers={ headers={
"Pragma": "public", "Pragma": "public",
"Cache-Control": "public, max-age=31536000", "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, 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: Args:
root_path (Path): The root path of the application. root_path (Path): The application root path.
config (Config): The configuration instance. config (Config): The configuration instance.
""" """
global STATIC_FILES # noqa: PLW0602 STATIC_STATE.root = get_root(root_path, config)
static_dir: Path | None = None STATIC_STATE.index_file = None
webui_files: list[Path] = [
(root_path / "ui" / "exported").absolute(),
(root_path.parent / "ui" / "exported").absolute(),
]
if config.static_ui_path: if STATIC_STATE.root is None:
webui_files = [Path(config.static_ui_path).absolute()] return
for p in webui_files: index_file: Path = (STATIC_STATE.root / "index.html").resolve()
if p.exists(): if not index_file.is_file():
static_dir = p message: str = f"Failed to find frontend entry file at '{index_file}'."
break if config.ignore_ui:
LOG.warning(message)
STATIC_STATE.root = None
return
if static_dir is None: raise ValueError(message)
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)
preloaded = 0 STATIC_STATE.index_file = index_file
for file in static_dir.rglob("*.*"): add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
if ".map" == file.suffix: add_route(method="GET", path="/{path:.*}", handler=serve_static_file, name="static_fallback")
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")
if "/" != config.base_path: 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") add_route(method="GET", path="/", handler=redirect_index, name="index_redirect")
if preloaded < 1: LOG.info(f"Serving frontend static assets from '{STATIC_STATE.root}'.")
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.")

View file

@ -215,7 +215,11 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
page += 1 page += 1
if not items_to_delete: 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( return web.json_response(
data={ data={

View file

@ -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 from unittest.mock import MagicMock
import pytest import pytest

View file

@ -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 asyncio
import threading import threading
import time import time

View file

@ -1,5 +1,3 @@
"""Comprehensive tests for cf_solver_handler yt-dlp request handler."""
from __future__ import annotations from __future__ import annotations
import http.cookiejar import http.cookiejar
@ -224,34 +222,3 @@ class TestCFSolverRH:
new_request = self.module.CFSolverRH._mark_retry(request) new_request = self.module.CFSolverRH._mark_retry(request)
assert new_request.extensions.get("cf_retry") is True 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)

View file

@ -1336,6 +1336,32 @@ class TestQueueManager:
item.close.assert_awaited_once() item.close.assert_awaited_once()
assert status[item.info._id] == "ok", "Regular running cancel should still report success" 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: class TestPoolManager:
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -8,11 +8,7 @@ from unittest.mock import Mock, patch
import pytest import pytest
from app.library.downloads.utils import ( from app.library.downloads.utils import (
BAD_LIVE_STREAM_OPTIONS,
DEBUG_MESSAGE_PREFIXES,
GENERIC_EXTRACTORS,
LIMITS, LIMITS,
YTDLP_PROGRESS_FIELDS,
create_debug_safe_dict, create_debug_safe_dict,
get_extractor_limit, get_extractor_limit,
handle_task_exception, 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: class TestPathUtilities:
def test_safe_relative_path_success(self) -> None: def test_safe_relative_path_success(self) -> None:
base = Path("/downloads") base = Path("/downloads")

View file

@ -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 import json
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path

View file

@ -1,5 +1,3 @@
"""Comprehensive tests for httpx_client Cloudflare challenge solving."""
from __future__ import annotations from __future__ import annotations
import http.cookiejar import http.cookiejar

View file

@ -1,10 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
import pytest
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
TEST_DIR = Path("var/tmp/tests_nfo") TEST_DIR = Path("var/tmp/tests_nfo")

View file

@ -1,5 +1,3 @@
"""Tests for the generic operations module."""
from app.library.operations import ( from app.library.operations import (
Operation, Operation,
filter_items, filter_items,

View file

@ -11,15 +11,7 @@ def reset_routes():
ROUTES.clear() ROUTES.clear()
class TestRouteType:
def test_all_returns_values(self) -> None:
assert set(RouteType.all()) == {"http", "socket"}
class TestMakeRouteName: 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: def test_trailing_slash_and_root(self) -> None:
# Current behavior converts empty part to 'part' # Current behavior converts empty part to 'part'
assert make_route_name("post", "/") == "post:part" assert make_route_name("post", "/") == "post:part"

View file

@ -1,5 +1,4 @@
import logging from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest import pytest
@ -13,16 +12,6 @@ class TestServices:
"""Clear services before each test.""" """Clear services before each test."""
Services._reset_singleton() 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): def test_add_and_get_service(self):
"""Test adding and retrieving services.""" """Test adding and retrieving services."""
services = Services() services = Services()
@ -264,29 +253,6 @@ class TestServices:
expected = "db:database, cache:redis, args:(), kwargs:{}" expected = "db:database, cache:redis, args:(), kwargs:{}"
assert result == expected 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): def test_add_none_service(self):
"""Test adding None as a service value.""" """Test adding None as a service value."""
services = Services() services = Services()
@ -295,22 +261,6 @@ class TestServices:
assert services.has("none_service") is True assert services.has("none_service") is True
assert services.get("none_service") is None 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): def test_overwrite_existing_service(self):
"""Test overwriting an existing service.""" """Test overwriting an existing service."""
services = Services() services = Services()
@ -319,22 +269,6 @@ class TestServices:
assert services.get("service") == "new_value" 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): def test_handler_exception_propagation(self):
"""Test that exceptions in handlers are properly propagated.""" """Test that exceptions in handlers are properly propagated."""
services = Services() services = Services()
@ -397,14 +331,6 @@ class TestServices:
result = services.handle_sync(lambda_handler) result = services.handle_sync(lambda_handler)
assert result == "Lambda: lambda_value" 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): def test_service_container_isolation(self):
"""Test that services don't interfere with each other.""" """Test that services don't interfere with each other."""
services = Services() services = Services()
@ -421,27 +347,6 @@ class TestServices:
assert services.get("data") is None assert services.get("data") is None
assert services.get("data_backup")["type"] == "backup" 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): def test_add_all_overwrites_existing(self):
"""Test that add_all overwrites existing services.""" """Test that add_all overwrites existing services."""
services = Services() services = Services()
@ -465,20 +370,6 @@ class TestServices:
assert services.get("existing") == "value" assert services.get("existing") == "value"
assert len(services.get_all()) == 1 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): def test_concurrent_access_safety(self):
"""Test basic thread safety aspects of singleton.""" """Test basic thread safety aspects of singleton."""
import threading import threading
@ -504,47 +395,3 @@ class TestServices:
# All should be the same instance # All should be the same instance
assert len(set(results)) == 1, "All threads should get the same singleton 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

View 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()}

View file

@ -47,23 +47,6 @@ class TestCheckUpdatesEndpoint:
assert "up_to_date" in body, "Response should include up_to_date status" assert "up_to_date" in body, "Response should include up_to_date status"
assert mock_check.called, "Should have called check_for_updates" 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 @pytest.mark.asyncio
async def test_check_updates_update_available(self): async def test_check_updates_update_available(self):
"""Test check updates returns update_available status with new version.""" """Test check updates returns update_available status with new version."""
@ -81,37 +64,3 @@ class TestCheckUpdatesEndpoint:
body = response.body.decode("utf-8") body = response.body.decode("utf-8")
assert "v1.0.5" in body, "Response should include new version" assert "v1.0.5" in body, "Response should include new version"
assert "update_available" in body, "Response should include update_available status" 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"

View file

@ -21,26 +21,6 @@ class TestUpdateChecker:
EventBus._reset_singleton() EventBus._reset_singleton()
Cache._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): def test_attach_schedules_check_when_enabled(self):
"""Test that attach schedules update check when config.check_for_updates is True.""" """Test that attach schedules update check when config.check_for_updates is True."""
import asyncio import asyncio

View file

@ -10,6 +10,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from app.features.ytdlp.utils import arg_converter from app.features.ytdlp.utils import arg_converter
from app.library.Utils import ( from app.library.Utils import (
FileLogFormatter, FileLogFormatter,

View file

@ -40,7 +40,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
size="lg" size="lg"
class="justify-center sm:min-w-28" class="justify-center sm:min-w-28"
@ -248,7 +249,7 @@
<div class="flex items-end"> <div class="flex items-end">
<UButton <UButton
type="button" type="button"
color="error" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
:disabled="addInProgress" :disabled="addInProgress"
@ -306,7 +307,8 @@
<div class="flex items-end"> <div class="flex items-end">
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-plus" icon="i-lucide-plus"
class="justify-center" class="justify-center"
:disabled="addInProgress || !newExtraKey || !newExtraValue" :disabled="addInProgress || !newExtraKey || !newExtraValue"
@ -473,7 +475,9 @@
: 'i-lucide-circle-help' : 'i-lucide-circle-help'
" "
title="Filter Status" title="Filter Status"
:description="logicStatusText" :description="
testData.data.status === null ? 'Not tested' : logicTest ? 'Matched' : 'Not matched'
"
/> />
<UFormField :ui="fieldUi"> <UFormField :ui="fieldUi">
@ -521,6 +525,7 @@ import { match_str } from '~/utils/ytdlp';
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void; (e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void; (e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
}>(); }>();
@ -533,7 +538,7 @@ const props = defineProps<{
const toast = useNotification(); const toast = useNotification();
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false);
const box = useConfirm(); const box = useConfirm();
const config = useConfigStore(); const config = useYtpConfig();
const form = reactive<Condition>(normalizeCondition(props.item)); const form = reactive<Condition>(normalizeCondition(props.item));
const importString = ref(''); const importString = ref('');
@ -555,6 +560,16 @@ const testData = ref<{
const showOptions = ref(false); const showOptions = ref(false);
const ytDlpOpt = ref<AutoCompleteOptions>([]); 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 = { const fieldUi = {
label: 'font-semibold text-default', label: 'font-semibold text-default',
container: 'space-y-2', container: 'space-y-2',
@ -584,10 +599,20 @@ watch(
() => props.item, () => props.item,
(value) => { (value) => {
Object.assign(form, normalizeCondition(value)); Object.assign(form, normalizeCondition(value));
importString.value = '';
newExtraKey.value = '';
newExtraValue.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
}, },
{ deep: true }, { deep: true },
); );
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch( watch(
() => config.ytdlp_options, () => config.ytdlp_options,
(newOptions) => (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> => { const checkInfo = async (): Promise<void> => {
for (const key of ['name', 'filter'] as const) { for (const key of ['name', 'filter'] as const) {
if (!form[key]) { if (!form[key]) {
@ -853,4 +870,9 @@ const updateExtraValue = (key: string, rawValue: string): void => {
[key]: rawValue.trim() ? parseValue(rawValue.trim()) : '', [key]: rawValue.trim() ? parseValue(rawValue.trim()) : '',
}; };
}; };
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script> </script>

View file

@ -40,7 +40,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
size="lg" size="lg"
class="justify-center sm:min-w-28" class="justify-center sm:min-w-28"
@ -247,6 +248,7 @@ import { decode } from '~/utils';
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void; (e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void; (e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
}>(); }>();
@ -258,7 +260,7 @@ const props = defineProps<{
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); const box = useConfirm();
const config = useConfigStore(); const config = useYtpConfig();
const fieldTypes = ['string', 'text', 'bool'] as const; const fieldTypes = ['string', 'text', 'bool'] as const;
const fieldTypeItems = [...fieldTypes]; const fieldTypeItems = [...fieldTypes];
@ -267,6 +269,14 @@ const ytDlpOptions = ref<AutoCompleteOptions>([]);
const showImport = useStorage('showDlFieldsImport', false); const showImport = useStorage('showDlFieldsImport', false);
const importString = ref(''); 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 = { const fieldUi = {
label: 'font-semibold text-default', label: 'font-semibold text-default',
container: 'space-y-2', container: 'space-y-2',
@ -287,10 +297,18 @@ watch(
() => props.item, () => props.item,
(value) => { (value) => {
Object.assign(form, normalizeField(value)); Object.assign(form, normalizeField(value));
importString.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
}, },
{ deep: true }, { deep: true },
); );
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch( watch(
() => config.ytdlp_options, () => config.ytdlp_options,
(newOptions) => (newOptions) =>
@ -399,4 +417,9 @@ const checkInfo = (): void => {
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }); emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
}; };
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script> </script>

View file

@ -5,7 +5,7 @@
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="inline-flex min-w-0 items-center gap-2 text-sm font-semibold text-default"> <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"> <UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
<span class="truncate" :class="{ 'has-tooltip': field }"> <span class="truncate" :class="{ 'has-tooltip': field }">
{{ label }} {{ label }}
@ -52,7 +52,7 @@
</template> </template>
<template v-else> <template v-else>
<span class="inline-flex items-center gap-2 font-semibold"> <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"> <UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
<span :class="{ 'has-tooltip': field }"> <span :class="{ 'has-tooltip': field }">
{{ label }} {{ label }}
@ -142,14 +142,6 @@ const boolModel = computed({
}, },
}); });
const resolvedIcon = computed(() => {
if (!props.icon) {
return '';
}
return props.icon;
});
const fieldUi = computed(() => ({ const fieldUi = computed(() => ({
container: props.compact ? 'space-y-1.5' : 'space-y-2', container: props.compact ? 'space-y-1.5' : 'space-y-2',
description: props.compact ? 'text-xs text-toned' : 'text-sm text-toned', description: props.compact ? 'text-xs text-toned' : 'text-sm text-toned',

View file

@ -9,7 +9,7 @@
@after:enter="focusInput" @after:enter="focusInput"
> >
<template #body> <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 }} {{ state.current?.opts.message }}
</p> </p>
@ -23,14 +23,6 @@
/> />
</UFormField> </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 <div
v-if=" v-if="
'confirm' === state.current?.type && 'confirm' === state.current?.type &&
@ -51,7 +43,7 @@
<template v-if="'alert' === state.current?.type"> <template v-if="'alert' === state.current?.type">
<UButton <UButton
id="primaryButton" id="primaryButton"
:color="resolveConfirmColor(state.current?.opts.confirmColor)" :color="state.current?.opts.confirmColor ?? 'primary'"
@click="onEnter" @click="onEnter"
> >
{{ state.current?.opts.confirmText ?? 'OK' }} {{ state.current?.opts.confirmText ?? 'OK' }}
@ -61,7 +53,7 @@
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type"> <template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
<UButton <UButton
id="primaryButton" id="primaryButton"
:color="resolveConfirmColor(state.current?.opts.confirmColor)" :color="state.current?.opts.confirmColor ?? 'primary'"
:disabled=" :disabled="
'prompt' === state.current?.type && 'prompt' === state.current?.type &&
localInput === (state.current?.opts as PromptOptions)?.initial localInput === (state.current?.opts as PromptOptions)?.initial
@ -129,8 +121,6 @@ const focusInput = async () => {
requestAnimationFrame(focusPrimary); requestAnimationFrame(focusPrimary);
}; };
const resolveConfirmColor = (color?: ConfirmOptions['confirmColor']) => color ?? 'primary';
const onCancel = () => cancel(); const onCancel = () => cancel();
const onEnter = () => const onEnter = () =>
confirm('confirm' === state.current?.type ? selected.value : localInput.value); confirm('confirm' === state.current?.type ? selected.value : localInput.value);

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,9 @@ const props = defineProps<{
unrenderDelay?: number; unrenderDelay?: number;
}>(); }>();
const ROOT_MARGIN = 600;
const nuxtApp = useNuxtApp();
const shouldRender = ref(false); const shouldRender = ref(false);
const targetEl = ref<HTMLElement | null>(null); const targetEl = ref<HTMLElement | null>(null);
const fixedMinHeight = ref(0); const fixedMinHeight = ref(0);
@ -21,12 +24,35 @@ const fixedMinHeight = ref(0);
let unrenderTimer: ReturnType<typeof setTimeout> | null = null; let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
let renderTimer: ReturnType<typeof setTimeout> | null = null; let renderTimer: ReturnType<typeof setTimeout> | null = null;
function onIdle(cb: () => void): void { function ensureRenderedIfNearViewport(): void {
if ('requestIdleCallback' in window) { if (!targetEl.value) {
(window as any).requestIdleCallback(cb); return;
} else {
setTimeout(() => nextTick(cb), 300);
} }
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( const { stop } = useIntersectionObserver(
@ -43,7 +69,7 @@ const { stop } = useIntersectionObserver(
props.unrender ? 200 : 0, props.unrender ? 200 : 0,
); );
shouldRender.value = true; ensureRenderedIfNearViewport();
if (!props.unrender) { if (!props.unrender) {
stop(); stop();
@ -61,19 +87,55 @@ const { stop } = useIntersectionObserver(
}, props.unrenderDelay ?? 6000); }, props.unrenderDelay ?? 6000);
} }
}, },
{ rootMargin: '600px' }, { rootMargin: `${ROOT_MARGIN}px` },
); );
if (props.renderOnIdle) { const removePageHooks = [
onIdle(() => { nuxtApp.hook('page:finish', () => {
shouldRender.value = true; requestAnimationFrame(() => {
if (!props.unrender) { ensureRenderedIfNearViewport();
stop(); });
} }),
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(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', ensureRenderedIfNearViewport);
removePageHooks.forEach((removeHook) => removeHook());
if (renderTimer) { if (renderTimer) {
clearTimeout(renderTimer); clearTimeout(renderTimer);
} }

View file

@ -202,6 +202,24 @@
border-radius: 0.9rem; 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 {
--markdown-alert-accent: color-mix(in oklab, var(--ui-border-accented) 70%, transparent); --markdown-alert-accent: color-mix(in oklab, var(--ui-border-accented) 70%, transparent);
margin-top: 1rem; margin-top: 1rem;
@ -294,6 +312,18 @@ const content = ref('');
const error = ref(''); const error = ref('');
const isLoading = ref(true); 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 createMarkdownParser = () => {
const parser = new Marked(); const parser = new Marked();
@ -370,7 +400,9 @@ const createMarkdownParser = () => {
const refPolicy = ' referrerpolicy="no-referrer"'; const refPolicy = ' referrerpolicy="no-referrer"';
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : ''; const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
const loading = ' loading="lazy"'; 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} />`;
}, },
}, },
}); });

View file

@ -37,7 +37,6 @@
base: 'min-h-[7.25rem] bg-elevated/60 ring-default focus-visible:ring-primary', base: 'min-h-[7.25rem] bg-elevated/60 ring-default focus-visible:ring-primary',
}" }"
@keydown="handleKeyDown" @keydown="handleKeyDown"
@input="() => void adjustTextareaHeight()"
/> />
<UInput <UInput
v-else v-else
@ -520,7 +519,7 @@ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void; (e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void;
(e: 'clear_form'): void; (e: 'clear_form'): void;
}>(); }>();
const config = useConfigStore(); const config = useYtpConfig();
const toast = useNotification(); const toast = useNotification();
const dialog = useDialog(); const dialog = useDialog();
const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions(); const { findPreset, hasPreset, selectItems, getPresetDefault } = usePresetOptions();
@ -662,9 +661,10 @@ const is_valid_dl_field = (dl_field: string): boolean => {
return false; return false;
}; };
const adjustTextareaHeight = async (): Promise<void> => { const getUrlElement = (): HTMLInputElement | HTMLTextAreaElement | null => {
await nextTick(); return (
urlTextarea.value?.textareaRef?.dispatchEvent(new Event('input', { bubbles: true })); urlTextarea.value?.textareaRef || (document.getElementById('url') as HTMLInputElement | null)
);
}; };
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => { const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
@ -690,10 +690,10 @@ const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
await nextTick(); await nextTick();
if (urlTextarea.value) { const field = getUrlElement();
await adjustTextareaHeight(); if (field instanceof HTMLTextAreaElement) {
urlTextarea.value.textareaRef?.setSelectionRange(cursorPos + 1, cursorPos + 1); field.setSelectionRange(cursorPos + 1, cursorPos + 1);
urlTextarea.value.textareaRef?.focus(); field.focus();
} }
} }
}; };
@ -714,11 +714,11 @@ const handlePaste = async (event: ClipboardEvent): Promise<void> => {
await nextTick(); await nextTick();
if (urlTextarea.value) { const field = getUrlElement();
await adjustTextareaHeight(); if (field instanceof HTMLTextAreaElement) {
const newPos = start + pastedText.length; const newPos = start + pastedText.length;
urlTextarea.value.textareaRef?.setSelectionRange(newPos, newPos); field.setSelectionRange(newPos, newPos);
urlTextarea.value.textareaRef?.focus(); field.focus();
} }
}; };
@ -944,10 +944,6 @@ onMounted(async () => {
if (!separators.some((s) => s.value === separator.value)) { if (!separators.some((s) => s.value === separator.value)) {
separator.value = separators[0]?.value ?? ','; separator.value = separators[0]?.value ?? ',';
} }
if (isMultiLineInput.value && urlTextarea.value) {
await adjustTextareaHeight();
}
}); });
const runCliCommand = async (): Promise<void> => { const runCliCommand = async (): Promise<void> => {
@ -1148,11 +1144,9 @@ const hasValidUrl = computed(() => form.value.url && form.value.url.trim().lengt
watch(isMultiLineInput, async (newValue) => { watch(isMultiLineInput, async (newValue) => {
await nextTick(); await nextTick();
if (newValue) { if (newValue) {
await adjustTextareaHeight();
urlTextarea.value?.textareaRef?.focus(); urlTextarea.value?.textareaRef?.focus();
return; return;
} }
const inputElement = document.getElementById('url') as HTMLInputElement; getUrlElement()?.focus();
inputElement?.focus();
}); });
</script> </script>

View file

@ -40,7 +40,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
size="lg" size="lg"
class="justify-center sm:min-w-28" class="justify-center sm:min-w-28"
@ -382,7 +383,7 @@
<div class="flex items-end"> <div class="flex items-end">
<UButton <UButton
type="button" type="button"
color="error" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
:disabled="addInProgress" :disabled="addInProgress"
@ -442,6 +443,7 @@ import type { notification, notificationRequestHeaderItem } from '~/types/notifi
const emitter = defineEmits<{ const emitter = defineEmits<{
(event: 'cancel'): void; (event: 'cancel'): void;
(event: 'dirty-change', dirty: boolean): void;
(event: 'submit', payload: { reference: number | undefined; item: notification }): 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 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( watch(
() => props.item, () => props.item,
(value) => { (value) => {
Object.assign(form, normalizeNotification(value)); Object.assign(form, normalizeNotification(value));
importString.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
}, },
{ deep: true }, { deep: true },
); );
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
function createDefaultNotification(): notification { function createDefaultNotification(): notification {
return { return {
name: '', name: '',
@ -633,4 +651,9 @@ const importItem = async (): Promise<void> => {
toast.error(`Failed to import task. ${error.message}`); toast.error(`Failed to import task. ${error.message}`);
} }
}; };
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script> </script>

View file

@ -138,9 +138,10 @@
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment'; import moment from 'moment';
import { useNotificationCenter } from '~/composables/useNotificationCenter';
import type { notificationType } from '~/composables/useNotification'; import type { notificationType } from '~/composables/useNotification';
const store = useNotificationStore(); const store = useNotificationCenter();
const copiedId = ref<string | null>(null); const copiedId = ref<string | null>(null);
const expandedId = ref<string | null>(null); const expandedId = ref<string | null>(null);

View file

@ -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>

View file

@ -71,7 +71,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
size="lg" size="lg"
:disabled="!importString" :disabled="!importString"
@ -358,6 +359,7 @@ import { normalizePresetName } from '~/utils';
const emitter = defineEmits<{ const emitter = defineEmits<{
(event: 'cancel'): void; (event: 'cancel'): void;
(event: 'dirty-change', dirty: boolean): void;
(event: 'submit', payload: { reference: number | null; preset: Preset }): void; (event: 'submit', payload: { reference: number | null; preset: Preset }): void;
}>(); }>();
@ -368,7 +370,7 @@ const props = defineProps<{
presets?: Preset[]; presets?: Preset[];
}>(); }>();
const config = useConfigStore(); const config = useYtpConfig();
const toast = useNotification(); const toast = useNotification();
const dialog = useDialog(); const dialog = useDialog();
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets); const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets);
@ -419,6 +421,15 @@ const textareaUi = {
const importPresetItems = computed(() => selectItems.value); 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( watch(
() => props.preset, () => props.preset,
(value) => { (value) => {
@ -433,10 +444,19 @@ watch(
priority: 0, priority: 0,
...JSON.parse(JSON.stringify(value || {})), ...JSON.parse(JSON.stringify(value || {})),
}); });
importString.value = '';
selectedPreset.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
}, },
{ deep: true }, { deep: true },
); );
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch( watch(
() => config.ytdlp_options, () => config.ytdlp_options,
(newOptions) => (newOptions) =>
@ -624,4 +644,9 @@ const importExistingPreset = async (): Promise<void> => {
await nextTick(); await nextTick();
selectedPreset.value = ''; selectedPreset.value = '';
}; };
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
:side="direction" :side="direction"
:dismissible="true" :dismissible="true"
:overlay="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')" @update:open="(open) => !open && emitter('close')"
> >
<template #header> <template #header>
@ -275,7 +275,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue'; import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useConfigStore } from '~/stores/ConfigStore';
import { useNotification } from '~/composables/useNotification'; import { useNotification } from '~/composables/useNotification';
import type { notificationTarget, toastPosition } 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 emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
const config = useConfigStore(); const config = useYtpConfig();
const notification = useNotification(); const notification = useNotification();
const bg_enable = useStorage<boolean>('random_bg', true); const bg_enable = useStorage<boolean>('random_bg', true);

File diff suppressed because it is too large Load diff

View file

@ -1,83 +1,17 @@
<template> <template>
<div class="space-y-6"> <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">
<div class="flex flex-wrap items-center gap-2"> <UButton
<UButton type="button"
type="button" color="neutral"
color="neutral" variant="ghost"
variant="ghost" size="sm"
size="sm" :icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" :disabled="isBusy"
:disabled="isBusy" @click="showImport = !showImport"
@click="showImport = !showImport" >
> {{ showImport ? 'Hide import' : 'Show import' }}
{{ showImport ? 'Hide import' : 'Show import' }} </UButton>
</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> </div>
<div <div
@ -132,7 +66,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
class="justify-center sm:min-w-28" class="justify-center sm:min-w-28"
:disabled="isBusy || !importString.trim()" :disabled="isBusy || !importString.trim()"
@ -219,7 +154,7 @@
<UFormField <UFormField
class="md:col-span-3" class="md:col-span-3"
:ui="fieldUi" :ui="fieldUi"
description="Disabled definitions won't match tasks." description="Whether this definition is active or not."
> >
<template #label> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@ -364,7 +299,7 @@
</div> </div>
<div class="grid gap-4 md:grid-cols-12"> <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> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-shapes" class="size-4 text-toned" /> <UIcon name="i-lucide-shapes" class="size-4 text-toned" />
@ -383,7 +318,7 @@
/> />
</UFormField> </UFormField>
<UFormField class="md:col-span-8" :ui="fieldUi"> <UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
<template #label> <template #label>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" /> <UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
@ -415,7 +350,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
size="sm" size="sm"
icon="i-lucide-plus" icon="i-lucide-plus"
:disabled="isBusy" :disabled="isBusy"
@ -431,7 +367,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 table-fixed w-full text-sm"> <table class="min-w-215 table-fixed w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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"> <th class="w-40">
<span class="inline-flex items-center gap-1.5"> <span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-key" class="size-3.5 text-toned" /> <UIcon name="i-lucide-key" class="size-3.5 text-toned" />
@ -473,7 +411,7 @@
<tr <tr
v-for="(field, index) in guiState.fields" v-for="(field, index) in guiState.fields"
:key="`${index}-${field.key}`" :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"> <td class="px-3 py-3">
<UInput <UInput
@ -517,7 +455,7 @@
<td class="px-3 py-3 text-right"> <td class="px-3 py-3 text-right">
<UButton <UButton
type="button" type="button"
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -571,6 +509,77 @@
:description="errorMessage" :description="errorMessage"
/> />
</template> </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> </div>
</template> </template>
@ -615,6 +624,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'submit', payload: TaskDefinitionDocument): void; (e: 'submit', payload: TaskDefinitionDocument): void;
(e: 'cancel'): void; (e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'import-existing', id: number): 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 => { const resetGuiState = (state: GuiState): void => {
guiState.name = state.name; guiState.name = state.name;
guiState.priority = state.priority; guiState.priority = state.priority;
@ -1027,6 +1047,10 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
containerSelector: '', containerSelector: '',
fields: [defaultField()], fields: [defaultField()],
}); });
nextTick(() => {
markClean();
emit('dirty-change', false);
});
return; return;
} }
@ -1050,6 +1074,11 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
mode.value = 'advanced'; mode.value = 'advanced';
errorMessage.value = 'Failed to prepare definition for editing.'; errorMessage.value = 'Failed to prepare definition for editing.';
} }
nextTick(() => {
markClean();
emit('dirty-change', false);
});
}; };
const importFromString = (): void => { const importFromString = (): void => {
@ -1088,6 +1117,8 @@ watch(
{ immediate: true }, { immediate: true },
); );
watch(isDirty, (value: boolean) => emit('dirty-change', value));
const switchMode = (next: EditorMode): void => { const switchMode = (next: EditorMode): void => {
if (isBusy.value || next === mode.value) { if (isBusy.value || next === mode.value) {
return; return;

View file

@ -17,7 +17,7 @@
<UButton <UButton
type="button" type="button"
color="warning" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
:loading="convertInProgress" :loading="convertInProgress"
@ -94,7 +94,8 @@
<UButton <UButton
type="button" type="button"
color="primary" color="neutral"
variant="outline"
icon="i-lucide-import" icon="i-lucide-import"
size="lg" size="lg"
:disabled="!import_string" :disabled="!import_string"
@ -512,6 +513,7 @@ const props = defineProps<{
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void; (e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
( (
e: 'submit', e: 'submit',
payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean }, payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean },
@ -519,7 +521,7 @@ const emitter = defineEmits<{
}>(); }>();
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useYtpConfig();
const dialog = useDialog(); const dialog = useDialog();
const { findPreset, getPresetDefault, selectItems } = usePresetOptions(); const { findPreset, getPresetDefault, selectItems } = usePresetOptions();
const showImport = useStorage('showTaskImport', false); 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 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 = { const fieldUi = {
label: 'font-semibold text-default', label: 'font-semibold text-default',
container: 'space-y-2', container: 'space-y-2',
@ -610,6 +621,13 @@ watch(
if (!value?.preset) { if (!value?.preset) {
form.preset = toRaw(config.app.default_preset); form.preset = toRaw(config.app.default_preset);
} }
import_string.value = '';
archiveAllAfterAdd.value = false;
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
}, },
{ immediate: true, deep: true }, { 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 handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement; const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const isTextarea = target.tagName === 'TEXTAREA'; const isTextarea = target.tagName === 'TEXTAREA';
@ -930,4 +950,9 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
return getPresetDefault(form.preset, type, ret); return getPresetDefault(form.preset, type, ret);
}; };
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script> </script>

View file

@ -66,7 +66,7 @@
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> <div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton <UButton
type="button" type="button"
color="warning" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-rotate-ccw" icon="i-lucide-rotate-ccw"
:disabled="loading" :disabled="loading"
@ -127,7 +127,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { request } from '~/utils'; import { request } from '~/utils';
import { useConfigStore } from '~/stores/ConfigStore';
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect'; import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
const props = defineProps<{ const props = defineProps<{
@ -138,7 +137,7 @@ const props = defineProps<{
const { selectItems } = usePresetOptions(); const { selectItems } = usePresetOptions();
const config = useConfigStore(); const config = useYtpConfig();
const url = ref(props.url ?? ''); const url = ref(props.url ?? '');
const preset = ref(props.preset || config.app.default_preset || ''); const preset = ref(props.preset || config.app.default_preset || '');
const handler = ref(props.handler ?? ''); const handler = ref(props.handler ?? '');

View file

@ -291,7 +291,7 @@ import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
import type { file_info, video_source_element, video_track_element } from '~/types/video'; 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 props = defineProps<{ item: StoreItem }>();
const emitter = defineEmits<{ const emitter = defineEmits<{

View file

@ -121,7 +121,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-180 w-full table-auto text-sm"> <table class="min-w-180 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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-80 whitespace-nowrap">Flags</th>
<th>Description</th> <th>Description</th>
</tr> </tr>
@ -130,7 +132,7 @@
<tr <tr
v-for="opt in group.items" v-for="opt in group.items"
:key="opt.flags.join('|')" :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"> <td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
@ -185,7 +187,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain"> <div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 w-full table-auto text-sm"> <table class="min-w-215 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned"> <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-80 whitespace-nowrap">Flags</th>
<th class="w-36 whitespace-nowrap">Group</th> <th class="w-36 whitespace-nowrap">Group</th>
<th>Description</th> <th>Description</th>
@ -195,7 +199,7 @@
<tr <tr
v-for="opt in visible" v-for="opt in visible"
:key="opt.flags.join('|')" :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"> <td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">

View 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;

View file

@ -1,7 +1,6 @@
import { ref, readonly, computed, toRaw } from 'vue'; import { ref, readonly, computed, toRaw } from 'vue';
import { useNotification } from '~/composables/useNotification'; import { useNotification } from '~/composables/useNotification';
import { useConfigStore } from '~/stores/ConfigStore';
import { request, parse_api_error, sTrim, encodePath } from '~/utils'; import { request, parse_api_error, sTrim, encodePath } from '~/utils';
import type { FileItem, Pagination } from '~/types/filebrowser'; import type { FileItem, Pagination } from '~/types/filebrowser';
@ -50,7 +49,7 @@ const handleError = (error: unknown): void => {
}; };
const buildQueryParams = (page?: number): string => { const buildQueryParams = (page?: number): string => {
const config = useConfigStore(); const config = useYtpConfig();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('page', String(page ?? pagination.value.page)); params.set('page', String(page ?? pagination.value.page));
params.set('per_page', String(config.app.default_pagination || 50)); params.set('per_page', String(config.app.default_pagination || 50));

View file

@ -52,10 +52,6 @@ export type ConfirmOptions = BaseOptions & {
* Text for the confirm button * Text for the confirm button
*/ */
cancelText?: string; cancelText?: string;
/**
* Raw HTML content to include in the dialog message.
*/
rawHTML?: string;
/** /**
* Optional checkbox-style options to return with the confirmation result. * Optional checkbox-style options to return with the confirmation result.
*/ */

View 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,
};
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View file

@ -1,4 +1,5 @@
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useNotificationCenter } from '~/composables/useNotificationCenter';
import { getNuxtToastManager } from '~/utils/nuxtToastManager'; import { getNuxtToastManager } from '~/utils/nuxtToastManager';
export type notificationType = 'info' | 'success' | 'warning' | 'error'; export type notificationType = 'info' | 'success' | 'warning' | 'error';
@ -70,7 +71,7 @@ const sendMessage = (
message: string, message: string,
opts?: notificationOptions, opts?: notificationOptions,
): void => { ): void => {
const notificationStore = useNotificationStore(); const notificationStore = useNotificationCenter();
const useToastNotification = const useToastNotification =
!window.isSecureContext || !window.isSecureContext ||
@ -115,7 +116,7 @@ const sendMessage = (
}; };
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => { const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
const notificationStore = useNotificationStore(); const notificationStore = useNotificationCenter();
if (!opts) { if (!opts) {
opts = {}; opts = {};

View 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;

View file

@ -1,6 +1,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { Preset } from '~/types/presets'; import type { Preset } from '~/types/presets';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import { cleanObject, prettyName } from '~/utils'; import { cleanObject, prettyName } from '~/utils';
type EditablePreset = Partial<Preset> & { type EditablePreset = Partial<Preset> & {
@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
export const usePresetEditor = () => { export const usePresetEditor = () => {
const presetsStore = usePresets(); const presetsStore = usePresets();
const dialog = useDialog();
const isOpen = ref(false); const isOpen = ref(false);
const reference = ref<number | null>(null); const reference = ref<number | null>(null);
const preset = ref<Partial<Preset>>(makeEmptyPreset()); const preset = ref<Partial<Preset>>(makeEmptyPreset());
const initialSnapshot = ref(''); const dirty = ref(false);
const sessionId = ref(0); 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(() => { const modalTitle = computed(() => {
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add'; 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 modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
const reset = (): void => { const reset = (): void => {
reference.value = null; discardEditor();
preset.value = makeEmptyPreset();
initialSnapshot.value = '';
}; };
const close = (): void => { const close = (): void => {
dirty.value = false;
isOpen.value = false; isOpen.value = false;
reset(); 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 => { const openCreate = (): void => {
reset(); reset();
dirty.value = false;
sessionId.value += 1; sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true; isOpen.value = true;
}; };
const openEdit = (item: Preset | EditablePreset): void => { const openEdit = (item: Preset | EditablePreset): void => {
dirty.value = false;
reference.value = item.id ?? null; reference.value = item.id ?? null;
preset.value = sanitizePreset(item); preset.value = sanitizePreset(item);
sessionId.value += 1; sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true; isOpen.value = true;
}; };
@ -94,22 +100,7 @@ export const usePresetEditor = () => {
return; return;
} }
if (!isDirty.value) { await requestCloseGuard();
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();
}
}; };
const submit = async ({ const submit = async ({
@ -144,10 +135,12 @@ export const usePresetEditor = () => {
modalTitle, modalTitle,
modalDescription, modalDescription,
isDirty, isDirty,
dirty,
addInProgress: presetsStore.addInProgress, addInProgress: presetsStore.addInProgress,
openCreate, openCreate,
openEdit, openEdit,
close, close,
handleOpenChange,
requestClose, requestClose,
reset, reset,
submit, submit,

View file

@ -1,5 +1,6 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue'; import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useYtpConfig } from '~/composables/useYtpConfig';
import type { Preset } from '~/types/presets'; import type { Preset } from '~/types/presets';
import { prettyName } from '~/utils'; import { prettyName } from '~/utils';
@ -19,7 +20,7 @@ export const usePresetOptions = (
source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>, source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>,
options: UsePresetOptionsOptions = {}, options: UsePresetOptionsOptions = {},
) => { ) => {
const config = useConfigStore(); const config = useYtpConfig();
const presets = computed<Preset[]>(() => { const presets = computed<Preset[]>(() => {
const items = source ? toValue(source) : config.presets; const items = source ? toValue(source) : config.presets;

View 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;

View 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;

View file

@ -1,7 +1,11 @@
<template> <template>
<UApp :toaster="toasterConfig"> <UApp :toaster="toasterConfig">
<Transition name="shell-mode" mode="out-in"> <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 <UAlert
v-if="showConnectionBanner" v-if="showConnectionBanner"
color="warning" color="warning"
@ -25,14 +29,14 @@
variant="link" variant="link"
size="sm" size="sm"
class="px-0" class="px-0"
@click="() => socket.reconnect()" @click="socket.reconnect()"
> >
Reconnect Reconnect
</UButton> </UButton>
</template> </template>
</UAlert> </UAlert>
<Simple @show_settings="() => (show_settings = true)" /> <Simple @show_settings="show_settings = true" />
</div> </div>
<div v-else key="regular" class="shell-stage flex flex-col"> <div v-else key="regular" class="shell-stage flex flex-col">
@ -64,6 +68,7 @@
:ui="{ base: 'relative flex min-h-full overflow-visible' }" :ui="{ base: 'relative flex min-h-full overflow-visible' }"
> >
<UDashboardSidebar <UDashboardSidebar
v-model:open="showSidebar"
side="left" side="left"
collapsible collapsible
resizable resizable
@ -169,19 +174,24 @@
variant="ghost" variant="ghost"
size="sm" size="sm"
icon="i-lucide-refresh-cw" icon="i-lucide-refresh-cw"
@click="reloadPage" @click="$router.go(0)"
> >
<span class="hidden xl:inline">Reload</span> <span class="hidden xl:inline">Reload</span>
</UButton> </UButton>
<UDashboardSearchButton class="shrink-0" /> <UDashboardSearchButton class="shrink-0" />
<UColorModeButton <UButton
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="sm" 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 <UButton
color="neutral" color="neutral"
@ -248,7 +258,7 @@
<button <button
type="button" type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline" class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage" @click="$router.go(0)"
> >
click here click here
</button> </button>
@ -261,7 +271,7 @@
<button <button
type="button" type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline" class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="() => config.loadConfig(true)" @click="config.loadConfig(true)"
> >
reload configuration reload configuration
</button> </button>
@ -269,7 +279,7 @@
<button <button
type="button" type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline" class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage" @click="$router.go(0)"
> >
reload the page reload the page
</button> </button>
@ -318,7 +328,7 @@
variant="link" variant="link"
size="sm" size="sm"
class="px-0" class="px-0"
@click="() => socket.reconnect()" @click="socket.reconnect()"
> >
Reconnect Reconnect
</UButton> </UButton>
@ -329,7 +339,7 @@
v-if="config.is_loaded" v-if="config.is_loaded"
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col" 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>
</div> </div>
@ -473,8 +483,8 @@
<SettingsPanel <SettingsPanel
:isOpen="show_settings" :isOpen="show_settings"
:isLoading="loadingImage" :isLoading="loadingImage"
@close="closeSettings()" @close="show_settings = false"
@reload_bg="() => loadImage(true)" @reload_bg="loadImage(true)"
direction="right" direction="right"
/> />
</UApp> </UApp>
@ -482,26 +492,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'; 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 { useStorage } from '@vueuse/core';
import moment from 'moment'; import moment from 'moment';
import { useMediaQuery } from '~/composables/useMediaQuery';
import type { toastPosition } from '~/composables/useNotification'; import type { toastPosition } from '~/composables/useNotification';
import { getDocsNavigationEntries } from '~/composables/useDocs';
import type { YTDLPOption } from '~/types/ytdlp'; import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue'; import Dialog from '~/components/Dialog.vue';
import Simple from '~/components/Simple.vue'; import Simple from '~/components/Simple.vue';
import Shutdown from '~/components/shutdown.vue'; import Shutdown from '~/components/shutdown.vue';
import type { version_check } from '~/types'; import type { version_check } from '~/types';
import {
type NavEntry = { getActiveNavItem,
id: string; getNavItems,
label: string; getNavSections,
description?: string; isNavItemActive,
icon: string; type NavItem,
to?: string; } from '~/utils/topLevelNavigation';
children?: NavEntry[];
};
type SidebarSection = { type SidebarSection = {
id: string; id: string;
@ -509,9 +517,16 @@ type SidebarSection = {
items: Array<Array<NavigationMenuItem>>; items: Array<Array<NavigationMenuItem>>;
}; };
const socket = useSocketStore(); type ColorModePreference = 'system' | 'light' | 'dark';
const config = useConfigStore(); 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 route = useRoute();
const colorMode = useColorMode();
const loadedImage = ref(); const loadedImage = ref();
const loadingImage = ref(false); const loadingImage = ref(false);
const bg_enable = useStorage('random_bg', true); const bg_enable = useStorage('random_bg', true);
@ -522,7 +537,148 @@ const show_settings = ref(false);
const checkingUpdates = ref(false); const checkingUpdates = ref(false);
const updateCheckMessage = ref('Up to date - Click to check'); const updateCheckMessage = ref('Up to date - Click to check');
const showRouteSearch = ref(false); const showRouteSearch = ref(false);
const showSidebar = ref(false);
const { alertDialog, confirmDialog } = useDialog(); 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 = { const dashboardSidebarUi = {
root: 'border-r border-default bg-default/95 backdrop-blur-sm', root: 'border-r border-default bg-default/95 backdrop-blur-sm',
@ -552,134 +708,51 @@ const navigationUi = (collapsed: boolean) => ({
linkLabel: collapsed ? 'hidden' : 'truncate', linkLabel: collapsed ? 'hidden' : 'truncate',
}); });
const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({ const makeNavigationItem = (item: NavItem): NavigationMenuItem => ({
label: item.label, label: item.label,
icon: item.icon, icon: item.icon,
to: item.to, to: item.to,
value: item.id, 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[]>(() => [ const navItems = computed(() => getNavItems(navigationAvailability.value));
{
id: 'downloads', const groupSectionEntries = (entries: Array<NavItem>): Array<Array<NavItem>> => {
label: 'Downloads', const order = [...new Set(entries.map((entry) => entry.group))];
description: 'Queued and completed downloads list.', return order.map((group) => entries.filter((entry) => entry.group === group));
icon: 'i-lucide-download', };
to: '/',
}, const sidebarItems = computed<
{ Array<{
id: 'files', id: string;
label: 'Files', label: string;
description: 'Browse downloaded files.', items: Array<Array<NavItem>>;
icon: 'i-lucide-folder-tree', }>
to: '/browser', >(() => {
}, return getNavSections()
{ .map((section) => {
id: 'presets', const sectionEntries = navItems.value.filter(
label: 'Presets', (entry) => entry.section === section.id && entry.sidebarVisible !== false,
description: );
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.',
icon: 'i-lucide-sliders-horizontal', return {
to: '/presets', id: section.id,
}, label: section.label,
{ items: groupSectionEntries(sectionEntries),
id: 'custom-fields', };
label: 'Custom Fields', })
description: 'Custom fields allow you to add new fields to the download form.', .filter((section) => section.items.some((group) => group.length > 0));
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 pageTitle = computed(() => { const pageTitle = computed(() => {
const path = route.path; const match = getActiveNavItem(route, navigationAvailability.value);
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]); return match?.navbarTitle || match?.label || 'YTPTube';
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 buildTooltip = computed( const buildTooltip = computed(
@ -703,81 +776,30 @@ const connectionBannerIcon = computed(() =>
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info', socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
); );
const sidebarSections = computed<Array<SidebarSection>>(() => { const sidebarSections = computed<Array<SidebarSection>>(() =>
const topLevelItems = (ids: string[]) => sidebarItems.value.map((section) => ({
ids ...section,
.map((id) => allNavItems.value.find((item) => item.id === id)) items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
.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 routeSearchGroups = computed(() => [ const routeSearchGroups = computed(() => [
{ ...sidebarItems.value
id: 'routes', .map((section) => ({
label: 'Routes', id: section.id,
items: allNavItems.value.flatMap((item) => { label: section.label,
const self = item.to items: section.items
? [ .flat()
{ .filter((entry) => entry.searchable !== false)
label: item.label, .map((entry) => ({
description: item.description, label: entry.label,
icon: item.icon, description: entry.description,
suffix: item.to, icon: entry.icon,
onSelect: () => handleRouteSelect(item), suffix: entry.to,
}, onSelect: () => handleRouteSelect(entry),
] })),
: []; }))
.filter((section) => section.items.length > 0),
const children = (item.children || []).map((child) => ({
label: child.label,
description: child.description,
icon: child.icon,
suffix: child.to || '',
onSelect: () => handleRouteSelect(child),
}));
return [...self, ...children];
}),
},
{ {
id: 'downloads', id: 'downloads',
label: 'Downloads', label: 'Downloads',
@ -840,7 +862,7 @@ const syncShellModeClass = () => {
html.classList.toggle('simple-mode', simpleMode.value); html.classList.toggle('simple-mode', simpleMode.value);
}; };
const handleRouteSelect = async (item: NavEntry) => { const handleRouteSelect = async (item: NavItem) => {
await closeRouteSearch(); await closeRouteSearch();
if (item.to) { if (item.to) {
@ -899,6 +921,22 @@ const checkForUpdates = async () => {
}; };
onMounted(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(); syncShellModeClass();
try { try {
@ -921,13 +959,29 @@ onMounted(async () => {
} catch {} } 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(bg_enable, async (v) => await handleImage(v));
watch(simpleMode, () => syncShellModeClass()); 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) { if (false === bg_enable.value) {
return; return;
} }
document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
syncOpacity();
}); });
watch(loadedImage, () => { watch(loadedImage, () => {
@ -936,7 +990,6 @@ watch(loadedImage, () => {
} }
const html = document.documentElement; const html = document.documentElement;
const body = document.querySelector('body');
const style = { const style = {
'background-color': 'unset', 'background-color': 'unset',
@ -954,7 +1007,7 @@ watch(loadedImage, () => {
.trim(), .trim(),
); );
html.classList.add('bg-fanart'); html.classList.add('bg-fanart');
body?.setAttribute('style', `opacity: ${bg_opacity.value}`); syncOpacity();
}); });
const handleImage = async (enabled: boolean) => { const handleImage = async (enabled: boolean) => {
@ -1025,8 +1078,6 @@ const useVersionUpdate = () => {
const { newVersionIsAvailable } = useVersionUpdate(); const { newVersionIsAvailable } = useVersionUpdate();
const closeSettings = () => (show_settings.value = false);
const shutdownApp = async () => { const shutdownApp = async () => {
if (false === config.app.is_native) { if (false === config.app.is_native) {
await alertDialog({ await alertDialog({
@ -1065,8 +1116,6 @@ const shutdownApp = async () => {
} }
}; };
const reloadPage = () => window.location.reload();
const connectionStatusColor = computed(() => { const connectionStatusColor = computed(() => {
switch (socket.connectionStatus) { switch (socket.connectionStatus) {
case 'connected': case 'connected':
@ -1111,6 +1160,8 @@ const toasterConfig = computed(() => ({
expand: true, expand: true,
progress: true, progress: true,
})); }));
const reloadPage = () => window.location.reload();
</script> </script>
<style> <style>

View file

@ -1,58 +1,50 @@
<template> <template>
<div class="w-full min-w-0 max-w-full space-y-4"> <div class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-2"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm text-toned"> <span
<nav aria-label="Breadcrumb" class="min-w-0"> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<ol class="flex flex-wrap items-center gap-1.5"> >
<template v-for="(item, index) in breadcrumbItems" :key="item.path"> <UIcon :name="pageShell.icon" class="size-5" />
<li v-if="index > 0" aria-hidden="true" class="text-muted">/</li> </span>
<li>
<a <div class="min-w-0 flex-1 space-y-3">
v-if="index !== breadcrumbItems.length - 1" <nav
:href="buildStateUrl(item.path)" aria-label="Breadcrumb"
class="rounded px-1 py-0.5 text-left text-default hover:text-highlighted" class="flex min-w-0 flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
@click="handleBreadcrumbClick($event, item.path)" >
> <span>{{ pageShell.sectionLabel }}</span>
{{ item.name }} <span>/</span>
</a> <span>{{ pageShell.pageLabel }}</span>
<span v-else class="rounded px-1 py-0.5 font-medium text-highlighted">
{{ item.name }} <template v-for="item in breadcrumbTrailItems" :key="item.path">
</span> <span>/</span>
</li>
</template> <button
</ol> 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> </nav>
<UIcon <div>
v-if="isLoading" <h1 class="truncate text-2xl font-semibold text-highlighted">
name="i-lucide-loader-circle" {{ currentDirectoryName }}
class="size-4 animate-spin text-info" </h1>
/> </div>
</div> </div>
<p class="text-sm text-toned">
{{ browserPath }}
</p>
</div> </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">
<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>
<UButton <UButton
color="neutral" color="neutral"
:variant="show_filter ? 'soft' : 'outline'" :variant="show_filter ? 'soft' : 'outline'"
@ -60,7 +52,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilter" @click="toggleFilter"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -71,7 +63,7 @@
icon="i-lucide-folder-plus" icon="i-lucide-folder-plus"
@click="() => void handleCreateDirectory()" @click="() => void handleCreateDirectory()"
> >
<span v-if="!isMobile">New Folder</span> <span>New Folder</span>
</UButton> </UButton>
<UButton <UButton
@ -79,9 +71,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @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>
<UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false"> <UDropdownMenu v-if="hasItems" :items="sortGroups" :modal="false">
@ -92,7 +85,7 @@
icon="i-lucide-arrow-up-down" icon="i-lucide-arrow-up-down"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
> >
<span v-if="!isMobile">Sort</span> <span>Sort</span>
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
@ -105,8 +98,20 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent(browserPath)" @click="() => void reloadContent(browserPath)"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
@ -119,9 +124,9 @@
color="neutral" color="neutral"
variant="outline" variant="outline"
size="sm" 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" :disabled="isLoading || filteredItems.length < 1"
@click="masterSelectAll = !masterSelectAll" @click="toggleMasterSelection"
> >
{{ masterSelectAll ? 'Unselect' : 'Select' }} {{ masterSelectAll ? 'Unselect' : 'Select' }}
</UButton> </UButton>
@ -130,60 +135,56 @@
{{ selectedElms.length }} {{ selectedElms.length }}
</UBadge> </UBadge>
<UButton <UDropdownMenu :items="bulkActionGroups" :modal="false">
color="info" <UButton
variant="outline" color="neutral"
size="sm" variant="outline"
icon="i-lucide-arrow-right-left" size="sm"
:disabled="!hasSelected || isLoading" icon="i-lucide-list"
@click="() => void handleMoveSelected()" trailing-icon="i-lucide-chevron-down"
> >
Move Actions
</UButton> </UButton>
</UDropdownMenu>
<UButton
color="error"
variant="outline"
size="sm"
icon="i-lucide-trash"
:disabled="!hasSelected || isLoading"
@click="() => void handleDeleteSelected()"
>
Delete
</UButton>
</div> </div>
<p class="text-sm text-toned"> <UPagination
Page {{ pagination.page }} of {{ pagination.total_pages || 1 }} v-if="pagination.total_pages > 1"
</p> :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> </div>
<Pager
v-if="pagination.total_pages > 1"
:page="pagination.page"
:last_page="pagination.total_pages"
:isLoading="isLoading"
@navigate="handlePageChange"
/>
<div <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" 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"> <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"> <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
<th v-if="controlEnabled" class="w-16">Select</th> 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="controlEnabled ? 'w-20' : 'w-24'"> >
# <th v-if="controlEnabled" class="w-16">
<UIcon <button
v-if="sort_by === 'type'" type="button"
:name="sortDirectionIcon" class="cursor-pointer"
class="ml-1 inline-flex size-3.5" :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>
<th class="text-left"> <th class="w-full text-left">
Name Name
<UIcon <UIcon
v-if="sort_by === 'name'" v-if="sort_by === 'name'"
@ -207,12 +208,16 @@
class="ml-1 inline-flex size-3.5" class="ml-1 inline-flex size-3.5"
/> />
</th> </th>
<th v-if="controlEnabled" class="w-80 whitespace-nowrap">Actions</th> <th v-if="controlEnabled" class="w-96 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td v-if="controlEnabled" class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -224,16 +229,14 @@
</label> </label>
</td> </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"> <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"> <div class="min-w-0 flex-1">
<UTooltip :text="item.name"> <UTooltip :text="item.name">
<a <a
@ -245,19 +248,6 @@
</a> </a>
</UTooltip> </UTooltip>
</div> </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> </div>
</td> </td>
@ -271,10 +261,23 @@
</UTooltip> </UTooltip>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <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" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@ -284,7 +287,7 @@
</UButton> </UButton>
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-arrow-right-left" icon="i-lucide-arrow-right-left"
@ -294,7 +297,7 @@
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@ -315,7 +318,11 @@
v-for="item in filteredItems" v-for="item in filteredItems"
:key="item.path" :key="item.path"
class="flex h-full flex-col border bg-default" 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> <template #header>
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@ -340,18 +347,6 @@
</div> </div>
<div class="flex shrink-0 items-center gap-1"> <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"> <label v-if="controlEnabled" class="inline-flex cursor-pointer items-center px-1">
<input <input
v-model="selectedElms" v-model="selectedElms"
@ -386,40 +381,56 @@
</div> </div>
</div> </div>
<div v-if="controlEnabled" class="mt-auto flex flex-wrap gap-2 pt-1 *:min-w-32 *:flex-1"> <template v-if="controlEnabled" #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="warning" <UButton
variant="outline" v-if="item.type === 'file'"
size="sm" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" size="xs"
@click="() => void handleAction('rename', item)" icon="i-lucide-download"
> external
Rename :href="downloadHref(item)"
</UButton> :download="downloadName(item)"
class="w-full justify-center"
>
Download
</UButton>
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-arrow-right-left" icon="i-lucide-pencil"
class="w-full justify-center" class="w-full justify-center"
@click="() => void handleAction('move', item)" @click="() => void handleAction('rename', item)"
> >
Move Rename
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="sm" size="sm"
icon="i-lucide-trash" icon="i-lucide-arrow-right-left"
class="w-full justify-center" class="w-full justify-center"
@click="() => void handleAction('delete', item)" @click="() => void handleAction('move', item)"
> >
Delete Move
</UButton> </UButton>
</div>
<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> </UCard>
</div> </div>
@ -431,10 +442,6 @@
title="No results" title="No results"
:description="`No results found for '${localSearch}'.`" :description="`No results found for '${localSearch}'.`"
/> />
<UButton color="neutral" variant="outline" size="sm" @click="clearFilter"
>Clear filter</UButton
>
</div> </div>
<UAlert <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." description="You can enable rename, delete, move, and create directory controls by setting YTP_BROWSER_CONTROL_ENABLED=true and restarting the application."
/> />
<Pager <div v-if="pagination.total_pages > 1" class="flex justify-end">
v-if="pagination.total_pages > 1" <UPagination
:page="pagination.page" :page="pagination.page"
:last_page="pagination.total_pages" :total="pagination.total"
:isLoading="isLoading" :items-per-page="pagination.per_page"
@navigate="handlePageChange" :disabled="isLoading"
/> show-edges
:sibling-count="0"
@update:page="handlePageChange"
size="sm"
/>
</div>
<UModal <UModal
v-if="model_item" v-if="model_item"
@ -515,18 +527,19 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { DropdownMenuItem } from '@nuxt/ui'; import type { DropdownMenuItem } from '@nuxt/ui';
import type { FileItem } from '~/types/filebrowser'; import type { FileItem } from '~/types/filebrowser';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute(); const route = useRoute();
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useYtpConfig();
const dialog = useDialog(); const dialog = useDialog();
const browser = useBrowser(); const browser = useBrowser();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const display_style = useStorage<string>('browser_display_style', 'list'); const display_style = useStorage<string>('browser_display_style', 'list');
const isMobile = useMediaQuery({ maxWidth: 639 });
const show_filter = ref(false); const show_filter = ref(false);
const localSearch = ref(''); const localSearch = ref('');
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const items = browser.items; const items = browser.items;
const browserPath = browser.path; const browserPath = browser.path;
@ -539,9 +552,17 @@ const sort_order = browser.sort_order;
const filteredItems = browser.filteredItems; const filteredItems = browser.filteredItems;
const controlEnabled = computed(() => Boolean(config.app.browser_control_enabled)); 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 hasItems = computed(() => filteredItems.value.length > 0);
const hasSelected = computed(() => selectedElms.value.length > 0); const hasSelected = computed(() => selectedElms.value.length > 0);
const displayedItemPaths = computed(() => filteredItems.value.map((item) => item.path)); 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(() => const sortDirectionIcon = computed(() =>
sort_order.value === 'asc' ? 'i-lucide-arrow-down' : 'i-lucide-arrow-up', 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 initialPath = (() => {
const slug = route.params.slug; const slug = route.params.slug;
if (Array.isArray(slug) && slug.length > 0) { if (Array.isArray(slug) && slug.length > 0) {
@ -696,19 +736,6 @@ const clearFilter = (): void => {
show_filter.value = false; 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 => { const itemHref = (item: FileItem): string => {
return item.content_type === 'dir' ? uri(`/browser/${item.path}`) : downloadHref(item); 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); return item.type === 'file' ? 'File' : ucFirst(item.type);
}; };
const escapeHtml = (value: string): string => {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
};
const toggleFilter = async (): Promise<void> => { const toggleFilter = async (): Promise<void> => {
show_filter.value = !show_filter.value; show_filter.value = !show_filter.value;
@ -910,13 +928,17 @@ const toggleFilter = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
searchInput.value?.focus(); searchInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const toggleDisplayStyle = (): void => { const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; display_style.value = display_style.value === 'list' ? 'grid' : 'list';
}; };
const toggleMasterSelection = (): void => {
masterSelectAll.value = !masterSelectAll.value;
};
const handleCreateDirectory = async (): Promise<void> => { const handleCreateDirectory = async (): Promise<void> => {
if (!controlEnabled.value) { if (!controlEnabled.value) {
return; return;
@ -1014,8 +1036,9 @@ const handleDeleteSelected = async (): Promise<void> => {
return; return;
} }
const rawHTML = const message =
'<ul>' + 'Delete the following items?' +
'\n\n' +
selectedElms.value selectedElms.value
.map((selectedPath) => { .map((selectedPath) => {
const item = items.value.find((entry) => entry.path === selectedPath); const item = items.value.find((entry) => entry.path === selectedPath);
@ -1023,15 +1046,14 @@ const handleDeleteSelected = async (): Promise<void> => {
return ''; return '';
} }
return `<li><strong>${escapeHtml(itemTypeLabel(item))}:</strong> ${escapeHtml(item.name)}</li>`; return `${itemTypeLabel(item)}: ${item.name}`;
}) })
.join('') + .filter(Boolean)
'</ul>'; .join('\n');
const { status } = await dialog.confirmDialog({ const { status } = await dialog.confirmDialog({
title: 'Delete Confirmation', title: 'Delete Confirmation',
message: 'Delete the following items?', message,
rawHTML,
confirmText: 'Delete', confirmText: 'Delete',
cancelText: 'Cancel', cancelText: 'Cancel',
confirmColor: 'error', confirmColor: 'error',

View file

@ -1,35 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>CHANGELOG</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Latest project changes, loaded remotely when available and falling back to the bundled <div
changelog file. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<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>
<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">
<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>
<UButton <UButton
v-if="logs.length > 0" v-if="logs.length > 0"
color="neutral" color="neutral"
@ -38,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilter = !toggleFilter" @click="toggleFilter = !toggleFilter"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<USwitch <USwitch
@ -48,6 +40,17 @@
:label="latestOnly ? 'Latest Only' : 'All Loaded'" :label="latestOnly ? 'Latest Only' : 'All Loaded'"
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }" :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>
</div> </div>
@ -79,17 +82,25 @@
Installed Installed
</UBadge> </UBadge>
</div> </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> </div>
<UBadge color="neutral" variant="soft" size="sm"> <div class="flex flex-wrap items-center justify-end gap-2 text-xs text-toned">
{{ log.commits?.length || 0 }} commits <span
</UBadge> 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>
<div class="space-y-3 border-t border-default pt-4"> <div class="space-y-3 border-t border-default pt-4">
@ -98,35 +109,48 @@
:key="commit.sha" :key="commit.sha"
class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3" 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"> <div class="min-w-0">
<p class="min-w-0 flex-1 text-sm text-default"> <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"> <span class="font-semibold text-highlighted">
{{ ucFirst(commit.message).replace(/\.$/, '') }}. {{ ucFirst(commit.message).replace(/\.$/, '') }}.
</span> </span>
</p> </NuxtLink>
<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>
</div> </div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-toned"> <div class="flex flex-wrap items-center gap-2 text-xs text-toned">
<span>{{ commit.author }}</span> <span
<UTooltip :text="`SHA: ${commit.full_sha} - Date: ${commit.date}`"> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<span>{{ moment(commit.date).fromNow() }}</span> >
<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>
<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> </div>
</article> </article>
</div> </div>
@ -145,10 +169,6 @@
:description="`No changelog entries found for the query: ${query}.`" :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 <UAlert
v-else v-else
color="warning" color="warning"
@ -167,10 +187,11 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { changelogs, changeset } from '~/types/changelogs'; import type { changelogs, changeset } from '~/types/changelogs';
import { request, ucFirst, uri } from '~/utils'; import { request, ucFirst, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useYtpConfig();
const isMobile = useMediaQuery({ maxWidth: 1024 }); const pageShell = requirePageShell('changelog');
useHead({ title: 'CHANGELOG' }); useHead({ title: 'CHANGELOG' });

View file

@ -1,34 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-filter" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Conditions</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Run yt-dlp custom match filter on returned info. and apply options. <div
</p> 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>
<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">
<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>
<UButton <UButton
v-if="items.length > 0" v-if="items.length > 0"
color="neutral" color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Condition</span> <span>New Condition</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +49,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,111 +65,168 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
<Pager <div
v-if="paging?.total_pages > 1" v-if="!isLoading && filteredItems.length > 0"
:page="paging.page" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
:last_page="paging.total_pages" >
:isLoading="isLoading" <div class="flex flex-wrap items-center gap-2">
@navigate="navigatePage" <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 <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" 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"> <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"> <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-full text-left">Condition</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-2"> <div class="space-y-2">
<div class="font-semibold text-highlighted">{{ cond.name }}</div> <div class="font-semibold text-highlighted">{{ cond.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<div class="inline-flex items-center gap-2"> <button
<UTooltip type="button"
:text="`Click to ${cond.enabled !== false ? 'disable' : 'enable'} condition`" 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"
<USwitch @click="() => void toggleEnabled(cond)"
:model-value="cond.enabled !== false" >
:disabled="conditions.addInProgress.value" <UIcon
@update:model-value="() => void toggleEnabled(cond)" name="i-lucide-power"
/> class="size-3.5"
</UTooltip> :class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <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" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span> <span>Priority: {{ cond.priority }}</span>
</span> </span>
</div> </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> </div>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(cond)" @click="exportItem(cond)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(cond)" @click="editItem(cond)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(cond)" @click="() => void deleteItem(cond)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -186,135 +237,178 @@
</div> </div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
v-for="cond in filteredItems" <UCard
:key="cond.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
class="flex h-full flex-col border bg-default" :ui="{
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" header: 'p-4 pb-3',
> body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
<template #header> footer: 'border-t border-default px-4 py-4',
<div class="flex items-start justify-between gap-3"> }"
<div class="min-w-0 flex-1 space-y-2"> >
<button <template #header>
type="button" <div class="flex min-w-0 items-start justify-between gap-3">
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted" <div class="min-w-0 flex-1">
@click="toggleExpand(cond.id, 'name')" <div class="flex items-start gap-2">
> <button
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span> type="button"
</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"> <div class="flex shrink-0 items-center gap-2">
<button <UButton
type="button" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default" variant="ghost"
:disabled="conditions.addInProgress.value" size="xs"
@click="() => void toggleEnabled(cond)" icon="i-lucide-file-up"
square
@click="exportItem(cond)"
> >
<UIcon <span>Export Condition</span>
name="i-lucide-power" </UButton>
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'" <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> </label>
</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> </div>
</div> </div>
</template>
<UButton <div class="space-y-2 text-sm text-default">
color="info" <div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
variant="ghost" <button
size="xs" type="button"
icon="i-lucide-file-up" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
square :disabled="conditions.addInProgress.value"
@click="exportItem(cond)" @click="() => void toggleEnabled(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"
> >
<span class="font-semibold">{{ key }}</span <UIcon
>: {{ value }} name="i-lucide-power"
</UBadge> 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>
</div> </div>
<button <template #footer>
v-if="cond.description" <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
type="button" <UButton
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left" color="neutral"
@click="toggleExpand(cond.id, 'description')" variant="outline"
> icon="i-lucide-pencil"
<UIcon name="i-lucide-message-square-text" class="mt-0.5 size-4 shrink-0 text-toned" /> class="w-full justify-center"
<span :class="expandClass(cond.id, 'description')">{{ cond.description }}</span> @click="editItem(cond)"
</button> >
</div> Edit
</UButton>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <UButton
<UButton color="neutral"
color="warning" variant="outline"
variant="outline" icon="i-lucide-trash"
icon="i-lucide-pencil" class="w-full justify-center"
class="w-full justify-center" @click="() => void deleteItem(cond)"
@click="editItem(cond)" >
> Delete
Edit </UButton>
</UButton> </div>
</template>
<UButton </UCard>
color="error" </div>
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(cond)"
>
Delete
</UButton>
</div>
</UCard>
</div> </div>
<UAlert <UAlert
@ -334,10 +428,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -349,6 +439,19 @@
description="There are no custom defined conditions yet. Click the New Condition button to add your first condition." 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 <div
v-if="filteredItems.length > 0 && !query" v-if="filteredItems.length > 0 && !query"
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default" class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
@ -381,11 +484,11 @@
<UModal <UModal
v-if="editorOpen" v-if="editorOpen"
:open="editorOpen" :open="editorOpen"
:title="modalTitle" :title="itemRef ? `Edit - ${item.name || 'Condition'}` : 'Add new condition'"
:description="modalDescription" description="Run yt-dlp custom match filter on returned info. and apply options."
:dismissible="!conditions.addInProgress.value" :dismissible="!conditions.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }" :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> <template #body>
<ConditionForm <ConditionForm
@ -393,7 +496,8 @@
:addInProgress="conditions.addInProgress.value" :addInProgress="conditions.addInProgress.value"
:reference="itemRef" :reference="itemRef"
:item="item as Condition" :item="item as Condition"
@cancel="closeEditor()" @cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem" @submit="updateItem"
/> />
</template> </template>
@ -402,21 +506,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useConditions } from '~/composables/useConditions'; import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions'; import type { Condition } from '~/types/conditions';
import type { APIResponse } from '~/types/responses'; import type { APIResponse } from '~/types/responses';
import { cleanObject, copyText, encode } from '~/utils'; import { cleanObject, copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type ConditionItemWithUI = Condition & { raw?: boolean }; type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm(); const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 }); const pageShell = requirePageShell('conditions');
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid'); const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const { toggleExpand, expandClass } = useExpandableMeta();
const conditions = useConditions(); const conditions = useConditions();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { confirmDialog } = useDialog();
const items = conditions.conditions as Ref<ConditionItemWithUI[]>; const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
const paging = conditions.pagination; 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 item = ref<Partial<Condition>>({});
const itemRef = ref<number | null | undefined>(null); const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false); const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref(''); const query = ref('');
const showFilter = ref(false); 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 removeKeys = ['raw', 'toggle_description'];
const expandedItems = reactive<Record<number, Set<string>>>({});
const filteredItems = computed<ConditionItemWithUI[]>(() => { const filteredItems = computed<ConditionItemWithUI[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
@ -441,22 +554,69 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet())); return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
}); });
const modalTitle = computed(() => const selectableConditionIds = computed(() =>
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition', 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( const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`, () => `${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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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 syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = conditions.pagination.value.total_pages; const totalPages = conditions.pagination.value.total_pages;
const nextQuery = { ...route.query }; const nextQuery = { ...route.query };
@ -478,7 +638,7 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const loadContent = async (pageNumber = 1): Promise<void> => { const loadContent = async (pageNumber = 1): Promise<void> => {
@ -499,6 +659,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => { const resetEditor = (): void => {
item.value = {}; item.value = {};
itemRef.value = null; itemRef.value = null;
editorDirty.value = false;
}; };
const closeEditor = (): void => { const closeEditor = (): void => {
@ -515,32 +676,13 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list'; displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
}; };
const toggleExpand = (itemId: number | undefined, field: string): void => { const toggleMasterSelection = (): void => {
if (!itemId) { if (allSelected.value) {
selectedIds.value = [];
return; return;
} }
if (!expandedItems[itemId]) { selectedIds.value = [...selectableConditionIds.value];
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';
}; };
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => { 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!); 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 ({ const updateItem = async ({
reference, reference,
item: updatedItem, item: updatedItem,
@ -581,6 +768,7 @@ const updateItem = async ({
}; };
const editItem = (value: Condition): void => { const editItem = (value: Condition): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(value)) as Condition; item.value = JSON.parse(JSON.stringify(value)) as Condition;
itemRef.value = value.id; itemRef.value = value.id;
editorOpen.value = true; editorOpen.value = true;

View file

@ -1,44 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <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-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-terminal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Console</span> >
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<UBadge :color="sessionStatusColor" variant="soft" size="sm"> <div class="min-w-0 space-y-2">
<span class="inline-flex items-center gap-1.5"> <div
<UIcon class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
: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"
> >
Exit {{ sessionExitCode }} <span>{{ pageShell.sectionLabel }}</span>
</UBadge> <span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm"> <p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
{{ commandHistory.length }} saved
</UBadge>
</div> </div>
<p class="text-sm text-toned">{{ sessionStatusDescription }}</p>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2"> <div class="flex flex-wrap gap-2 xl:justify-end">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
@ -62,13 +45,15 @@
</div> </div>
</div> </div>
<UPageCard variant="naked" :ui="pageCardUi"> <UPageCard variant="naked" :ui="pageCardUi" class="flex min-h-0 flex-1">
<template #body> <template #body>
<div class="space-y-4"> <div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="overflow-hidden rounded-xl border border-default bg-neutral-950/95 shadow-sm"> <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 <div
ref="terminal_window" 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> </div>
@ -79,14 +64,25 @@
<div class="min-w-0 flex-1 space-y-1"> <div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div <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" /> <UIcon name="i-lucide-send" class="size-4 shrink-0 text-toned" />
<span>Command</span> <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> </div>
<UBadge :color="inputModeColor" variant="soft" size="sm"> <UBadge :color="isMultiLineInput ? 'info' : 'neutral'" variant="soft" size="sm">
{{ inputModeLabel }} {{ isMultiLineInput ? 'Multi-line' : 'Single-line' }}
</UBadge> </UBadge>
</div> </div>
@ -107,37 +103,13 @@
:description="sessionError" :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 &lt;your command&gt;</code>.
</p>
</template>
</UAlert>
<UAlert <UAlert
v-if="sessionStatus === 'reconnecting'" v-if="sessionStatus === 'reconnecting'"
color="warning" color="warning"
variant="soft" variant="soft"
icon="i-lucide-rotate-cw" icon="i-lucide-rotate-cw"
title="Reconnecting to the live stream" title="Reconnecting to the command stream"
description="The command is still tracked in the background and the page is trying to reattach automatically." description="The connection was lost. Attempting to reconnect and restore the stream."
/>
<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."
/> />
<UAlert <UAlert
@ -146,16 +118,7 @@
variant="soft" variant="soft"
icon="i-lucide-circle-off" icon="i-lucide-circle-off"
title="Session interrupted" title="Session interrupted"
description="The last command did not finish cleanly. Inspect the transcript above or rerun the command below." description="The command execution was interrupted."
/>
<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."
/> />
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end"> <div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
@ -167,8 +130,8 @@
class="console-input" class="console-input"
:options="ytDlpOptions" :options="ytDlpOptions"
:disabled="isStartBlocked" :disabled="isStartBlocked"
:icon="commandInputIcon" :icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
:icon-class="commandInputIconClass" :icon-class="isLoading ? 'animate-spin' : ''"
placeholder="--help" placeholder="--help"
:rows="5" :rows="5"
@keydown="handleKeyDown" @keydown="handleKeyDown"
@ -181,8 +144,8 @@
class="console-input" class="console-input"
:options="ytDlpOptions" :options="ytDlpOptions"
:disabled="isStartBlocked" :disabled="isStartBlocked"
:icon="commandInputIcon" :icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-terminal'"
:icon-class="commandInputIconClass" :icon-class="isLoading ? 'animate-spin' : ''"
placeholder="--help" placeholder="--help"
:multiple="true" :multiple="true"
:allowShortFlags="true" :allowShortFlags="true"
@ -247,7 +210,7 @@
<tr <tr
v-for="(cmd, index) in historyEntries" v-for="(cmd, index) in historyEntries"
:key="`${index}-${cmd}`" :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"> <td class="px-3 py-3 align-middle">
<button <button
@ -335,11 +298,19 @@
} }
.terminal-host :deep(.xterm) { .terminal-host :deep(.xterm) {
height: 100%;
padding: 0.75rem !important; padding: 0.75rem !important;
} }
.terminal-host :deep(.xterm-viewport) { .terminal-host :deep(.xterm-viewport) {
-ms-overflow-style: none;
background-color: transparent !important; background-color: transparent !important;
scrollbar-width: none;
}
.terminal-host :deep(.xterm-viewport::-webkit-scrollbar) {
width: 0;
height: 0;
} }
</style> </style>
@ -354,6 +325,7 @@ import { useConsoleSession } from '~/composables/useConsoleSession';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import type { AutoCompleteOptions } from '~/types/autocomplete'; import type { AutoCompleteOptions } from '~/types/autocomplete';
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
useHead({ title: 'Console' }); useHead({ title: 'Console' });
@ -366,13 +338,14 @@ let terminalResizeObserver: ResizeObserver | null = null;
let didInitialRender = false; let didInitialRender = false;
let renderedChunkCount = 0; let renderedChunkCount = 0;
const config = useConfigStore(); const config = useYtpConfig();
const toast = useNotification(); const toast = useNotification();
const dialog = useDialog(); const dialog = useDialog();
const consoleSession = useConsoleSession(); const consoleSession = useConsoleSession();
const pageShell = requirePageShell('console');
const terminal = ref<Terminal | null>(null); const terminal = shallowRef<Terminal | null>(null);
const terminalFit = ref<FitAddon | null>(null); const terminalFit = shallowRef<FitAddon | null>(null);
const command = ref(''); const command = ref('');
const manualReconnectPending = ref(false); const manualReconnectPending = ref(false);
const cancelPending = ref(false); const cancelPending = ref(false);
@ -383,10 +356,10 @@ const storedCommand = useStorage<string>('console_command', '');
const commandHistory = useStorage<string[]>('console_command_history', []); const commandHistory = useStorage<string[]>('console_command_history', []);
const pageCardUi = { const pageCardUi = {
root: 'w-full bg-transparent', root: 'flex min-h-0 flex-1 w-full bg-transparent',
container: 'w-full p-0 sm:p-0', container: 'flex min-h-0 flex-1 w-full p-0 sm:p-0',
wrapper: 'w-full items-stretch', wrapper: 'flex min-h-0 flex-1 w-full items-stretch',
body: 'w-full', body: 'flex min-h-0 flex-1 w-full flex-col',
}; };
const historyCardUi = { const historyCardUi = {
@ -404,11 +377,9 @@ const sessionStatus = computed(() => consoleSession.state.value.status);
const sessionError = computed(() => consoleSession.state.value.error); const sessionError = computed(() => consoleSession.state.value.error);
const sessionExitCode = computed(() => consoleSession.state.value.exitCode); const sessionExitCode = computed(() => consoleSession.state.value.exitCode);
const hasActiveSession = computed(() => Boolean(consoleSession.state.value.sessionId)); 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 displayCommand = computed(() => command.value.trim().replace(/^yt-dlp\b\s*/i, ''));
const runnableCommand = computed(() => displayCommand.value.replace(/\n/g, ' ').trim()); const runnableCommand = computed(() => displayCommand.value.replace(/\n/g, ' ').trim());
const hasValidCommand = computed(() => Boolean(runnableCommand.value)); const hasValidCommand = computed(() => Boolean(runnableCommand.value));
const hasYtDlpPrefix = computed(() => /^yt-dlp\b/i.test(command.value.trim()));
const isLoading = computed(() => consoleSession.isLoading.value); const isLoading = computed(() => consoleSession.isLoading.value);
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n'))); const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
const historyEntries = computed(() => commandHistory.value); const historyEntries = computed(() => commandHistory.value);
@ -418,22 +389,6 @@ const showCancelButton = computed(() =>
); );
const isStartBlocked = computed(() => isLoading.value); const isStartBlocked = computed(() => isLoading.value);
const canStartCommand = computed(() => !isStartBlocked.value && hasValidCommand.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(() => { const runButtonLabel = computed(() => {
if (runnableCommand.value === 'clear') { if (runnableCommand.value === 'clear') {
return 'Clear output'; return 'Clear output';
@ -468,7 +423,7 @@ const sessionStatusLabel = computed(() => {
return 'Reconnecting'; return 'Reconnecting';
case 'finished': case 'finished':
return 'Finished'; return sessionExitCode.value === 0 ? 'Finished' : 'Failed';
case 'interrupted': case 'interrupted':
return 'Interrupted'; return 'Interrupted';
@ -495,7 +450,7 @@ const sessionStatusColor = computed(() => {
return 'warning'; return 'warning';
case 'finished': case 'finished':
return 'success'; return sessionExitCode.value === 0 ? 'success' : 'error';
case 'error': case 'error':
return 'error'; return 'error';
@ -512,7 +467,7 @@ const sessionStatusIcon = computed(() => {
return 'i-lucide-loader-circle'; return 'i-lucide-loader-circle';
case 'finished': case 'finished':
return 'i-lucide-circle-check'; return sessionExitCode.value === 0 ? 'i-lucide-circle-check' : 'i-lucide-triangle-alert';
case 'interrupted': case 'interrupted':
return 'i-lucide-circle-off'; return 'i-lucide-circle-off';
@ -528,38 +483,6 @@ const sessionStatusIcon = computed(() => {
} }
}); });
const sessionStatusSpinning = computed(() => ACTIVE_SESSION_STATUSES.includes(sessionStatus.value)); 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) => { watch(command, (value) => {
storedCommand.value = value; storedCommand.value = value;
}); });

View file

@ -1,34 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-braces" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Custom Fields</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Custom fields allow you to add new fields to the download form. <div
</p> 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>
<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">
<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>
<UButton <UButton
v-if="items.length > 0" v-if="items.length > 0"
color="neutral" color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Field</span> <span>New Field</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +49,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,91 +65,167 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
<Pager <div
v-if="paging?.total_pages > 1" v-if="!isLoading && filteredItems.length > 0"
:page="paging.page" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
:last_page="paging.total_pages" >
:isLoading="isLoading" <div class="flex flex-wrap items-center gap-2">
@navigate="navigatePage" <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 <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" 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"> <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"> <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-full text-left">Field</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-2"> <div class="space-y-2">
<div class="font-semibold text-highlighted">{{ field.name }}</div> <div class="font-semibold text-highlighted">{{ field.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span class="inline-flex items-center gap-1"> <span
<UIcon name="i-lucide-terminal" class="size-3.5" /> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<span>{{ field.field }}</span> >
</span>
<span class="inline-flex items-center gap-1">
<UIcon name="i-lucide-list-ordered" class="size-3.5" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span> <span>Order: {{ field.order }}</span>
</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" /> <UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span> <span>Type: {{ field.kind }}</span>
</span> </span>
</div>
<div v-if="field.description" class="text-xs text-toned"> <span
{{ field.description }} 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>
</div> </div>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(field)" @click="exportItem(field)"
> >
<span v-if="!isMobile">Export</span> Export
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(field)" @click="editItem(field)"
> >
<span v-if="!isMobile">Edit</span> Edit
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(field)" @click="() => void deleteItem(field)"
> >
<span v-if="!isMobile">Delete</span> Delete
</UButton> </UButton>
</div> </div>
</td> </td>
@ -166,89 +236,131 @@
</div> </div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
v-for="field in filteredItems" <UCard
:key="field.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
class="flex h-full flex-col border bg-default" :ui="{
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" header: 'p-4 pb-3',
> body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
<template #header> footer: 'border-t border-default px-4 py-4',
<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> <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"> <div class="flex shrink-0 items-center gap-2">
<span <UButton
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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>Export Field</span>
<span>Order {{ field.order }}</span> </UButton>
</span>
<span <label class="inline-flex cursor-pointer items-center justify-center">
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" <input
> v-model="selectedIds"
<UIcon name="i-lucide-shapes" class="size-3.5" /> class="completed-checkbox size-4 rounded border-default"
<span>{{ field.kind }}</span> type="checkbox"
</span> :value="field.id"
/>
</label>
</div> </div>
</div> </div>
</template>
<UButton <div class="space-y-2 text-sm text-default">
color="info" <div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
variant="ghost" <span
size="xs" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
icon="i-lucide-file-up" >
square <UIcon name="i-lucide-list-ordered" class="size-3.5" />
@click="exportItem(field)" <span>Order: {{ field.order }}</span>
/> </span>
</div>
</template>
<div class="space-y-2 text-sm text-default"> <span
<div class="rounded-md border border-default bg-muted/20 px-3 py-2"> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<div class="flex items-start gap-2"> >
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" /> <UIcon name="i-lucide-shapes" class="size-3.5" />
<span class="wrap-break-word">{{ field.field }}</span> <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> </div>
<div <template #footer>
v-if="field.description" <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
class="rounded-md border border-default bg-muted/20 px-3 py-2" <UButton
> color="neutral"
<div class="flex items-start gap-2"> variant="outline"
<UIcon icon="i-lucide-pencil"
name="i-lucide-message-square-text" class="w-full justify-center"
class="mt-0.5 size-4 shrink-0 text-toned" @click="editItem(field)"
/> >
<span class="wrap-break-word">{{ field.description }}</span> 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> </template>
</div> </UCard>
</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>
</div> </div>
<UAlert <UAlert
@ -268,10 +380,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -283,14 +391,27 @@
description="There are no custom defined fields yet. Click the New Field button to add your first field." 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 <UModal
v-if="editorOpen" v-if="editorOpen"
:open="editorOpen" :open="editorOpen"
:title="modalTitle" :title="itemRef ? `Edit - ${item.name || 'Field'}` : 'Add new field'"
:description="modalDescription" description="Custom fields allow you to add new fields to the download form."
:dismissible="!dlFields.addInProgress.value" :dismissible="!dlFields.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }" :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> <template #body>
<DLFieldForm <DLFieldForm
@ -298,7 +419,8 @@
:addInProgress="dlFields.addInProgress.value" :addInProgress="dlFields.addInProgress.value"
:reference="itemRef" :reference="itemRef"
:item="item as DLField" :item="item as DLField"
@cancel="closeEditor()" @cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem" @submit="updateItem"
/> />
</template> </template>
@ -307,19 +429,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useDlFields } from '~/composables/useDlFields'; import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields'; import type { DLField } from '~/types/dl_fields';
import type { APIResponse } from '~/types/responses'; import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils'; import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm(); 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 displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const dlFields = useDlFields(); const dlFields = useDlFields();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { confirmDialog } = useDialog();
const items = dlFields.dlFields as Ref<DLField[]>; const items = dlFields.dlFields as Ref<DLField[]>;
const paging = dlFields.pagination; 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 item = ref<Partial<DLField>>({});
const itemRef = ref<number | null | undefined>(null); const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false); const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref(''); const query = ref('');
const showFilter = ref(false); 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 filteredItems = computed<DLField[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
@ -341,20 +473,69 @@ const filteredItems = computed<DLField[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet())); return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
}); });
const modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field')); const selectableFieldIds = computed(() =>
const modalDescription = computed( filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
() => 'Custom fields allow you to add new fields to the download form.',
); );
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( const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`, () => `${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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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 syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = dlFields.pagination.value.total_pages; const totalPages = dlFields.pagination.value.total_pages;
const nextQuery = { ...route.query }; const nextQuery = { ...route.query };
@ -376,7 +557,7 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const loadContent = async (pageNumber = 1): Promise<void> => { const loadContent = async (pageNumber = 1): Promise<void> => {
@ -397,6 +578,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => { const resetEditor = (): void => {
item.value = {}; item.value = {};
itemRef.value = null; itemRef.value = null;
editorDirty.value = false;
}; };
const closeEditor = (): void => { const closeEditor = (): void => {
@ -413,6 +595,15 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list'; 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> => { const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) { if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return; return;
@ -421,6 +612,51 @@ const deleteItem = async (field: DLField): Promise<void> => {
await dlFields.deleteDlField(field.id!); 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 ({ const updateItem = async ({
reference, reference,
item: updatedItem, item: updatedItem,
@ -442,6 +678,7 @@ const updateItem = async ({
}; };
const editItem = (field: DLField): void => { const editItem = (field: DLField): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(field)) as DLField; item.value = JSON.parse(JSON.stringify(field)) as DLField;
itemRef.value = field.id; itemRef.value = field.id;
editorOpen.value = true; editorOpen.value = true;

View file

@ -1,22 +1,40 @@
<template> <template>
<main class="space-y-6"> <main class="space-y-6">
<UPageHeader :title="docEntry.title" :description="docEntry.description" :ui="pageHeaderUi"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<template #links> <div class="flex min-w-0 items-center gap-3">
<div class="flex flex-wrap items-center gap-2"> <span
<UButton class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
v-for="entry in docsEntries" >
:key="entry.id" <UIcon :name="pageShell.icon" class="size-5" />
:to="entry.route" </span>
:color="entry.file === docEntry.file ? 'primary' : 'neutral'"
:variant="entry.file === docEntry.file ? 'solid' : 'outline'" <div class="min-w-0 space-y-2">
size="sm" <div
:icon="entry.icon" class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
> >
{{ entry.navLabel }} <span>{{ pageShell.sectionLabel }}</span>
</UButton> <span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div> </div>
</template> </div>
</UPageHeader>
<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"> <UPageCard variant="outline" :ui="pageCardUi">
<template #body> <template #body>
@ -31,6 +49,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Markdown from '~/components/Markdown.vue'; import Markdown from '~/components/Markdown.vue';
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs'; import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute(); const route = useRoute();
@ -49,19 +68,12 @@ const docEntry = computed(() => {
return entry; return entry;
}); });
const pageShell = computed(() => requirePageShell(docEntry.value.id));
useHead(() => ({ useHead(() => ({
title: docEntry.value.title, 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 = { const pageCardUi = {
root: 'w-full bg-default', root: 'w-full bg-default',
container: 'w-full p-0', container: 'w-full p-0',

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

View file

@ -1,8 +1,10 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-start gap-3">
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted"> <span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon <UIcon
name="i-lucide-file-text" name="i-lucide-file-text"
:class="[ :class="[
@ -12,36 +14,22 @@
:title="loading ? 'Loading history' : 'Live stream active'" :title="loading ? 'Loading history' : 'Live stream active'"
:aria-label="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> <div class="min-w-0 space-y-2">
<div
<UBadge color="neutral" variant="soft" size="sm"> class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
{{ filteredLogs.length }} shown
</UBadge>
<UBadge
v-if="logs.length !== filteredLogs.length"
color="neutral"
variant="outline"
size="sm"
> >
{{ logs.length }} loaded <span>{{ pageShell.sectionLabel }}</span>
</UBadge> <span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<UBadge v-if="hasActiveFilter" color="warning" variant="soft" size="sm"> <p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
{{ matchCount }} matches
</UBadge>
<UBadge v-if="reachedEnd && !hasActiveFilter" color="neutral" variant="soft" size="sm">
Start of file loaded
</UBadge>
</div> </div>
<p class="text-sm text-toned">Scroll near the top to load older logs.</p>
</div> </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 <UButton
v-if="!autoScroll" v-if="!autoScroll"
color="neutral" color="neutral"
@ -53,22 +41,6 @@
Jump to Live Tail Jump to Live Tail
</UButton> </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 <UButton
color="neutral" color="neutral"
:variant="toggleFilter ? 'soft' : 'outline'" :variant="toggleFilter ? 'soft' : 'outline'"
@ -91,22 +63,23 @@
> >
Wrap lines Wrap lines
</UButton> </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>
</div> </div>
<UPageCard variant="naked" :ui="pageCardUi"> <UPageCard variant="naked" :ui="pageCardUi">
<template #body> <template #body>
<div class="w-full min-w-0 max-w-full space-y-3"> <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 class="w-full min-w-0 max-w-full overflow-hidden">
<div <div
ref="logContainer" ref="logContainer"
@ -148,24 +121,24 @@
</article> </article>
</div> </div>
<div <div v-else class="space-y-3 font-sans text-sm leading-normal">
v-else <UAlert
class="rounded-xl border border-default bg-muted/20 px-4 py-6 text-center" v-if="query"
> color="warning"
<div class="space-y-2"> variant="soft"
<p class="text-sm font-semibold text-highlighted"> icon="i-lucide-search"
{{ title="No Results"
hasActiveFilter :description="`No log lines found for the query: ${query}. Please try a different search term.`"
? 'No log lines match the current filter.' />
: 'No log lines are available yet.'
}}
</p>
<p v-if="hasActiveFilter" class="text-xs text-toned"> <UAlert
Try a different term or clear <code>{{ query }}</code v-else
>. color="warning"
</p> variant="soft"
</div> icon="i-lucide-circle-alert"
title="No log lines"
description="No log lines are available yet."
/>
</div> </div>
<div ref="bottomMarker" /> <div ref="bottomMarker" />
@ -185,6 +158,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs'; import type { log_line } from '~/types/logs';
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils'; import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = { type FilteredLogEntry = {
log: log_line; log: log_line;
@ -199,8 +173,9 @@ const FILTER_CONTEXT_REGEX = /context:(\d+)/;
let scrollTimeout: NodeJS.Timeout | null = null; let scrollTimeout: NodeJS.Timeout | null = null;
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useYtpConfig();
const route = useRoute(); const route = useRoute();
const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer'); const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker'); const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
@ -302,8 +277,6 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
return result; return result;
}); });
const matchCount = computed(() => filteredLogs.value.filter((entry) => entry.isMatch).length);
const fetchLogs = async (): Promise<void> => { const fetchLogs = async (): Promise<void> => {
loading.value = true; loading.value = true;

View file

@ -1,34 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-bell" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Notifications</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Send notifications to your webhooks based on specified events or presets. <div
</p> 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>
<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">
<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>
<UButton <UButton
v-if="notifications.length > 0" v-if="notifications.length > 0"
color="neutral" color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Notification</span> <span>New Notification</span>
</UButton> </UButton>
<UButton <UButton
@ -60,7 +53,7 @@
:disabled="sendingTest" :disabled="sendingTest"
@click="() => void sendTest()" @click="() => void sendTest()"
> >
<span v-if="!isMobile">Send Test</span> <span>Send Test</span>
</UButton> </UButton>
<UButton <UButton
@ -69,9 +62,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -84,34 +78,108 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
<Pager <div
v-if="paging?.total_pages > 1" v-if="!isLoading && filteredTargets.length > 0"
:page="paging.page" class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
:last_page="paging.total_pages" >
:isLoading="isLoading" <div class="flex flex-wrap items-center gap-2">
@navigate="navigatePage" <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 <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" 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"> <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"> <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-full text-left">Targets</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-2"> <div class="space-y-2">
<div class="min-w-0 text-sm font-semibold text-highlighted"> <div class="min-w-0 text-sm font-semibold text-highlighted">
@ -141,54 +209,61 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button> </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" /> <UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span>On: {{ joinEvents(item.on) }}</span> <span>On: {{ joinEvents(item.on) }}</span>
</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" /> <UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Presets: {{ joinPresets(item.presets) }}</span> <span>Presets: {{ joinPresets(item.presets) }}</span>
</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" /> <UIcon name="i-lucide-key" class="size-3.5" />
<span>Headers: {{ headerKeys(item).join(', ') }}</span> <span>Headers: {{ headerKeys(item).length }}</span>
</span> </span>
</div> </div>
</div> </div>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(item)" @click="exportItem(item)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(item)" @click="editItem(item)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -199,104 +274,191 @@
</div> </div>
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
v-for="item in filteredTargets" <UCard
:key="item.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
class="flex h-full flex-col border bg-default" :ui="{
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" header: 'p-4 pb-3',
> body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
<template #header> footer: 'border-t border-default px-4 py-4',
<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"> <template #header>
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) <div class="flex min-w-0 items-start justify-between gap-3">
</span> <div class="min-w-0 flex-1">
@ <div class="flex items-start gap-2">
<a <button
:href="item.request.url" type="button"
target="_blank" class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
rel="noreferrer" @click="toggleExpand(item.id, 'title')"
class="text-sm text-primary hover:underline" >
> <span :class="['block', expandClass(item.id, 'title')]">
{{ item.name }} {{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
</a> {{ item.name }}
</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-toned"> <div class="flex shrink-0 items-center gap-2">
<button <UButton
type="button" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default" variant="ghost"
:disabled="addInProgress" size="xs"
@click="() => void toggleEnabled(item)" icon="i-lucide-file-up"
square
@click="exportItem(item)"
> >
<UIcon <span>Export Target</span>
name="i-lucide-power" </UButton>
class="size-3.5"
:class="item.enabled !== false ? 'text-success' : 'text-error'" <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> </label>
</button>
</div> </div>
</div> </div>
</template>
<UButton <div class="space-y-2 text-sm text-default">
color="info" <div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
variant="ghost" <button
size="xs" type="button"
icon="i-lucide-file-up" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
square :disabled="addInProgress"
@click="exportItem(item)" @click="() => void toggleEnabled(item)"
/> >
</div> <UIcon
</template> 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"> <span
<div class="rounded-md border border-default bg-muted/20 px-3 py-2"> class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<div class="flex items-start gap-2"> >
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" /> <UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span class="wrap-break-word">On: {{ joinEvents(item.on) }}</span> <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>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2"> <button
<div class="flex items-start gap-2"> type="button"
<UIcon name="i-lucide-sliders-horizontal" class="mt-0.5 size-4 shrink-0 text-toned" /> 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"
<span class="wrap-break-word">Presets: {{ joinPresets(item.presets) }}</span> @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>
<div <button
v-if="headerKeys(item).length > 0" v-if="headerKeys(item).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2" 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"
<div class="flex items-start gap-2"> @click="toggleExpand(item.id, 'headers')"
>
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" /> <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 class="min-w-0 flex-1">
</div> <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>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
color="warning" <UButton
variant="outline" color="neutral"
icon="i-lucide-pencil" variant="outline"
class="w-full justify-center" icon="i-lucide-pencil"
@click="editItem(item)" class="w-full justify-center"
> @click="editItem(item)"
Edit >
</UButton> Edit
</UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-trash" icon="i-lucide-trash"
class="w-full justify-center" class="w-full justify-center"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
Delete Delete
</UButton> </UButton>
</div> </div>
</UCard> </template>
</UCard>
</div>
</div> </div>
<UAlert <UAlert
@ -316,10 +478,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -331,6 +489,19 @@
description="No notification targets found. Click on the New Notification button to add your first notification target." 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 <div
v-if="!query && filteredTargets.length > 0" v-if="!query && filteredTargets.length > 0"
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default" class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
@ -366,11 +537,11 @@
<UModal <UModal
v-if="editorOpen" v-if="editorOpen"
:open="editorOpen" :open="editorOpen"
:title="modalTitle" :title="targetRef ? `Edit - ${target.name}` : 'Add new notification target'"
:description="modalDescription" description="Send notifications to your webhooks based on specified events or presets."
:dismissible="!addInProgress" :dismissible="!addInProgress"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }" :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> <template #body>
<NotificationForm <NotificationForm
@ -379,7 +550,8 @@
:reference="targetRef" :reference="targetRef"
:item="target" :item="target"
:allowedEvents="allowedEvents" :allowedEvents="allowedEvents"
@cancel="closeEditor()" @cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem" @submit="updateItem"
/> />
</template> </template>
@ -388,20 +560,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useNotifications } from '~/composables/useNotifications'; import { useNotifications } from '~/composables/useNotifications';
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils'; import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
import type { ImportedItem } from '~/types'; import type { ImportedItem } from '~/types';
import type { notification } from '~/types/notification'; import type { notification } from '~/types/notification';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification(); const toast = useNotification();
const box = useConfirm(); 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'>( const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style', 'notification_display_style',
'cards', 'cards',
); );
const isMobile = useMediaQuery({ maxWidth: 639 });
const notificationsStore = useNotifications(); const notificationsStore = useNotifications();
const notifications = notificationsStore.notifications; const notifications = notificationsStore.notifications;
@ -415,25 +594,40 @@ const page = ref(1);
const targetRef = ref<number | undefined>(undefined); const targetRef = ref<number | undefined>(undefined);
const target = ref<notification>(defaultState()); const target = ref<notification>(defaultState());
const editorOpen = ref(false); const editorOpen = ref(false);
const editorDirty = ref(false);
const sendingTest = ref(false); const sendingTest = ref(false);
const query = ref(''); const query = ref('');
const showFilter = ref(false); 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'>(() => const displayStyle = computed<'list' | 'grid'>(() =>
displayStyleState.value === 'list' ? '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( const modalKey = computed(
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`, () => `${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 filteredTargets = computed<notification[]>(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
const items = notifications.value as notification[]; const items = notifications.value as notification[];
@ -445,12 +639,47 @@ const filteredTargets = computed<notification[]>(() => {
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet())); 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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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 { function defaultState(): notification {
return { return {
name: '', name: '',
@ -469,7 +698,7 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const loadContent = async (pageNumber = page.value): Promise<void> => { const loadContent = async (pageNumber = page.value): Promise<void> => {
@ -488,6 +717,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => { const resetEditor = (): void => {
target.value = defaultState(); target.value = defaultState();
targetRef.value = undefined; targetRef.value = undefined;
editorDirty.value = false;
}; };
const closeEditor = (): void => { const closeEditor = (): void => {
@ -500,7 +730,17 @@ const openCreate = (): void => {
editorOpen.value = true; editorOpen.value = true;
}; };
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableNotificationIds.value];
};
const editItem = (item: notification): void => { const editItem = (item: notification): void => {
editorDirty.value = false;
target.value = JSON.parse(JSON.stringify(item)) as notification; target.value = JSON.parse(JSON.stringify(item)) as notification;
targetRef.value = item.id ?? undefined; targetRef.value = item.id ?? undefined;
editorOpen.value = true; editorOpen.value = true;
@ -519,6 +759,51 @@ const deleteItem = async (item: notification): Promise<void> => {
await notificationsStore.deleteNotification(item.id); 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> => { const toggleEnabled = async (item: notification): Promise<void> => {
if (!item.id) { if (!item.id) {
toast.error('Notification target not found.'); toast.error('Notification target not found.');

View file

@ -1,35 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Presets</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Presets are pre-defined command options for yt-dlp that you want to apply to given <div
download. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<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>
<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">
<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>
<UButton <UButton
v-if="presetsNoDefault.length > 0" v-if="presetsNoDefault.length > 0"
color="neutral" color="neutral"
@ -38,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -48,7 +40,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="editor.openCreate()" @click="editor.openCreate()"
> >
<span v-if="!isMobile">New Preset</span> <span>New Preset</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +48,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @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>
<UButton <UButton
@ -71,26 +64,96 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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> </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" 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"> <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"> <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-full text-left">Preset</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-48 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td class="px-3 py-3 align-middle">
<div class="space-y-1"> <div class="space-y-1">
<div class="font-semibold text-highlighted"> <div class="font-semibold text-highlighted">
@ -99,14 +162,20 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span <span
class="inline-flex items-center gap-1" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
:class="item.cookies ? 'text-info' : ''"
> >
<UIcon name="i-lucide-cookie" class="size-3.5" /> <UIcon
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span> name="i-lucide-cookie"
class="size-3.5"
:class="item.cookies ? 'text-success' : ''"
/>
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
</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" /> <UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span> <span>Priority: {{ item.priority }}</span>
</span> </span>
@ -114,36 +183,36 @@
</div> </div>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="exportItem(item)" @click="exportItem(item)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editor.openEdit(item)" @click="editor.openEdit(item)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -154,121 +223,169 @@
</div> </div>
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard <div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
v-for="item in filteredPresets" <UCard
:key="item.id" class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
class="flex h-full flex-col border bg-default" :ui="{
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }" header: 'p-4 pb-3',
> body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
<template #header> footer: 'border-t border-default px-4 py-4',
<div class="flex items-start justify-between gap-3"> }"
<button >
type="button" <template #header>
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted" <div class="flex min-w-0 items-start justify-between gap-3">
@click="toggleExpand(item.id, 'title')" <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 <span
:class=" class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
"
> >
{{ 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>
<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> </button>
<UButton <div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
color="info" <button
variant="ghost" v-if="item.template"
size="xs" type="button"
icon="i-lucide-file-up" :class="[
square '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="exportItem(item)" !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>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1"> <template #footer>
<span <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" <UButton
:class="item.cookies ? 'border-info/40 text-info' : ''" color="neutral"
> variant="outline"
<UIcon name="i-lucide-cookie" class="size-3.5" /> icon="i-lucide-pencil"
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span> class="w-full justify-center"
</span> @click="editor.openEdit(item)"
>
Edit
</UButton>
<span <UButton
v-if="item.priority > 0" color="neutral"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" variant="outline"
> icon="i-lucide-trash"
<UIcon name="i-lucide-list-ordered" class="size-3.5" /> class="w-full justify-center"
<span>Priority {{ item.priority }}</span> @click="() => void deleteItem(item)"
</span> >
</div> Delete
</template> </UButton>
</div>
<div class="space-y-2 text-sm text-default"> </template>
<button </UCard>
v-if="item.folder" </div>
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>
</div> </div>
<UAlert <UAlert
@ -288,10 +405,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -308,7 +421,8 @@
<ul class="list-disc space-y-2 pl-5 text-sm text-default"> <ul class="list-disc space-y-2 pl-5 text-sm text-default">
<li> <li>
When you export preset, it doesn't include the cookies field contents for security 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> </li>
</ul> </ul>
</template> </template>
@ -321,7 +435,7 @@
:description="editor.modalDescription.value" :description="editor.modalDescription.value"
:dismissible="!editor.addInProgress.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' }" :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> <template #body>
<PresetForm <PresetForm
@ -331,6 +445,7 @@
:preset="editor.preset.value" :preset="editor.preset.value"
:presets="presets" :presets="presets"
@cancel="() => void editor.requestClose()" @cancel="() => void editor.requestClose()"
@dirty-change="(dirty) => (editor.dirty.value = dirty)"
@submit="editor.submit" @submit="editor.submit"
/> />
</template> </template>
@ -339,28 +454,36 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { Preset } from '~/types/presets'; import type { Preset } from '~/types/presets';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils'; import { prettyName } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean }; type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
const presetsStore = usePresets(); const presetsStore = usePresets();
const config = useConfigStore(); const config = useYtpConfig();
const box = useConfirm(); const box = useConfirm();
const editor = usePresetEditor(); const editor = usePresetEditor();
const pageShell = requirePageShell('presets');
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<string>('preset_display_style', 'grid'); const display_style = useStorage<string>('preset_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 1024 }); const isMobile = useMediaQuery({ maxWidth: 639 });
const query = ref(''); const query = ref('');
const showFilter = ref(false); 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 presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
const isLoading = presetsStore.isLoading; const isLoading = presetsStore.isLoading;
const expandedItems = ref<Record<string, Set<string>>>({});
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default)); 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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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> => { const toggleFilterPanel = async (): Promise<void> => {
showFilter.value = !showFilter.value; showFilter.value = !showFilter.value;
if (!showFilter.value) { if (!showFilter.value) {
@ -389,13 +550,67 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const reloadContent = async (): Promise<void> => { const reloadContent = async (): Promise<void> => {
await presetsStore.loadPresets(1, 1000); 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> => { const deleteItem = async (item: Preset): Promise<void> => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) { if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return; return;
@ -410,35 +625,6 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; 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 exportItem = (item: Preset): void => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description']; const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
const userData = Object.fromEntries( const userData = Object.fromEntries(

View file

@ -1,34 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-workflow" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Task Definitions</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
Create definitions to turn any website into a downloadable feed of links. <div
</p> 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>
<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">
<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>
<UButton <UButton
v-if="definitions.length > 0" v-if="definitions.length > 0"
color="neutral" color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -47,7 +40,7 @@
icon="i-lucide-search" icon="i-lucide-search"
@click="inspect = true" @click="inspect = true"
> >
<span v-if="!isMobile">Inspect</span> <span>Inspect</span>
</UButton> </UButton>
<UButton <UButton
@ -57,7 +50,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreate" @click="openCreate"
> >
<span v-if="!isMobile">New Definition</span> <span>New Definition</span>
</UButton> </UButton>
<UButton <UButton
@ -65,9 +58,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @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>
<UButton <UButton
@ -79,8 +73,20 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
@ -94,17 +100,62 @@
/> />
<div <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" 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"> <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"> <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-full text-left">Definition</th>
<th class="w-28 whitespace-nowrap">Priority</th> <th class="w-28 whitespace-nowrap">Priority</th>
<th class="w-36 whitespace-nowrap">Updated</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> </tr>
</thead> </thead>
@ -112,8 +163,19 @@
<tr <tr
v-for="definition in filteredDefinitions" v-for="definition in filteredDefinitions"
:key="definition.id" :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"> <td class="px-3 py-3 align-middle">
<div class="space-y-1"> <div class="space-y-1">
<div class="font-semibold text-highlighted"> <div class="font-semibold text-highlighted">
@ -123,7 +185,7 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned"> <div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<button <button
type="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)" @click="() => void toggle(definition)"
> >
<UIcon <UIcon
@ -134,19 +196,19 @@
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span> <span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button> </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" /> <UIcon name="i-lucide-link" class="size-3.5" />
<span <span>Patterns: {{ definition.match_url.length }} match/s</span>
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
>
</span> </span>
</div> </div>
</div> </div>
</td> </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"> <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')"> <UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')">
@ -158,36 +220,36 @@
</UTooltip> </UTooltip>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="info" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
@click="() => void exportDefinition(definition)" @click="() => void exportDefinition(definition)"
> >
<span v-if="!isMobile">Export</span> <span class="hidden sm:inline">Export</span>
</UButton> </UButton>
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="() => void openEdit(definition)" @click="() => void openEdit(definition)"
> >
<span v-if="!isMobile">Edit</span> <span class="hidden sm:inline">Edit</span>
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void remove(definition)" @click="() => void remove(definition)"
> >
<span v-if="!isMobile">Delete</span> <span class="hidden sm:inline">Delete</span>
</UButton> </UButton>
</div> </div>
</td> </td>
@ -201,110 +263,122 @@
v-else-if="filteredDefinitions.length > 0" v-else-if="filteredDefinitions.length > 0"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
> >
<UCard <div
v-for="definition in filteredDefinitions" v-for="definition in filteredDefinitions"
:key="definition.id" :key="definition.id"
class="flex h-full flex-col border bg-default" class="min-w-0 w-full max-w-full"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
> >
<template #header> <UCard
<div class="flex items-start justify-between gap-3"> class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
<div class="min-w-0 flex-1 space-y-2"> :ui="{
<div class="text-sm font-semibold text-highlighted wrap-break-word"> header: 'p-4 pb-3',
{{ definition.name || '(Unnamed definition)' }} body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
</div> footer: 'border-t border-default px-4 py-4',
}"
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1"> >
<button <template #header>
type="button" <div class="flex min-w-0 items-start justify-between gap-3">
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default" <div class="min-w-0 flex-1">
@click="() => void toggle(definition)" <div class="flex items-start gap-2">
> <button
<UIcon type="button"
name="i-lucide-power" class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
class="size-3.5" @click="toggleExpand(definition.id, 'title')"
: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
> >
<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> </span>
</div> </div>
</div> </button>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
/>
</div> </div>
</template>
<div class="space-y-3 text-sm text-default"> <template #footer>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2"> <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<div <UButton
class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-toned" color="neutral"
> variant="outline"
<UIcon name="i-lucide-link" class="size-3.5" /> icon="i-lucide-pencil"
<span>Match patterns</span> class="w-full justify-center"
</div> @click="() => void openEdit(definition)"
<div class="space-y-1 text-sm">
<div
v-for="pattern in definition.match_url.slice(0, 3)"
:key="pattern"
class="truncate text-default"
> >
{{ pattern }} Edit
</div> </UButton>
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
+{{ definition.match_url.length - 3 }} more <UButton
</div> color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void remove(definition)"
>
Delete
</UButton>
</div> </div>
</div> </template>
</div> </UCard>
</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>
</div> </div>
<UAlert <UAlert
@ -324,10 +398,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -342,11 +412,19 @@
<UModal <UModal
v-if="isEditorOpen" v-if="isEditorOpen"
:open="isEditorOpen" :open="isEditorOpen"
:title="editorTitle" :title="
:description="editorDescription" 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" :dismissible="!editorLoading && !editorSubmitting"
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }" :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> <template #body>
<TaskDefinitionEditor <TaskDefinitionEditor
@ -356,7 +434,8 @@
:loading="editorLoading" :loading="editorLoading"
:submitting="editorSubmitting" :submitting="editorSubmitting"
@submit="submitDefinition" @submit="submitDefinition"
@cancel="closeEditor" @cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@import-existing="importExistingDefinition" @import-existing="importExistingDefinition"
/> />
</template> </template>
@ -378,14 +457,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import moment from 'moment'; import moment from 'moment';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'; import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery'; import { useMediaQuery } from '~/composables/useMediaQuery';
import { copyText, encode } from '~/utils'; import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
import type { import type {
TaskDefinitionDetailed, 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 taskDefs = useTaskDefinitionsComposable();
const definitionsRef = taskDefs.definitions; const definitionsRef = taskDefs.definitions;
@ -430,6 +514,7 @@ const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.v
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const isEditorOpen = ref(false); const isEditorOpen = ref(false);
const editorDirty = ref(false);
const editorMode = ref<'create' | 'edit'>('create'); const editorMode = ref<'create' | 'edit'>('create');
const editorLoading = ref(false); const editorLoading = ref(false);
const editorSubmitting = ref(false); const editorSubmitting = ref(false);
@ -437,11 +522,14 @@ const workingDefinition = ref<TaskDefinitionDocument | null>(null);
const workingId = ref<number | null>(null); const workingId = ref<number | null>(null);
const inspect = ref(false); const inspect = ref(false);
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid'); const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const query = ref(''); const query = ref('');
const showFilter = ref(false); const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null); const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const hideImportByDefault = ref(false); const hideImportByDefault = ref(false);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => { const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
const normalizedQuery = query.value.trim().toLowerCase(); 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>(() => { const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if (editorMode.value !== 'edit' || !workingId.value) { if (editorMode.value !== 'edit' || !workingId.value) {
return undefined; return undefined;
@ -471,30 +588,45 @@ const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
return definitions.value.find((item) => item.id === workingId.value); 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( const showImportByDefault = computed(
() => editorMode.value === 'create' && !hideImportByDefault.value, () => 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) => { watch(showFilter, (value) => {
if (!value) { if (!value) {
query.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 => { const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument; return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
}; };
@ -507,7 +639,7 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const reloadContent = async (): Promise<void> => { const reloadContent = async (): Promise<void> => {
@ -518,7 +650,17 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list'; 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 => { const openCreate = (): void => {
editorDirty.value = false;
editorMode.value = 'create'; editorMode.value = 'create';
workingId.value = null; workingId.value = null;
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION); workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
@ -529,6 +671,7 @@ const openCreate = (): void => {
}; };
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => { const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorDirty.value = false;
editorMode.value = 'edit'; editorMode.value = 'edit';
workingId.value = summary.id; workingId.value = summary.id;
workingDefinition.value = null; workingDefinition.value = null;
@ -559,6 +702,7 @@ const importExistingDefinition = async (id: number): Promise<void> => {
return; return;
} }
editorDirty.value = false;
editorMode.value = 'create'; editorMode.value = 'create';
workingId.value = null; workingId.value = null;
workingDefinition.value = { workingDefinition.value = {
@ -578,6 +722,7 @@ const closeEditor = (): void => {
return; return;
} }
editorDirty.value = false;
isEditorOpen.value = false; isEditorOpen.value = false;
workingDefinition.value = null; workingDefinition.value = null;
workingId.value = null; workingId.value = null;
@ -625,6 +770,48 @@ const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
await deleteDefinition(summary.id); 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> => { const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled); await toggleEnabled(summary.id, !summary.enabled);
}; };

View file

@ -1,35 +1,27 @@
<template> <template>
<main class="w-full min-w-0 max-w-full space-y-4"> <main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1"> <div class="flex min-w-0 items-center gap-3">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted"> <span
<UIcon name="i-lucide-list-todo" class="size-5 text-toned" /> class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
<span>Tasks</span> >
</div> <UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned"> <div class="min-w-0 space-y-2">
The task runner is simple queue system that allows you to poll channels or playlists for <div
new content at specified intervals. class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
</p> >
<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>
<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">
<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>
<UButton <UButton
v-if="tasks.length > 0" v-if="tasks.length > 0"
color="neutral" color="neutral"
@ -38,7 +30,7 @@
icon="i-lucide-filter" icon="i-lucide-filter"
@click="toggleFilterPanel" @click="toggleFilterPanel"
> >
<span v-if="!isMobile">Filter</span> <span>Filter</span>
</UButton> </UButton>
<UButton <UButton
@ -48,7 +40,7 @@
icon="i-lucide-plus" icon="i-lucide-plus"
@click="openCreateForm" @click="openCreateForm"
> >
<span v-if="!isMobile">New Task</span> <span>New Task</span>
</UButton> </UButton>
<UButton <UButton
@ -56,9 +48,10 @@
variant="outline" variant="outline"
size="sm" size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'" :icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle" @click="toggleDisplayStyle"
> >
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span> <span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton> </UButton>
<UButton <UButton
@ -71,8 +64,20 @@
:disabled="isLoading" :disabled="isLoading"
@click="() => void reloadContent()" @click="() => void reloadContent()"
> >
<span v-if="!isMobile">Reload</span> <span>Reload</span>
</UButton> </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>
</div> </div>
@ -112,13 +117,15 @@
</div> </div>
<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" 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"> <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"> <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"> <th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection"> <button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon <UIcon
@ -128,13 +135,17 @@
</button> </button>
</th> </th>
<th class="w-full text-left">Task</th> <th class="w-full text-left">Task</th>
<th class="w-100 whitespace-nowrap">Timer</th> <th class="w-50 whitespace-nowrap">Timer</th>
<th class="w-44 whitespace-nowrap">Actions</th> <th class="w-75 whitespace-nowrap">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-default"> <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"> <td class="px-3 py-3 text-center align-top">
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
@ -184,7 +195,7 @@
<button <button
type="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" 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 <UIcon
name="i-lucide-power" name="i-lucide-power"
@ -194,27 +205,30 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button> </button>
<span <button
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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 <UIcon
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'" name="i-lucide-circle-play"
class="size-3.5" class="size-3.5"
:class="item.auto_start ? 'text-success' : 'text-error'"
/> />
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span> <span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
</span> </button>
<button <button
type="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" 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 <UIcon
name="i-lucide-rss" name="i-lucide-rss"
class="size-3.5" class="size-3.5"
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'" :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> </button>
<span <span
@ -222,27 +236,10 @@
> >
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" /> <UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span class="capitalize"> <span class="capitalize">
{{ item.preset ?? config.app.default_preset }} Preset: {{ item.preset ?? config.app.default_preset }}
</span> </span>
</span> </span>
</div> </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>
</div> </div>
</td> </td>
@ -273,7 +270,7 @@
> >
<span class="inline-flex items-center gap-1 whitespace-nowrap"> <span class="inline-flex items-center gap-1 whitespace-nowrap">
<UIcon name="i-lucide-triangle-alert" class="size-3.5" /> <UIcon name="i-lucide-triangle-alert" class="size-3.5" />
<span>No timer or handler</span> <span>Not configured</span>
</span> </span>
</p> </p>
@ -286,26 +283,26 @@
</div> </div>
</td> </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"> <div class="flex items-center justify-end gap-2">
<UButton <UButton
color="warning" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-pencil" icon="i-lucide-pencil"
@click="editItem(item)" @click="editItem(item)"
> >
<span v-if="!isMobile">Edit</span> Edit
</UButton> </UButton>
<UButton <UButton
color="error" color="neutral"
variant="outline" variant="outline"
size="xs" size="xs"
icon="i-lucide-trash" icon="i-lucide-trash"
@click="() => void deleteItem(item)" @click="() => void deleteItem(item)"
> >
<span v-if="!isMobile">Delete</span> Delete
</UButton> </UButton>
<UDropdownMenu :items="itemActionGroups(item)" :modal="false"> <UDropdownMenu :items="itemActionGroups(item)" :modal="false">
@ -316,7 +313,7 @@
icon="i-lucide-settings-2" icon="i-lucide-settings-2"
trailing-icon="i-lucide-chevron-down" trailing-icon="i-lucide-chevron-down"
> >
<span v-if="!isMobile">Actions</span> Actions
</UButton> </UButton>
</UDropdownMenu> </UDropdownMenu>
</div> </div>
@ -334,7 +331,11 @@
<div v-for="item in filteredTasks" :key="item.id" class="min-w-0 w-full max-w-full"> <div v-for="item in filteredTasks" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard <UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default" 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> <template #header>
<div class="flex min-w-0 items-start justify-between gap-3"> <div class="flex min-w-0 items-start justify-between gap-3">
@ -343,10 +344,20 @@
<NuxtLink <NuxtLink
target="_blank" target="_blank"
:href="item.url" :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> </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 <UIcon
v-if="item.id && isTaskInProgress(item.id)" v-if="item.id && isTaskInProgress(item.id)"
@ -355,7 +366,10 @@
/> />
</div> </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 <UBadge
v-for="tag in get_tags(item.name)" v-for="tag in get_tags(item.name)"
:key="`${item.id}-${tag}`" :key="`${item.id}-${tag}`"
@ -370,14 +384,15 @@
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<UButton <UButton
color="info" color="neutral"
variant="ghost" variant="ghost"
size="xs" size="xs"
icon="i-lucide-file-up" icon="i-lucide-file-up"
square square
@click="() => void exportItem(item)" @click="() => void exportItem(item)"
/> >
<span>Export Task</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center"> <label class="inline-flex cursor-pointer items-center justify-center">
<input <input
v-model="selectedElms" v-model="selectedElms"
@ -391,11 +406,11 @@
</template> </template>
<div class="space-y-2 text-sm text-default"> <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 <button
type="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" 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 toggleEnabled(item)" @click="() => void toggleFlag(item, 'enabled')"
> >
<UIcon <UIcon
name="i-lucide-power" name="i-lucide-power"
@ -405,39 +420,55 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button> </button>
<span <button
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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 <UIcon
:name="item.auto_start ? 'i-lucide-circle-pause' : 'i-lucide-circle-play'" name="i-lucide-circle-play"
class="size-3.5" class="size-3.5"
:class="item.auto_start ? 'text-success' : 'text-error'"
/> />
<span>{{ item.auto_start ? 'Auto' : 'Manual' }}</span> <span>Auto start: {{ item.auto_start ? 'Yes' : 'No' }}</span>
</span> </button>
<button <button
type="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" 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 toggleHandlerEnabled(item)" @click="() => void toggleFlag(item, 'handler_enabled')"
> >
<UIcon <UIcon
name="i-lucide-rss" name="i-lucide-rss"
class="size-3.5" class="size-3.5"
:class="item.handler_enabled !== false ? 'text-success' : 'text-error'" :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> </button>
<span <button
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1" 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" /> <UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span class="capitalize">{{ item.preset ?? config.app.default_preset }}</span> <span class="min-w-0 flex-1">
</span> <span :class="['min-w-0 capitalize', expandClass(item.id, 'preset')]">
Preset: {{ item.preset ?? config.app.default_preset }}
</span>
</span>
</button>
</div> </div>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div class="flex items-start gap-2 text-sm"> <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 <UIcon
:name=" :name="
item.timer item.timer
@ -451,88 +482,120 @@
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Schedule</div>
<template v-if="item.timer"> <template v-if="item.timer">
<a <a
target="_blank" target="_blank"
:href="`https://crontab.guru/#${item.timer.replace(/ /g, '_')}`" :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> </a>
<p
class="mt-1 text-xs"
:class="tryParse(item.timer) === 'Invalid' ? 'text-error' : 'text-toned'"
>
{{ tryParse(item.timer) }}
</p>
</template> </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 Handler only
</p> </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>
<div v-if="item.folder" class="rounded-md border border-default bg-muted/20 px-3 py-2"> <button
<div class="flex items-start gap-2"> 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" /> <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 class="min-w-0 flex-1">
</div> <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>
<div <div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
v-if="item.template" <button
class="rounded-md border border-default bg-muted/20 px-3 py-2" v-if="item.template"
> type="button"
<div class="flex items-start gap-2"> :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" /> <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 class="min-w-0 flex-1">
</div> <div class="text-xs font-medium text-toned">Output template</div>
</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"> <button
<div class="flex items-start gap-2"> 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" /> <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 class="min-w-0 flex-1">
</div> <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> </div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1"> <template #footer>
<UButton <div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
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">
<UButton <UButton
color="neutral" color="neutral"
variant="outline" variant="outline"
icon="i-lucide-settings-2" icon="i-lucide-pencil"
trailing-icon="i-lucide-chevron-down"
class="w-full justify-center" class="w-full justify-center"
@click="editItem(item)"
> >
Actions Edit
</UButton> </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> </UCard>
</div> </div>
</div> </div>
@ -554,10 +617,6 @@
title="No Results" title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`" :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> </div>
<UAlert <UAlert
@ -603,11 +662,15 @@
<UModal <UModal
v-if="toggleForm" v-if="toggleForm"
:open="toggleForm" :open="toggleForm"
:title="editorTitle" :title="taskRef ? `Edit - ${task.name || 'Task'}` : 'Add new task'"
:description="editorDescription" :description="
taskRef
? 'Update the settings of your automated download task'
: 'Create an automated download task'
"
:dismissible="!addInProgress" :dismissible="!addInProgress"
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }" :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> <template #body>
<TaskForm <TaskForm
@ -615,7 +678,8 @@
:addInProgress="addInProgress" :addInProgress="addInProgress"
:reference="taskRef" :reference="taskRef"
:task="task as Task" :task="task as Task"
@cancel="closeEditor" @cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem" @submit="updateItem"
/> />
</template> </template>
@ -642,6 +706,7 @@ import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useTasks } from '~/composables/useTasks'; import { useTasks } from '~/composables/useTasks';
import TaskInspect from '~/components/TaskInspect.vue'; import TaskInspect from '~/components/TaskInspect.vue';
import type { ExportedTask, Task } from '~/types/tasks'; import type { ExportedTask, Task } from '~/types/tasks';
@ -649,16 +714,19 @@ import type { WSEP } from '~/types/sockets';
import { sleep } from '~/utils'; import { sleep } from '~/utils';
import { useSessionCache } from '~/utils/cache'; import { useSessionCache } from '~/utils/cache';
import type { item_request } from '~/types/item'; import type { item_request } from '~/types/item';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm(); const box = useConfirm();
const toast = useNotification(); const toast = useNotification();
const config = useConfigStore(); const config = useYtpConfig();
const socket = useSocketStore(); const socket = useAppSocket();
const stateStore = useStateStore(); const stateStore = useQueueState();
const pageShell = requirePageShell('tasks');
const { confirmDialog } = useDialog(); const { confirmDialog } = useDialog();
const sessionCache = useSessionCache(); const sessionCache = useSessionCache();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<'list' | 'grid' | 'cards'>('tasks_display_style', 'grid'); 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 tasksComposable = useTasks();
const { const {
@ -686,34 +754,43 @@ const createEmptyTask = (): Partial<Task> => ({
const task = ref<Partial<Task>>(createEmptyTask()); const task = ref<Partial<Task>>(createEmptyTask());
const taskRef = ref<number | null>(null); const taskRef = ref<number | null>(null);
const toggleForm = ref(false); const toggleForm = ref(false);
const editorDirty = ref(false);
const selectedElms = ref<number[]>([]); const selectedElms = ref<number[]>([]);
const massRun = ref(false); const massRun = ref(false);
const massDelete = ref(false); const massDelete = ref(false);
const inspectTask = ref<Task | null>(null); const inspectTask = ref<Task | null>(null);
const query = ref(''); const query = ref('');
const showFilter = ref(false); 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 CACHE_KEY = 'tasks:handler_support';
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {}); const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {});
const displayStyle = computed<'list' | 'grid'>(() => const displayStyle = computed<'list' | 'grid'>(() =>
display_style.value === 'list' ? 'list' : 'grid', display_style.value === 'list' ? 'list' : 'grid',
); );
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const editorSessionId = ref(0); 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 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 filteredTasks = computed(() => {
const normalizedQuery = query.value?.toLowerCase(); const normalizedQuery = query.value?.toLowerCase();
if (!normalizedQuery) { if (!normalizedQuery) {
@ -792,7 +869,7 @@ const toggleFilterPanel = async (): Promise<void> => {
} }
await nextTick(); await nextTick();
filterInput.value?.focus(); filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
}; };
const toggleMasterSelection = (): void => { const toggleMasterSelection = (): void => {
@ -882,6 +959,7 @@ const reloadContent = async (fromMounted: boolean = false) => {
const resetForm = (closeForm: boolean = false) => { const resetForm = (closeForm: boolean = false) => {
task.value = createEmptyTask(); task.value = createEmptyTask();
taskRef.value = null; taskRef.value = null;
editorDirty.value = false;
if (closeForm) { if (closeForm) {
toggleForm.value = false; toggleForm.value = false;
@ -906,15 +984,16 @@ const deleteSelected = async () => {
const { status } = await confirmDialog({ const { status } = await confirmDialog({
title: 'Delete Selected Tasks', title: 'Delete Selected Tasks',
rawHTML: message:
`Delete <strong class="text-red-500">${selectedElms.value.length}</strong> task/s?<ul>` + `Delete ${selectedElms.value.length} task/s?` +
'\n\n' +
selectedElms.value selectedElms.value
.map((id) => { .map((id) => {
const item = tasks.value.find((task) => task.id === 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('') + .filter(Boolean)
'</ul>', .join('\n'),
confirmText: 'Delete', confirmText: 'Delete',
confirmColor: 'error', confirmColor: 'error',
}); });
@ -956,34 +1035,23 @@ const deleteItem = async (item: Task) => {
await tasksComposable.deleteTask(item.id); await tasksComposable.deleteTask(item.id);
}; };
const toggleEnabled = async (item: Task) => { const toggleFlag = async (item: Task, field: 'enabled' | 'auto_start' | 'handler_enabled') => {
if (!item.id) { if (!item.id) {
toast.error('Task ID is missing'); toast.error('Task ID is missing');
return; 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) { if (updated) {
item.enabled = updated.enabled; item[field] = updated[field];
if (updated.enabled) {
if (field === 'enabled' && updated.enabled) {
await checkHandlerSupport(updated); await checkHandlerSupport(updated);
} }
}
};
const toggleHandlerEnabled = async (item: Task) => { if (field === 'handler_enabled' && updated.handler_enabled) {
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) {
await checkHandlerSupport(updated); await checkHandlerSupport(updated);
} }
} }
@ -1036,6 +1104,7 @@ const updateItem = async ({
}; };
const editItem = (item: Task) => { const editItem = (item: Task) => {
editorDirty.value = false;
task.value = { ...item }; task.value = { ...item };
taskRef.value = item.id ?? null; taskRef.value = item.id ?? null;
editorSessionId.value += 1; editorSessionId.value += 1;
@ -1067,15 +1136,16 @@ const runSelected = async () => {
} }
const { status } = await confirmDialog({ const { status } = await confirmDialog({
rawHTML: message:
'Run the following tasks?<ul>' + 'Run the following tasks?' +
'\n\n' +
selectedElms.value selectedElms.value
.map((id) => { .map((id) => {
const item = tasks.value.find((task) => task.id === id); const item = tasks.value.find((task) => task.id === id);
return item ? `<li>${item.name}</li>` : ''; return item ? item.name : '';
}) })
.join('') + .filter(Boolean)
'</ul>', .join('\n'),
}); });
if (true !== status) { if (true !== status) {
@ -1262,24 +1332,13 @@ const generateMeta = async (item: Task) => {
try { try {
const { status } = await confirmDialog({ const { status } = await confirmDialog({
rawHTML: ` message:
<p> `Generate '${item.name}' metadata? You will be notified when it is done.` +
Generate '${item.name}' metadata? you will be notified when it is done. '\n\nThis action will generate:' +
</p> '\n- tvshow.nfo - for media center compatibility' +
<p> '\n- title [id].info.json - yt-dlp metadata file' +
<b>This action will generate:</b> '\n- Thumbnails: poster.jpg, fanart.jpg, thumb.jpg, banner.jpg, icon.jpg, landscape.jpg if they are available.' +
<ul> '\n\nWarning: This will overwrite existing metadata files if they exist.',
<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>`,
}); });
if (true !== status) { if (true !== status) {
@ -1302,7 +1361,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
{ {
label: 'Run now', label: 'Run now',
icon: 'i-lucide-square-play', icon: 'i-lucide-square-play',
color: 'primary',
onSelect: () => void runNow(item), onSelect: () => void runNow(item),
}, },
{ {
@ -1336,7 +1394,6 @@ const itemActionGroups = (item: Task): DropdownMenuItem[][] => [
{ {
label: 'Export Task', label: 'Export Task',
icon: 'i-lucide-file-up', icon: 'i-lucide-file-up',
color: 'info',
onSelect: () => void exportItem(item), onSelect: () => void exportItem(item),
}, },
], ],

View file

@ -1,6 +1,7 @@
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity, syncOpacity } from '~/utils';
const OVERLAY_SELECTOR = '[data-slot="overlay"]'; const OVERLAY_SELECTOR = '[data-slot="overlay"]';
const SETTINGS_PANEL_SELECTOR = '.yt-settings-panel';
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
if (import.meta.server) { if (import.meta.server) {
@ -11,7 +12,19 @@ export default defineNuxtPlugin(() => {
let isLocked = false; let isLocked = false;
const syncOverlayOpacity = (): void => { 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) { if (hasOverlay && !isLocked) {
disableOpacity(); disableOpacity();
@ -19,6 +32,11 @@ export default defineNuxtPlugin(() => {
return; return;
} }
if (hasOverlay) {
syncOpacity();
return;
}
if (!hasOverlay && isLocked) { if (!hasOverlay && isLocked) {
enableOpacity(); enableOpacity();
isLocked = false; isLocked = false;
@ -40,18 +58,4 @@ export default defineNuxtPlugin(() => {
} else { } else {
startObserver(); startObserver();
} }
window.addEventListener(
'beforeunload',
() => {
observer?.disconnect();
observer = null;
if (isLocked) {
enableOpacity();
isLocked = false;
}
},
{ once: true },
);
}); });

View file

@ -1,138 +1,206 @@
<!doctype html> <!doctype html>
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body=""> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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> <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, html,
body {
min-height: 100%;
}
body { body {
margin: 0; margin: 0;
padding: 0; min-height: 100vh;
height: 100%; min-height: 100dvh;
display: flex; padding: 1rem;
align-items: center; display: grid;
justify-content: center; place-items: center;
background-color: #fff;
color: #111;
user-select: none;
overflow: hidden; overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: var(--text);
transition: user-select: none;
background-color 0.3s ease, font-family:
color 0.3s ease; system-ui,
} -apple-system,
@media (prefers-color-scheme: dark) { BlinkMacSystemFont,
html, 'Segoe UI',
body { sans-serif;
background-color: #121212; background:
color: #eee; radial-gradient(circle at top left, var(--accent-soft), transparent 24%),
} linear-gradient(180deg, var(--bg-deep), var(--bg));
} }
.loader-container { body::before {
text-align: center; 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; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 0.95rem;
} }
svg.ytptube-loading { .brand img {
width: 420px; width: 2.8rem;
height: 105px; height: 2.8rem;
stroke: #ff0000; flex: none;
stroke-width: 4.5px; display: block;
stroke-linecap: round; object-fit: contain;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
} }
@media (prefers-color-scheme: dark) { .brand-copy {
svg.ytptube-loading { min-width: 0;
stroke: #ff4444; }
.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% { 100% {
stroke-dashoffset: -700; transform: translateX(330%);
} }
} }
.spinner { @media (max-width: 560px) {
margin: 20px auto 0; .loading-card {
width: 64px; padding: 1.3rem;
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 (prefers-color-scheme: dark) { .brand {
.spinner { align-items: flex-start;
border-top-color: #ff4444; }
filter: drop-shadow(0 0 8px #ff4444);
h1 {
font-size: 1.35rem;
} }
} }
@keyframes spin { @media (prefers-reduced-motion: reduce) {
to { *,
transform: rotate(360deg); *::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> </style>
</head> </head>
<body> <body>
<div class="loader-container" role="img" aria-label="Loading YTPTube"> <main class="loading-card" role="status" aria-live="polite" aria-busy="true">
<svg <div class="brand">
class="ytptube-loading" <img src="/images/favicon.png" alt="" aria-hidden="true" />
viewBox="0 0 420 105"
xmlns="http://www.w3.org/2000/svg" <div class="brand-copy">
aria-hidden="true" <p class="label">YTPTube</p>
focusable="false" <h1>Loading your dashboard</h1>
> </div>
<!-- Y --> </div>
<path d="M10 10 L25 37.5 L10 65" />
<path d="M25 37.5 L40 10" /> <p class="description">Getting the interface ready. Please wait a moment...</p>
<!-- T -->
<path d="M50 10 L90 10" /> <div class="loader" aria-hidden="true"></div>
<path d="M70 10 L70 65" /> <p class="hint">Please wait...</p>
<!-- P --> </main>
<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>
</body> </body>
</html> </html>

View file

@ -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;
};
});

View file

@ -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,
};
});

View file

@ -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>>,
};
});

View file

@ -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,
};
});

View file

@ -142,45 +142,6 @@ const dEvent = (eventName: string, detail: Record<string, any> = {}): boolean =>
return window.dispatchEvent(new CustomEvent(eventName, { detail })); 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. * Safely encode a path string for use in a URL.
* *
@ -742,10 +703,33 @@ const setBodyOpacity = (value: string): boolean => {
return false; return false;
} }
body.setAttribute('style', `opacity: ${value}`); body.style.opacity = value;
return true; 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 => { const disableOpacity = (): boolean => {
if (!getStorageValue<boolean>('random_bg', true, false)) { if (!getStorageValue<boolean>('random_bg', true, false)) {
opacityLockCount = 0; opacityLockCount = 0;
@ -763,11 +747,7 @@ const enableOpacity = (): boolean => {
} }
opacityLockCount = Math.max(0, opacityLockCount - 1); opacityLockCount = Math.max(0, opacityLockCount - 1);
if (opacityLockCount > 0) { return syncOpacity();
return setBodyOpacity('1.0');
}
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
}; };
const stripPath = (base_path: string, real_path: string): string => { const stripPath = (base_path: string, real_path: string): string => {
@ -1008,7 +988,6 @@ export {
r, r,
copyText, copyText,
dEvent, dEvent,
makePagination,
encodePath, encodePath,
request, request,
removeANSIColors, removeANSIColors,
@ -1028,6 +1007,7 @@ export {
awaiter, awaiter,
encode, encode,
decode, decode,
syncOpacity,
disableOpacity, disableOpacity,
enableOpacity, enableOpacity,
stripPath, stripPath,

View 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;
};

View file

@ -5,41 +5,39 @@
"": { "": {
"name": "nuxt-app", "name": "nuxt-app",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "latest", "@iconify-json/lucide": "^1.2.102",
"@microsoft/fetch-event-source": "latest", "@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "latest", "@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "latest", "@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "latest", "@nuxt/ui": "^4.6.1",
"@pinia/nuxt": "latest", "@vueuse/core": "^14.2.1",
"@vueuse/core": "latest", "@vueuse/nuxt": "^14.2.1",
"@vueuse/nuxt": "latest", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-fit": "latest", "@xterm/xterm": "^6.0.0",
"@xterm/xterm": "latest", "cron-parser": "^5.5.0",
"cron-parser": "latest", "cronstrue": "^3.14.0",
"cronstrue": "latest", "hls.js": "^1.6.16",
"hls.js": "latest", "marked": "^18.0.0",
"marked": "latest", "marked-alert": "^2.1.2",
"marked-alert": "latest", "marked-base-url": "^1.1.9",
"marked-base-url": "latest", "marked-gfm-heading-id": "^4.1.4",
"marked-gfm-heading-id": "latest", "moment": "^2.30.1",
"moment": "latest", "nuxt": "^4.4.2",
"nuxt": "latest", "tailwindcss": "^4.2.2",
"pinia": "latest", "vue": "^3.5.32",
"tailwindcss": "latest", "vue-router": "^5.0.4",
"vue": "latest",
"vue-router": "latest",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "^1.3.12",
"@types/jsdom": "latest", "@types/jsdom": "^28.0.1",
"@types/node": "latest", "@types/node": "25.6.0",
"@typescript-eslint/parser": "latest", "@typescript-eslint/parser": "^8.58.2",
"eslint": "latest", "eslint": "^10.2.0",
"jsdom": "latest", "jsdom": "^29.0.2",
"oxfmt": "latest", "oxfmt": "^0.45.0",
"typescript": "latest", "typescript": "^6.0.2",
"vue-eslint-parser": "latest", "vue-eslint-parser": "^10.4.0",
"vue-tsc": "latest", "vue-tsc": "^3.2.6",
}, },
}, },
}, },
@ -240,7 +238,7 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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/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/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/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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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/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=="], "@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/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=="], "@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=="], "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/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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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/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/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/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=="], "@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=="], "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=="], "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=="], "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=="], "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/@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=="], "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=="], "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=="], "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-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=="], "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=="], "@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/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/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=="], "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=="], "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=="], "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=="], "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=="], "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/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=="], "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/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-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=="], "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=="],

View file

@ -15,8 +15,10 @@ try {
} }
} catch {} } catch {}
const isProd = 'production' === process.env.NODE_ENV;
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: false, ssr: false,
sourcemap: false === isProd,
devtools: { enabled: true }, devtools: { enabled: true },
devServer: { devServer: {
port: 8082, port: 8082,
@ -57,14 +59,22 @@ export default defineNuxtConfig({
}, },
pageTransition: { name: 'page' }, pageTransition: { name: 'page' },
}, },
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'], modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'],
icon: { 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: { nitro: {
sourceMap: false === isProd,
output: { output: {
publicDir: publicDir: isProd ? __dirname + '/exported' : __dirname + '/dist',
'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
}, },
...extraNitro, ...extraNitro,
}, },
@ -87,6 +97,15 @@ export default defineNuxtConfig({
}, },
build: { build: {
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 2000,
rollupOptions: {
onwarn(warning, warn) {
if ('SOURCEMAP_BROKEN' === warning.code) {
return;
}
warn(warning);
},
},
}, },
}, },
telemetry: false, telemetry: false,

View file

@ -21,26 +21,24 @@
}, },
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.101", "@iconify-json/lucide": "^1.2.102",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "^1.15.2", "@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2", "@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.6.1", "@nuxt/ui": "^4.6.1",
"@pinia/nuxt": "^0.11.3",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1", "@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"cronstrue": "^3.14.0", "cronstrue": "^3.14.0",
"hls.js": "^1.6.15", "hls.js": "^1.6.16",
"marked": "^18.0.0", "marked": "^18.0.0",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-base-url": "^1.1.9", "marked-base-url": "^1.1.9",
"marked-gfm-heading-id": "^4.1.4", "marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-router": "^5.0.4" "vue-router": "^5.0.4"
@ -50,13 +48,13 @@
"@parcel/watcher" "@parcel/watcher"
], ],
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.11", "@types/bun": "^1.3.12",
"@types/node": "25.5.2", "@types/node": "25.6.0",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@typescript-eslint/parser": "^8.58.1", "@typescript-eslint/parser": "^8.58.2",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"oxfmt": "^0.44.0", "oxfmt": "^0.45.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vue-eslint-parser": "^10.4.0", "vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6" "vue-tsc": "^3.2.6"

View file

@ -133,43 +133,6 @@ describe('useConditions', () => {
requestSpy.mockRestore() 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', () => { describe('getCondition', () => {
@ -191,23 +154,6 @@ describe('useConditions', () => {
requestSpy.mockRestore() 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', () => { describe('createCondition', () => {
@ -230,61 +176,10 @@ describe('useConditions', () => {
expect(result).toEqual(mockCondition) expect(result).toEqual(mockCondition)
expect(conditions.conditions.value).toContainEqual(mockCondition) expect(conditions.conditions.value).toContainEqual(mockCondition)
expect(conditions.pagination.value.total).toBe(initialTotal + 1) expect(conditions.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
requestSpy.mockRestore() 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', () => { describe('updateCondition', () => {
@ -307,31 +202,6 @@ describe('useConditions', () => {
requestSpy.mockRestore() 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 () => { it('removes id field from condition before sending', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
@ -369,54 +239,6 @@ describe('useConditions', () => {
requestSpy.mockRestore() 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', () => { describe('deleteCondition', () => {
@ -440,54 +262,6 @@ describe('useConditions', () => {
requestSpy.mockRestore() 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', () => { 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()
})
})
}) })

View 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);
});
});

View file

@ -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' 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 => ({ const buildPreset = (name: string, isDefault: boolean): Preset => ({
name, name,
default: isDefault, default: isDefault,
@ -15,12 +31,12 @@ const buildPreset = (name: string, isDefault: boolean): Preset => ({
}) })
const setConfigStore = (presets: Preset[]) => { const setConfigStore = (presets: Preset[]) => {
;(globalThis as any).useConfigStore = () => ({ configState = {
presets, presets,
app: { app: {
download_path: '/downloads', download_path: '/downloads',
}, },
}) }
} }
describe('usePresetOptions', () => { describe('usePresetOptions', () => {

View file

@ -136,43 +136,6 @@ describe('usePresets', () => {
requestSpy.mockRestore() 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', () => { describe('getPreset', () => {
@ -194,23 +157,6 @@ describe('usePresets', () => {
requestSpy.mockRestore() 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', () => { describe('createPreset', () => {
@ -236,67 +182,10 @@ describe('usePresets', () => {
expect(result).toEqual(mockPreset) expect(result).toEqual(mockPreset)
expect(presets.presets.value).toContainEqual(mockPreset) expect(presets.presets.value).toContainEqual(mockPreset)
expect(presets.pagination.value.total).toBe(initialTotal + 1) expect(presets.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
requestSpy.mockRestore() 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', () => { describe('updatePreset', () => {
@ -396,125 +285,6 @@ describe('usePresets', () => {
requestSpy.mockRestore() 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()
})
})
}) })

View file

@ -258,57 +258,6 @@ describe('useTasks', () => {
requestSpy.mockRestore() 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', () => { describe('updateTask', () => {
@ -331,31 +280,6 @@ describe('useTasks', () => {
requestSpy.mockRestore() 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 () => { it('removes id field from task before sending', async () => {
const requestSpy = spyOn(utils, 'request') const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
@ -394,54 +318,6 @@ describe('useTasks', () => {
requestSpy.mockRestore() 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', () => { describe('deleteTask', () => {
@ -465,54 +341,6 @@ describe('useTasks', () => {
requestSpy.mockRestore() 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', () => { describe('inspectTaskHandler', () => {

View 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();
});
});

View file

@ -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', () => { 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', () => { it('ag returns nested value or default value', () => {
const payload = { a: { b: { c: 42 } } }; const payload = { a: { b: { c: 42 } } };
expect(utils.ag(payload, 'a.b.c')).toBe(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'); 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', () => { it('encodePath safely encodes components', () => {
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4'); 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'); 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', () => { it('basename returns final segment optionally trimming extension', () => {
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4'); expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video'); 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(90)).toBe('01:30');
expect(utils.formatTime(3661)).toBe('01:01:01'); 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', () => { describe('data conversion helpers', () => {
@ -358,15 +326,6 @@ describe('data conversion helpers', () => {
expect(utils.decode(encoded)).toEqual(payload); 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', () => { it('getQueryParams parses query strings', () => {
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' }); 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', () => { 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 () => { it('copyText uses clipboard API and notifies success', async () => {
utils.copyText('sample'); utils.copyText('sample');
@ -439,7 +375,7 @@ describe('dom and browser helpers', () => {
it('disableOpacity toggles body opacity when enabled', () => { it('disableOpacity toggles body opacity when enabled', () => {
const result = utils.disableOpacity(); const result = utils.disableOpacity();
expect(result).toBe(true); 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', () => { it('disableOpacity returns false when background disabled', () => {
@ -474,7 +410,27 @@ describe('dom and browser helpers', () => {
storageMap.set('random_bg_opacity', { value: 0.75 }); storageMap.set('random_bg_opacity', { value: 0.75 });
const result = utils.enableOpacity(); const result = utils.enableOpacity();
expect(result).toBe(true); 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'); 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', () => { describe('async helpers', () => {

View file

@ -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
View file

@ -738,64 +738,64 @@ wheels = [
[[package]] [[package]]
name = "lxml" name = "lxml"
version = "6.0.3" version = "6.1.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/d9/80/de3d3a790edf6d026c829fe8ccf54845058f57f8bb788e420c3b227eecef/lxml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:c8e3b8a54e65393ce1d5c7d9753fe756f0d96089e7163b20ddec3e5bb56a963e", size = 3596004 }, { 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/9f/cf/43c9a5926060e39d99593921f37d7e88f129bc32ab6266b8460483abd613/lxml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:724b26a38cef98d6869d00a33cb66083bee967598e44f6a8e53f1dd283c851b0", size = 3994750 }, { 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/e5/d3/b224dbc282bfef52d2e05645e405b5ed89c6391144dc09864229fe9ce88c/lxml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:f27373113fda6621e4201f529908a24c8a190c2af355aed4711dadca44db4673", size = 3657620 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/06/0e/bd1157d7b09d1f5e1d580c124203cee656130a3f8908365760a593b21daf/lxml-6.0.3-cp314-cp314-win32.whl", hash = "sha256:1b60a3a1205f869bd47874787c792087174453b1a869db4837bf5b3ff92be017", size = 3656378 }, { 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/c5/cc/d50cbce8cd5687670868bea33bbeefa0866c5e5d02c5e11c4a04c79fc45e/lxml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:5b6913a68d98c58c673667c864500ba31bc9b0f462effac98914e9a92ebacd2e", size = 4062518 }, { 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/fd/c7/ece11a1e51390502894838aa384e9f98af7bef4d6806a927197153a16972/lxml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:1b36a3c73f2a6d9c2bfae78089ca7aedae5c2ee5fd5214a15f00b2f89e558ba7", size = 3741064 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206 },
{ 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/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/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/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/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/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/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/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/66/68/fcdbb78c8cda81a86e17b31abf103b7e474e474a09fb291a99e7a9b43eb8/lxml-6.0.3-cp314-cp314t-win32.whl", hash = "sha256:bf98f5f87f6484302e7cce4e2ca5af43562902852063d916c3e2f1c115fdce60", size = 3896249 }, { 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/88/fb/6292681ac4a4223b700569ce98f71662cb07c5a3ade4f346f5f0d5c574cf/lxml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d3d65e511e4e656ec67b472110f7a72cbf8547ca15f76fe74cffa4e97412a064", size = 4391091 }, { 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/99/39/a0f486360a6f1b36fd2f5eb62d037652bef503d82b6f853aee6664cdfcac/lxml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:cbc7ce67f85b92db97c92219985432be84dc1ba9a028e68c6933e89551234df2", size = 3816374 }, { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120 },
] ]
[[package]] [[package]]
@ -892,11 +892,11 @@ wheels = [
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1110,7 +1110,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.13.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
@ -1118,62 +1118,65 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { 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 = [ 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]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.41.5" version = "2.46.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, { 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/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, { 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/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, { 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/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/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, { 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/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, { 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/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, { 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/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/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867 },
{ 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/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, { 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/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, { 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/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, { 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]] [[package]]
@ -1635,27 +1638,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.10" version = "0.15.11"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943 },
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964 }, { 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/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044 }, { 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/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607 }, { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614 },
] ]
[[package]] [[package]]