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
![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)
![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
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.Events import EventBus, Events
from app.library.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__)
@ -83,6 +84,11 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
if not (cond := params.get("condition")):
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
try:
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
try:
preset: str = params.get("preset", config.default_preset)
key: str = cache.hash(url + str(preset))

View file

@ -4,8 +4,14 @@ from __future__ import annotations
import pytest
import pytest_asyncio
from types import SimpleNamespace
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from app.features.conditions.models import ConditionModel
from app.features.conditions.router import conditions_test
from app.library.config import Config
from app.library.encoder import Encoder
from app.features.conditions.repository import ConditionsRepository
from app.library.sqlite_store import SqliteStore
@ -34,28 +40,43 @@ async def repo():
SqliteStore._reset_singleton()
def _json_request(path: str, payload: object) -> web.Request:
request = make_mocked_request("POST", path)
async def _json() -> object:
return payload
request.json = _json # type: ignore[attr-defined]
return request
class TestAllowInternalUrlsScope:
def setup_method(self) -> None:
Config._reset_singleton()
@pytest.mark.asyncio
async def test_conditions_test_rejects_internal_url_when_disallowed(self) -> None:
config = Config.get_instance()
config.allow_internal_urls = False
encoder = Encoder()
cache = SimpleNamespace(hash=lambda value: value, has=lambda _key: False, set=lambda **_kwargs: None)
request = _json_request(
"/api/conditions/test/",
{"url": "http://127.0.0.1/test", "condition": "title", "preset": config.default_preset},
)
response = await conditions_test(request, encoder, cache, config)
assert response.status == web.HTTPBadRequest.status_code
body = response.body
assert isinstance(body, bytes)
assert b"internal urls" in body.lower()
class TestConditionsRepository:
"""Test suite for ConditionsRepository database operations."""
@pytest.mark.asyncio
async def test_repository_singleton(self, repo):
"""Verify repository follows singleton pattern."""
instance1 = ConditionsRepository.get_instance()
instance2 = ConditionsRepository.get_instance()
assert instance1 is instance2, "Should return same singleton instance"
@pytest.mark.asyncio
async def test_list_empty(self, repo):
"""List returns empty when no conditions exist."""
conditions = await repo.list()
assert conditions == [], "Should return empty list when no conditions"
@pytest.mark.asyncio
async def test_count_empty(self, repo):
"""Count returns 0 when no conditions exist."""
count = await repo.count()
assert count == 0, "Should return 0 when no conditions exist"
@pytest.mark.asyncio
async def test_create_condition(self, repo):
"""Create condition with valid data."""

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import pytest
import pytest_asyncio
from app.features.dl_fields.models import DLFieldModel
from app.features.dl_fields.repository import DLFieldsRepository
from app.library.sqlite_store import SqliteStore
@ -35,25 +34,6 @@ async def repo(tmp_path):
class TestDLFieldsRepository:
"""Test suite for DLFieldsRepository database operations."""
@pytest.mark.asyncio
async def test_repository_singleton(self, repo):
"""Verify repository follows singleton pattern."""
instance1 = DLFieldsRepository.get_instance()
instance2 = DLFieldsRepository.get_instance()
assert instance1 is instance2, "Should return same singleton instance"
@pytest.mark.asyncio
async def test_list_empty(self, repo):
"""List returns empty when no fields exist."""
fields = await repo.list()
assert fields == [], "Should return empty list when no fields"
@pytest.mark.asyncio
async def test_count_empty(self, repo):
"""Count returns 0 when no fields exist."""
count = await repo.count()
assert count == 0, "Should return 0 when no fields exist"
@pytest.mark.asyncio
async def test_create_field(self, repo):
"""Create field with valid data."""

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import pytest
import pytest_asyncio
from app.features.tasks.models import TaskModel
from app.features.tasks.repository import TasksRepository
from app.library.sqlite_store import SqliteStore
@ -35,25 +34,6 @@ async def repo(tmp_path):
class TestTasksRepository:
"""Test suite for TasksRepository database operations."""
@pytest.mark.asyncio
async def test_repository_singleton(self, repo):
"""Verify repository follows singleton pattern."""
instance1 = TasksRepository.get_instance()
instance2 = TasksRepository.get_instance()
assert instance1 is instance2, "Should return same singleton instance"
@pytest.mark.asyncio
async def test_list_empty(self, repo):
"""List returns empty when no tasks exist."""
tasks = await repo.list()
assert tasks == [], "Should return empty list when no tasks"
@pytest.mark.asyncio
async def test_count_empty(self, repo):
"""Count returns 0 when no tasks exist."""
count = await repo.count()
assert count == 0, "Should return 0 when no tasks exist"
@pytest.mark.asyncio
async def test_create_task(self, repo):
"""Create task with valid data."""

View file

@ -107,10 +107,10 @@ class HttpAPI:
registered_options: list = []
base_path: str = self.config.base_path.rstrip("/")
from app.routes.api._static import preload_static
from app.routes.api._static import setup_static_routes
load_modules(self.rootPath, self.rootPath / "routes" / "api")
preload_static(self.rootPath, self.config)
setup_static_routes(self.rootPath, self.config)
async def options_handler(_: Request) -> Response:
return web.Response(status=204)

View file

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

View file

@ -1,19 +1,18 @@
import logging
from pathlib import Path
from pathlib import Path, PurePosixPath
import magic
from aiohttp import web
from aiohttp.web import Request, Response
from aiohttp.web import Request, StreamResponse
from app.library.config import Config
from app.library.router import add_route
from app.library.Utils import get_file
MIME = magic.Magic(mime=True)
LOG: logging.Logger = logging.getLogger(__name__)
STATIC_FILES: dict[str, dict] = {}
EXT_TO_MIME: dict = {
EXT_TO_MIME: dict[str, str] = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
@ -23,120 +22,167 @@ EXT_TO_MIME: dict = {
".m4a": "audio/mp4",
}
FRONTEND_ROUTES: list[str] = [
"/console/",
"/presets/",
"/tasks/",
"/task_definitions/",
"/notifications/",
"/changelog/",
"/logs/",
"/conditions/",
"/browser/",
"/simple/",
"/browser/{path:.*}",
]
class StaticState:
def __init__(self) -> None:
self.root: Path | None = None
self.index_file: Path | None = None
async def serve_static_file(request: Request, config: Config) -> Response:
STATIC_STATE = StaticState()
def get_root(root_path: Path, config: Config) -> Path | None:
"""
Preload static files from the ui/exported folder.
Get the static root directory.
Args:
root_path (Path): The application root path.
config (Config): The configuration instance.
Returns:
Path | None: The static directory, or None when UI is ignored.
"""
search_paths: list[Path] = []
if config.static_ui_path:
search_paths.append(Path(config.static_ui_path).absolute())
search_paths.extend(
[
(root_path / "ui" / "exported").absolute(),
(root_path.parent / "ui" / "exported").absolute(),
]
)
for path in search_paths:
if path.is_dir():
return path
message: str = f"Could not find the frontend assets in '{[str(path) for path in search_paths]=}'."
if config.ignore_ui:
LOG.warning(message)
return None
raise ValueError(message)
def normalize_path(path: str, base_path: str) -> str:
"""
Normalize the request path by stripping the base path.
Args:
path (str): The raw request path.
base_path (str): The configured base path.
Returns:
str: The path relative to the static root.
"""
if "/" == base_path:
return path or "/"
base_prefix: str = base_path.rstrip("/")
if path == base_prefix:
return "/"
if path.startswith(f"{base_prefix}/"):
stripped: str = path[len(base_prefix) :]
return stripped or "/"
return path or "/"
def get_static_file(path: str) -> Path | None:
"""
Get the static file corresponding to a request path.
Args:
path (str): The normalized request path.
Returns:
Path | None: The resolved file path.
"""
if STATIC_STATE.root is None:
return None
relative_path: str = path.lstrip("/")
if not relative_path:
return STATIC_STATE.index_file if STATIC_STATE.index_file and STATIC_STATE.index_file.is_file() else None
file, status = get_file(STATIC_STATE.root, relative_path)
if web.HTTPOk.status_code == status and file.is_file():
return file
return None
async def serve_static_file(request: Request, config: Config) -> StreamResponse:
"""
Serve frontend static files with SPA fallback handling.
Args:
request (Request): The request object.
config (Config): The configuration instance.
Returns:
Response: The response object.
StreamResponse: The response object.
"""
path: str = request.path
path: str = normalize_path(request.path, config.base_path)
file_path: Path | None = get_static_file(path)
if "/" != config.base_path and path.startswith(config.base_path):
path = path.replace(config.base_path[:-1], "", 1)
if path not in STATIC_FILES:
for k in STATIC_FILES:
if path.startswith(k):
path = k
break
if file_path is None:
if (
STATIC_STATE.index_file is not None
and not path.startswith("/api/")
and not PurePosixPath(path.lstrip("/")).suffix
):
file_path = STATIC_STATE.index_file
else:
return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code)
item: dict = STATIC_FILES[path]
return web.FileResponse(
path=item["file"],
path=file_path,
headers={
"Pragma": "public",
"Cache-Control": "public, max-age=31536000",
"Content-Type": item.get("content_type"),
"Content-Type": EXT_TO_MIME.get(file_path.suffix, MIME.from_file(str(file_path))),
},
status=web.HTTPOk.status_code,
)
def preload_static(root_path: Path, config: Config) -> None:
def setup_static_routes(root_path: Path, config: Config) -> None:
"""
Preload static files from the ui/exported folder.
Set up routes for serving frontend static files.
Args:
root_path (Path): The root path of the application.
root_path (Path): The application root path.
config (Config): The configuration instance.
"""
global STATIC_FILES # noqa: PLW0602
static_dir: Path | None = None
webui_files: list[Path] = [
(root_path / "ui" / "exported").absolute(),
(root_path.parent / "ui" / "exported").absolute(),
]
STATIC_STATE.root = get_root(root_path, config)
STATIC_STATE.index_file = None
if config.static_ui_path:
webui_files = [Path(config.static_ui_path).absolute()]
if STATIC_STATE.root is None:
return
for p in webui_files:
if p.exists():
static_dir = p
break
index_file: Path = (STATIC_STATE.root / "index.html").resolve()
if not index_file.is_file():
message: str = f"Failed to find frontend entry file at '{index_file}'."
if config.ignore_ui:
LOG.warning(message)
STATIC_STATE.root = None
return
if static_dir is None:
webui_files = [str(p) for p in webui_files]
msg: str = f"Could not find the frontend UI static assets in '{webui_files=}'."
raise ValueError(msg)
raise ValueError(message)
preloaded = 0
STATIC_STATE.index_file = index_file
for file in static_dir.rglob("*.*"):
if ".map" == file.suffix:
continue
uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}"
contentType = EXT_TO_MIME.get(file.suffix)
if not contentType:
contentType = MIME.from_file(str(file))
STATIC_FILES[uri_path] = {
"uri": uri_path,
"content_type": contentType,
"file": file,
}
add_route(method="GET", path=uri_path, handler=serve_static_file)
preloaded += 1
if "/index.html" in STATIC_FILES:
for path in FRONTEND_ROUTES:
STATIC_FILES[path] = STATIC_FILES["/index.html"]
STATIC_FILES[path.lstrip("/")] = STATIC_FILES["/index.html"]
add_route(method="GET", path=path, handler=serve_static_file)
LOG.debug(f"Preloading frontend static route '{path}'.")
preloaded += 1
# Add main app route
STATIC_FILES["/"] = STATIC_FILES["/index.html"]
STATIC_FILES[config.base_path] = STATIC_FILES["/index.html"]
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index")
add_route(method="GET", path="/{path:.*}", handler=serve_static_file, name="static_fallback")
if "/" != config.base_path:
@ -145,12 +191,4 @@ def preload_static(root_path: Path, config: Config) -> None:
add_route(method="GET", path="/", handler=redirect_index, name="index_redirect")
if preloaded < 1:
message: str = f"Failed to find any static files in '{static_dir}'."
if config.ignore_ui:
LOG.warning(message)
return
raise ValueError(message)
LOG.info(f"Loaded '{preloaded}' static files.")
LOG.info(f"Serving frontend static assets from '{STATIC_STATE.root}'.")

View file

@ -215,7 +215,11 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
page += 1
if not items_to_delete:
return web.json_response(data={"error": "No items matched the filter."}, status=web.HTTPBadRequest.status_code)
return web.json_response(
data={"items": {}, "deleted": 0},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
return web.json_response(
data={

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

View file

@ -1,5 +1,3 @@
"""Comprehensive tests for cf_solver_handler yt-dlp request handler."""
from __future__ import annotations
import http.cookiejar
@ -224,34 +222,3 @@ class TestCFSolverRH:
new_request = self.module.CFSolverRH._mark_retry(request)
assert new_request.extensions.get("cf_retry") is True
class TestCfSolverPreference:
"""Test cf_solver_preference function."""
def test_preference_with_flaresolverr(self, cf_handler_module, monkeypatch):
"""Test preference when FlareSolverr is configured."""
mock_config = Mock()
mock_config.flaresolverr_url = "http://localhost:8191/v1"
import app.library.config
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)
def test_preference_without_flaresolverr(self, cf_handler_module, monkeypatch):
"""Test preference when FlareSolverr is not configured."""
mock_config = Mock()
mock_config.flaresolverr_url = None
import app.library.config
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)
def test_preference_with_empty_flaresolverr(self, cf_handler_module, monkeypatch):
"""Test preference when FlareSolverr URL is empty."""
mock_config = Mock()
mock_config.flaresolverr_url = ""
import app.library.config
monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config)

View file

@ -1336,6 +1336,32 @@ class TestQueueManager:
item.close.assert_awaited_once()
assert status[item.info._id] == "ok", "Regular running cancel should still report success"
@pytest.mark.asyncio
async def test_clear_flushes_history_deletes_before_returning(self) -> None:
queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock()
item = Mock()
item.info = make_item(id="done-id", title="Finished clip")
item.info._id = "done-id"
item.info.status = "finished"
item.info.filename = "clip.mp4"
item.info.folder = ""
done_store = Mock()
done_store.get = AsyncMock(return_value=item)
done_store.delete = AsyncMock()
done_store._connection = Mock()
done_store._connection.flush = AsyncMock()
queue_manager.done = done_store
status = await DownloadQueue.clear(queue_manager, [item.info._id], remove_file=False)
done_store.delete.assert_awaited_once_with(item.info._id)
done_store._connection.flush.assert_awaited_once()
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
class TestPoolManager:
@pytest.mark.asyncio

View file

@ -8,11 +8,7 @@ from unittest.mock import Mock, patch
import pytest
from app.library.downloads.utils import (
BAD_LIVE_STREAM_OPTIONS,
DEBUG_MESSAGE_PREFIXES,
GENERIC_EXTRACTORS,
LIMITS,
YTDLP_PROGRESS_FIELDS,
create_debug_safe_dict,
get_extractor_limit,
handle_task_exception,
@ -24,26 +20,6 @@ from app.library.downloads.utils import (
)
class TestConstants:
def test_generic_extractors_tuple(self) -> None:
assert isinstance(GENERIC_EXTRACTORS, tuple), "Should be a tuple"
assert "HTML5MediaEmbed" in GENERIC_EXTRACTORS, "Should contain HTML5MediaEmbed"
assert "generic" in GENERIC_EXTRACTORS, "Should contain generic"
def test_ytdlp_progress_fields_tuple(self) -> None:
assert isinstance(YTDLP_PROGRESS_FIELDS, tuple), "Should be a tuple"
assert "status" in YTDLP_PROGRESS_FIELDS, "Should contain status field"
assert "downloaded_bytes" in YTDLP_PROGRESS_FIELDS, "Should contain downloaded_bytes field"
def test_bad_live_stream_options_list(self) -> None:
assert isinstance(BAD_LIVE_STREAM_OPTIONS, list), "Should be a list"
assert "concurrent_fragment_downloads" in BAD_LIVE_STREAM_OPTIONS, "Should contain concurrent option"
def test_debug_message_prefixes_list(self) -> None:
assert isinstance(DEBUG_MESSAGE_PREFIXES, list), "Should be a list"
assert "[debug] " in DEBUG_MESSAGE_PREFIXES, "Should contain debug prefix"
class TestPathUtilities:
def test_safe_relative_path_success(self) -> None:
base = Path("/downloads")

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import logging
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@ -13,16 +12,6 @@ class TestServices:
"""Clear services before each test."""
Services._reset_singleton()
def test_singleton_behavior(self):
"""Test that Services follows singleton pattern."""
service1 = Services()
service2 = Services()
service3 = Services.get_instance()
assert service1 is service2, "Multiple Services() calls should return same instance"
assert service1 is service3, "get_instance() should return same instance"
assert id(service1) == id(service2) == id(service3), "All references should point to same object"
def test_add_and_get_service(self):
"""Test adding and retrieving services."""
services = Services()
@ -264,29 +253,6 @@ class TestServices:
expected = "db:database, cache:redis, args:(), kwargs:{}"
assert result == expected
def test_service_types_preserved(self):
"""Test that different service types are preserved correctly."""
services = Services()
# Test various types
string_service = "string_value"
int_service = 42
list_service = [1, 2, 3]
dict_service = {"key": "value"}
custom_object = MagicMock()
services.add("string", string_service)
services.add("int", int_service)
services.add("list", list_service)
services.add("dict", dict_service)
services.add("object", custom_object)
assert services.get("string") == string_service
assert services.get("int") == int_service
assert services.get("list") == list_service
assert services.get("dict") == dict_service
assert services.get("object") is custom_object
def test_add_none_service(self):
"""Test adding None as a service value."""
services = Services()
@ -295,22 +261,6 @@ class TestServices:
assert services.has("none_service") is True
assert services.get("none_service") is None
def test_service_name_edge_cases(self):
"""Test edge cases for service names."""
services = Services()
# Empty string name
services.add("", "empty_name_value")
assert services.get("") == "empty_name_value"
# Numeric string name
services.add("123", "numeric_name")
assert services.get("123") == "numeric_name"
# Special characters in name
services.add("special-chars_123!@#", "special_value")
assert services.get("special-chars_123!@#") == "special_value"
def test_overwrite_existing_service(self):
"""Test overwriting an existing service."""
services = Services()
@ -319,22 +269,6 @@ class TestServices:
assert services.get("service") == "new_value"
def test_singleton_persistence_across_operations(self):
"""Test that singleton behavior persists across various operations."""
# Get instance and add a service
services1 = Services()
services1.add("persistent", "value")
# Get another instance and verify service exists
services2 = Services.get_instance()
assert services2.get("persistent") == "value"
# Clear from one instance
services1.clear()
# Verify cleared in other instance
assert services2.get("persistent") is None
def test_handler_exception_propagation(self):
"""Test that exceptions in handlers are properly propagated."""
services = Services()
@ -397,14 +331,6 @@ class TestServices:
result = services.handle_sync(lambda_handler)
assert result == "Lambda: lambda_value"
def test_logging_configuration(self):
"""Test that logging is properly configured."""
# This test verifies the module-level logger setup
from app.library.Services import LOG
assert isinstance(LOG, logging.Logger)
assert LOG.name == "app.library.Services"
def test_service_container_isolation(self):
"""Test that services don't interfere with each other."""
services = Services()
@ -421,27 +347,6 @@ class TestServices:
assert services.get("data") is None
assert services.get("data_backup")["type"] == "backup"
def test_large_number_of_services(self):
"""Test handling a large number of services."""
services = Services()
# Add many services
num_services = 1000
for i in range(num_services):
services.add(f"service_{i}", f"value_{i}")
# Verify all exist
assert len(services.get_all()) == num_services
# Verify specific services
assert services.get("service_0") == "value_0"
assert services.get("service_500") == "value_500"
assert services.get("service_999") == "value_999"
# Clear should work efficiently
services.clear()
assert len(services.get_all()) == 0
def test_add_all_overwrites_existing(self):
"""Test that add_all overwrites existing services."""
services = Services()
@ -465,20 +370,6 @@ class TestServices:
assert services.get("existing") == "value"
assert len(services.get_all()) == 1
def test_type_var_generic_behavior(self):
"""Test that TypeVar T is handled correctly."""
services = Services()
# Add different types and ensure they're returned correctly
services.add("string", "text")
services.add("number", 42)
services.add("boolean", True) # noqa: FBT003
# Type should be preserved (runtime check)
assert isinstance(services.get("string"), str)
assert isinstance(services.get("number"), int)
assert isinstance(services.get("boolean"), bool)
def test_concurrent_access_safety(self):
"""Test basic thread safety aspects of singleton."""
import threading
@ -504,47 +395,3 @@ class TestServices:
# All should be the same instance
assert len(set(results)) == 1, "All threads should get the same singleton instance"
def test_method_chaining_possibility(self):
"""Test that methods can be potentially chained."""
services = Services()
# While current implementation doesn't return self, test the pattern works
services.add("test1", "value1")
services.add("test2", "value2")
services.remove("test1")
assert services.get("test1") is None
assert services.get("test2") == "value2"
def test_edge_case_empty_handler_name(self):
"""Test handlers with minimal or no names."""
services = Services()
services.add("param", "value")
# Anonymous lambda
result = services.handle_sync(lambda param: f"anon: {param}")
assert result == "anon: value"
# Function with minimal signature info
def minimal(param):
return param
result = services.handle_sync(minimal)
assert result == "value"
def test_services_state_isolation(self):
"""Test that different Services instances share state properly."""
# This test verifies the singleton behavior more thoroughly
s1 = Services()
s1.add("shared", "data")
s2 = Services.get_instance()
assert s2.get("shared") == "data"
s2.add("another", "value")
assert s1.get("another") == "value"
# Clear from s1 affects s2
s1.clear()
assert len(s2.get_all()) == 0

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 mock_check.called, "Should have called check_for_updates"
@pytest.mark.asyncio
async def test_check_updates_returns_current_version(self):
"""Test check updates includes current version in response."""
config = Config.get_instance()
config.check_for_updates = True
config.app_version = "v1.2.3"
encoder = Encoder()
update_checker = UpdateChecker.get_instance()
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
mock_check.return_value = (("up_to_date", None), ("up_to_date", None))
response = await check_updates(config, encoder, update_checker)
assert 200 == response.status, "Should return 200"
body = response.body.decode("utf-8")
assert "1.2.3" in body, "Response should include current version"
@pytest.mark.asyncio
async def test_check_updates_update_available(self):
"""Test check updates returns update_available status with new version."""
@ -81,37 +64,3 @@ class TestCheckUpdatesEndpoint:
body = response.body.decode("utf-8")
assert "v1.0.5" in body, "Response should include new version"
assert "update_available" in body, "Response should include update_available status"
@pytest.mark.asyncio
async def test_check_updates_null_when_no_new_version(self):
"""Test check updates returns null for new_version when none available."""
config = Config.get_instance()
config.check_for_updates = True
config.app_version = "v1.0.0"
encoder = Encoder()
update_checker = UpdateChecker.get_instance()
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
mock_check.return_value = (("up_to_date", None), ("up_to_date", None))
response = await check_updates(config, encoder, update_checker)
assert 200 == response.status, "Should return 200"
body = response.body.decode("utf-8")
assert "null" in body.lower(), "Response should include null for new_version when not available"
@pytest.mark.asyncio
async def test_check_updates_error_status(self):
"""Test check updates handles error status correctly."""
config = Config.get_instance()
config.check_for_updates = True
config.app_version = "v1.0.0"
encoder = Encoder()
update_checker = UpdateChecker.get_instance()
with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check:
mock_check.return_value = (("error", None), ("error", None))
response = await check_updates(config, encoder, update_checker)
assert 200 == response.status, "Should return 200"
body = response.body.decode("utf-8")
assert "error" in body, "Response should include error status"

View file

@ -21,26 +21,6 @@ class TestUpdateChecker:
EventBus._reset_singleton()
Cache._reset_singleton()
def test_singleton_pattern(self):
"""Test that UpdateChecker follows singleton pattern."""
from app.library.UpdateChecker import UpdateChecker
instance1 = UpdateChecker.get_instance()
instance2 = UpdateChecker.get_instance()
assert instance1 is instance2, "Should return same instance"
def test_initialization_with_defaults(self):
"""Test UpdateChecker initializes with default config and scheduler."""
from app.library.UpdateChecker import UpdateChecker
checker = UpdateChecker.get_instance()
assert checker._config is not None, "Should have config instance"
assert checker._scheduler is not None, "Should have scheduler instance"
assert checker._notify is not None, "Should have EventBus instance"
assert checker._job_id is None, "Should have no job ID initially"
def test_attach_schedules_check_when_enabled(self):
"""Test that attach schedules update check when config.check_for_updates is True."""
import asyncio

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@
@after:enter="focusInput"
>
<template #body>
<p v-if="state.current?.opts.message">
<p v-if="state.current?.opts.message" class="whitespace-pre-line wrap-break-word">
{{ state.current?.opts.message }}
</p>
@ -23,14 +23,6 @@
/>
</UFormField>
<div
v-else-if="
'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML
"
class="max-h-[40vh] overflow-auto text-sm text-default"
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
/>
<div
v-if="
'confirm' === state.current?.type &&
@ -51,7 +43,7 @@
<template v-if="'alert' === state.current?.type">
<UButton
id="primaryButton"
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
:color="state.current?.opts.confirmColor ?? 'primary'"
@click="onEnter"
>
{{ state.current?.opts.confirmText ?? 'OK' }}
@ -61,7 +53,7 @@
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
<UButton
id="primaryButton"
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
:color="state.current?.opts.confirmColor ?? 'primary'"
:disabled="
'prompt' === state.current?.type &&
localInput === (state.current?.opts as PromptOptions)?.initial
@ -129,8 +121,6 @@ const focusInput = async () => {
requestAnimationFrame(focusPrimary);
};
const resolveConfirmColor = (color?: ConfirmOptions['confirmColor']) => color ?? 'primary';
const onCancel = () => cancel();
const onEnter = () =>
confirm('confirm' === state.current?.type ? selected.value : localInput.value);

File diff suppressed because it is too large Load diff

View file

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

View file

@ -202,6 +202,24 @@
border-radius: 0.9rem;
}
.docs-markdown img.docs-markdown__image--inline {
display: inline-block;
max-width: none;
vertical-align: middle;
border: 0;
border-radius: 0;
}
.docs-markdown img.docs-markdown__image--badge {
height: 1.25rem;
}
.docs-markdown img.docs-markdown__image--large {
width: auto;
min-width: min(100%, 24rem);
margin-inline: auto;
}
.markdown-alert {
--markdown-alert-accent: color-mix(in oklab, var(--ui-border-accented) 70%, transparent);
margin-top: 1rem;
@ -294,6 +312,18 @@ const content = ref('');
const error = ref('');
const isLoading = ref(true);
const isInlineMarkdownImage = (href: string): boolean => {
return /(?:badge\.svg|shields\.io|ghcr-badge|\.svg(?:[?#].*)?$)/i.test(href);
};
const getMarkdownImageClasses = (href: string): string[] => {
if (isInlineMarkdownImage(href)) {
return ['docs-markdown__image--inline', 'docs-markdown__image--badge'];
}
return ['docs-markdown__image--large'];
};
const createMarkdownParser = () => {
const parser = new Marked();
@ -370,7 +400,9 @@ const createMarkdownParser = () => {
const refPolicy = ' referrerpolicy="no-referrer"';
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
const loading = ' loading="lazy"';
return `<img src="${token.href || ''}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
const src = token.href || '';
const classes = getMarkdownImageClasses(src).join(' ');
return `<img src="${src}" class="${classes}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
},
},
});

View file

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,83 +1,17 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:disabled="isBusy"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide import' : 'Show import' }}
</UButton>
<div class="inline-flex rounded-md border border-default bg-muted/20 p-1">
<UButton
type="button"
size="sm"
icon="i-lucide-sliders-horizontal"
:color="mode === 'gui' ? 'primary' : 'neutral'"
:variant="mode === 'gui' ? 'solid' : 'ghost'"
:disabled="!guiSupported || isBusy"
@click="switchMode('gui')"
>
GUI
</UButton>
<UButton
type="button"
size="sm"
icon="i-lucide-code"
:color="mode === 'advanced' ? 'primary' : 'neutral'"
:variant="mode === 'advanced' ? 'solid' : 'ghost'"
:disabled="isBusy"
@click="switchMode('advanced')"
>
Advanced
</UButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<UButton
v-if="mode === 'advanced'"
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-wand-sparkles"
:disabled="isBusy"
@click="beautify"
>
Format
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-x"
:disabled="submitting"
@click="cancel"
>
Cancel
</UButton>
<UButton
type="button"
color="primary"
size="sm"
icon="i-lucide-save"
:loading="submitting"
:disabled="isBusy"
@click="submit"
>
Save
</UButton>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
type="button"
color="neutral"
variant="ghost"
size="sm"
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
:disabled="isBusy"
@click="showImport = !showImport"
>
{{ showImport ? 'Hide import' : 'Show import' }}
</UButton>
</div>
<div
@ -132,7 +66,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
icon="i-lucide-import"
class="justify-center sm:min-w-28"
:disabled="isBusy || !importString.trim()"
@ -219,7 +154,7 @@
<UFormField
class="md:col-span-3"
:ui="fieldUi"
description="Disabled definitions won't match tasks."
description="Whether this definition is active or not."
>
<template #label>
<div class="flex flex-wrap items-center gap-2">
@ -364,7 +299,7 @@
</div>
<div class="grid gap-4 md:grid-cols-12">
<UFormField class="md:col-span-4" :ui="fieldUi">
<UFormField class="md:col-span-4" :ui="fieldUi" description="Selector type">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-shapes" class="size-4 text-toned" />
@ -383,7 +318,7 @@
/>
</UFormField>
<UFormField class="md:col-span-8" :ui="fieldUi">
<UFormField class="md:col-span-8" :ui="fieldUi" description="Match expression">
<template #label>
<div class="flex flex-wrap items-center gap-2">
<UIcon name="i-lucide-crosshair" class="size-4 text-toned" />
@ -415,7 +350,8 @@
<UButton
type="button"
color="primary"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="isBusy"
@ -431,7 +367,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 table-fixed w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-40">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-key" class="size-3.5 text-toned" />
@ -473,7 +411,7 @@
<tr
v-for="(field, index) in guiState.fields"
:key="`${index}-${field.key}`"
class="align-top"
class="align-top [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3">
<UInput
@ -517,7 +455,7 @@
<td class="px-3 py-3 text-right">
<UButton
type="button"
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@ -571,6 +509,77 @@
:description="errorMessage"
/>
</template>
<div
class="flex flex-col gap-3 border-t border-default pt-5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="inline-flex self-start rounded-md border border-default bg-muted/20 p-1">
<UButton
type="button"
size="sm"
icon="i-lucide-sliders-horizontal"
color="neutral"
:variant="mode === 'gui' ? 'soft' : 'ghost'"
:disabled="!guiSupported || isBusy"
@click="switchMode('gui')"
>
GUI
</UButton>
<UButton
type="button"
size="sm"
icon="i-lucide-code"
color="neutral"
:variant="mode === 'advanced' ? 'soft' : 'ghost'"
:disabled="isBusy"
@click="switchMode('advanced')"
>
Advanced
</UButton>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
v-if="mode === 'advanced'"
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-wand-sparkles"
:disabled="isBusy"
class="justify-center"
@click="beautify"
>
Format
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="lg"
icon="i-lucide-x"
:disabled="submitting"
class="justify-center"
@click="cancel"
>
Cancel
</UButton>
<UButton
type="button"
color="primary"
size="lg"
icon="i-lucide-save"
:loading="submitting"
:disabled="isBusy"
class="justify-center"
@click="submit"
>
Save
</UButton>
</div>
</div>
</div>
</template>
@ -615,6 +624,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'submit', payload: TaskDefinitionDocument): void;
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'import-existing', id: number): void;
}>();
@ -708,6 +718,16 @@ const existingDefinitionItems = computed(() => {
}));
});
const dirtySource = computed(() => ({
mode: mode.value,
showImport: showImport.value,
importString: importString.value,
selectedExisting: selectedExisting.value,
jsonText: jsonText.value,
guiState: JSON.parse(JSON.stringify(guiState)),
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const resetGuiState = (state: GuiState): void => {
guiState.name = state.name;
guiState.priority = state.priority;
@ -1027,6 +1047,10 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
containerSelector: '',
fields: [defaultField()],
});
nextTick(() => {
markClean();
emit('dirty-change', false);
});
return;
}
@ -1050,6 +1074,11 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
mode.value = 'advanced';
errorMessage.value = 'Failed to prepare definition for editing.';
}
nextTick(() => {
markClean();
emit('dirty-change', false);
});
};
const importFromString = (): void => {
@ -1088,6 +1117,8 @@ watch(
{ immediate: true },
);
watch(isDirty, (value: boolean) => emit('dirty-change', value));
const switchMode = (next: EditorMode): void => {
if (isBusy.value || next === mode.value) {
return;

View file

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

View file

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

View file

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

View file

@ -121,7 +121,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-180 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-80 whitespace-nowrap">Flags</th>
<th>Description</th>
</tr>
@ -130,7 +132,7 @@
<tr
v-for="opt in group.items"
:key="opt.flags.join('|')"
class="align-top hover:bg-muted/20"
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2">
@ -185,7 +187,9 @@
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-215 w-full table-auto text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-80 whitespace-nowrap">Flags</th>
<th class="w-36 whitespace-nowrap">Group</th>
<th>Description</th>
@ -195,7 +199,7 @@
<tr
v-for="opt in visible"
:key="opt.flags.join('|')"
class="align-top hover:bg-muted/20"
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="w-80 px-3 py-3 align-top">
<div class="flex items-start gap-2">

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

View file

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

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

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 type { Preset } from '~/types/presets';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import { cleanObject, prettyName } from '~/utils';
type EditablePreset = Partial<Preset> & {
@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
export const usePresetEditor = () => {
const presetsStore = usePresets();
const dialog = useDialog();
const isOpen = ref(false);
const reference = ref<number | null>(null);
const preset = ref<Partial<Preset>>(makeEmptyPreset());
const initialSnapshot = ref('');
const dirty = ref(false);
const sessionId = ref(0);
const discardEditor = (): void => {
dirty.value = false;
reference.value = null;
preset.value = makeEmptyPreset();
};
const {
isDirty,
requestClose: requestCloseGuard,
handleOpenChange,
} = useDirtyCloseGuard(isOpen, {
dirty,
message: 'You have unsaved preset changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
const modalTitle = computed(() => {
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add';
});
@ -53,39 +71,27 @@ export const usePresetEditor = () => {
const modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
const reset = (): void => {
reference.value = null;
preset.value = makeEmptyPreset();
initialSnapshot.value = '';
discardEditor();
};
const close = (): void => {
dirty.value = false;
isOpen.value = false;
reset();
};
const snapshot = (item: Partial<Preset>): string =>
JSON.stringify(sanitizePreset(item as Preset));
const isDirty = computed(() => {
if (!isOpen.value) {
return false;
}
return snapshot(preset.value) !== initialSnapshot.value;
});
const openCreate = (): void => {
reset();
dirty.value = false;
sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true;
};
const openEdit = (item: Preset | EditablePreset): void => {
dirty.value = false;
reference.value = item.id ?? null;
preset.value = sanitizePreset(item);
sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true;
};
@ -94,22 +100,7 @@ export const usePresetEditor = () => {
return;
}
if (!isDirty.value) {
close();
return;
}
const { status } = await dialog.confirmDialog({
title: 'Discard changes?',
message: 'You have unsaved changes. Close the preset editor and discard them?',
confirmText: 'Discard',
cancelText: 'Keep editing',
confirmColor: 'warning',
});
if (status === true) {
close();
}
await requestCloseGuard();
};
const submit = async ({
@ -144,10 +135,12 @@ export const usePresetEditor = () => {
modalTitle,
modalDescription,
isDirty,
dirty,
addInProgress: presetsStore.addInProgress,
openCreate,
openEdit,
close,
handleOpenChange,
requestClose,
reset,
submit,

View file

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

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>
<UApp :toaster="toasterConfig">
<Transition name="shell-mode" mode="out-in">
<div v-if="simpleMode" key="simple" class="shell-stage flex flex-col">
<div
v-if="simpleMode"
key="simple"
class="shell-stage flex flex-col bg-default/95 backdrop-blur-sm"
>
<UAlert
v-if="showConnectionBanner"
color="warning"
@ -25,14 +29,14 @@
variant="link"
size="sm"
class="px-0"
@click="() => socket.reconnect()"
@click="socket.reconnect()"
>
Reconnect
</UButton>
</template>
</UAlert>
<Simple @show_settings="() => (show_settings = true)" />
<Simple @show_settings="show_settings = true" />
</div>
<div v-else key="regular" class="shell-stage flex flex-col">
@ -64,6 +68,7 @@
:ui="{ base: 'relative flex min-h-full overflow-visible' }"
>
<UDashboardSidebar
v-model:open="showSidebar"
side="left"
collapsible
resizable
@ -169,19 +174,24 @@
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
@click="reloadPage"
@click="$router.go(0)"
>
<span class="hidden xl:inline">Reload</span>
</UButton>
<UDashboardSearchButton class="shrink-0" />
<UColorModeButton
<UButton
color="neutral"
variant="ghost"
size="sm"
aria-label="Toggle color mode"
/>
:icon="colorModeButtonIcon"
:aria-label="colorModeButtonTitle"
:title="colorModeButtonTitle"
@click="colorMode.preference = nextColorModePreference"
>
<span class="hidden xl:inline">{{ colorModeButtonTitle }}</span>
</UButton>
<UButton
color="neutral"
@ -248,7 +258,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage"
@click="$router.go(0)"
>
click here
</button>
@ -261,7 +271,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="() => config.loadConfig(true)"
@click="config.loadConfig(true)"
>
reload configuration
</button>
@ -269,7 +279,7 @@
<button
type="button"
class="font-semibold text-highlighted underline-offset-2 hover:underline"
@click="reloadPage"
@click="$router.go(0)"
>
reload the page
</button>
@ -318,7 +328,7 @@
variant="link"
size="sm"
class="px-0"
@click="() => socket.reconnect()"
@click="socket.reconnect()"
>
Reconnect
</UButton>
@ -329,7 +339,7 @@
v-if="config.is_loaded"
class="flex min-h-0 min-w-0 max-w-full flex-1 flex-col"
>
<NuxtPage :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<NuxtPage :isLoading="loadingImage" @reload_bg="loadImage(true)" />
</div>
</div>
@ -473,8 +483,8 @@
<SettingsPanel
:isOpen="show_settings"
:isLoading="loadingImage"
@close="closeSettings()"
@reload_bg="() => loadImage(true)"
@close="show_settings = false"
@reload_bg="loadImage(true)"
direction="right"
/>
</UApp>
@ -482,26 +492,24 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
import { ref, onMounted, readonly } from 'vue';
import { ref, onBeforeUnmount, onMounted, readonly } from 'vue';
import { useStorage } from '@vueuse/core';
import moment from 'moment';
import { useMediaQuery } from '~/composables/useMediaQuery';
import type { toastPosition } from '~/composables/useNotification';
import { getDocsNavigationEntries } from '~/composables/useDocs';
import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue';
import Simple from '~/components/Simple.vue';
import Shutdown from '~/components/shutdown.vue';
import type { version_check } from '~/types';
type NavEntry = {
id: string;
label: string;
description?: string;
icon: string;
to?: string;
children?: NavEntry[];
};
import {
getActiveNavItem,
getNavItems,
getNavSections,
isNavItemActive,
type NavItem,
} from '~/utils/topLevelNavigation';
type SidebarSection = {
id: string;
@ -509,9 +517,16 @@ type SidebarSection = {
items: Array<Array<NavigationMenuItem>>;
};
const socket = useSocketStore();
const config = useConfigStore();
type ColorModePreference = 'system' | 'light' | 'dark';
type SwipeMode = 'open' | 'close';
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
const socket = useAppSocket();
const config = useYtpConfig();
const route = useRoute();
const colorMode = useColorMode();
const loadedImage = ref();
const loadingImage = ref(false);
const bg_enable = useStorage('random_bg', true);
@ -522,7 +537,148 @@ const show_settings = ref(false);
const checkingUpdates = ref(false);
const updateCheckMessage = ref('Up to date - Click to check');
const showRouteSearch = ref(false);
const showSidebar = ref(false);
const { alertDialog, confirmDialog } = useDialog();
const isMobile = useMediaQuery({ query: '(max-width: 1023px)' });
const SwipeState = {
mode: null as SwipeMode | null,
tracking: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
};
const colorModePreferences: Array<ColorModePreference> = ['system', 'light', 'dark'];
const colorModePreference = computed<ColorModePreference>(() => {
const preference = colorMode.preference;
return colorModePreferences.includes(preference as ColorModePreference)
? (preference as ColorModePreference)
: 'system';
});
const colorModeButtonIcon = computed(() => {
switch (colorModePreference.value) {
case 'light':
return 'i-lucide-sun';
case 'dark':
return 'i-lucide-moon';
default:
return 'i-lucide-monitor';
}
});
const nextColorModePreference = computed<ColorModePreference>(() => {
const currentIndex = colorModePreferences.indexOf(colorModePreference.value);
return colorModePreferences[(currentIndex + 1) % colorModePreferences.length] ?? 'system';
});
const colorModeButtonTitle = computed(() => {
switch (colorModePreference.value) {
case 'light':
return 'Light';
case 'dark':
return 'Dark';
default:
return 'System';
}
});
const resetSwipe = (): void => {
SwipeState.mode = null;
SwipeState.tracking = false;
SwipeState.startX = 0;
SwipeState.startY = 0;
SwipeState.endX = 0;
SwipeState.endY = 0;
};
const updateSwipePosition = (touch?: Touch): void => {
if (!touch) {
return;
}
SwipeState.endX = touch.clientX;
SwipeState.endY = touch.clientY;
};
const handleSwipeStart = (event: TouchEvent): void => {
if (!isMobile.value || event.touches.length !== 1) {
resetSwipe();
return;
}
const touch = event.touches[0];
if (!touch) {
resetSwipe();
return;
}
const swipeMode: SwipeMode | null = showSidebar.value
? 'close'
: touch.clientX <= MOBILE_SIDEBAR_EDGE_WIDTH
? 'open'
: null;
if (!swipeMode) {
resetSwipe();
return;
}
SwipeState.mode = swipeMode;
SwipeState.tracking = true;
SwipeState.startX = touch.clientX;
SwipeState.startY = touch.clientY;
updateSwipePosition(touch);
};
const handleSwipeMove = (event: TouchEvent): void => {
if (!SwipeState.tracking || event.touches.length !== 1) {
return;
}
updateSwipePosition(event.touches[0]);
};
const completeSwipe = (): void => {
if (!SwipeState.tracking) {
return;
}
const swipeMode = SwipeState.mode;
const deltaX = SwipeState.endX - SwipeState.startX;
const deltaY = SwipeState.endY - SwipeState.startY;
const isHorizontalOpenSwipe =
swipeMode === 'open' &&
deltaX >= MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
deltaX > Math.abs(deltaY);
const isHorizontalCloseSwipe =
swipeMode === 'close' &&
deltaX <= -MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE &&
Math.abs(deltaX) > Math.abs(deltaY);
resetSwipe();
if (isHorizontalOpenSwipe) {
showSidebar.value = true;
}
if (isHorizontalCloseSwipe) {
showSidebar.value = false;
}
};
const handleSwipeEnd = (event: TouchEvent): void => {
updateSwipePosition(event.changedTouches[0]);
completeSwipe();
};
const handleSwipeCancel = (): void => {
resetSwipe();
};
const dashboardSidebarUi = {
root: 'border-r border-default bg-default/95 backdrop-blur-sm',
@ -552,134 +708,51 @@ const navigationUi = (collapsed: boolean) => ({
linkLabel: collapsed ? 'hidden' : 'truncate',
});
const makeNavigationItem = (item: NavEntry): NavigationMenuItem => ({
const makeNavigationItem = (item: NavItem): NavigationMenuItem => ({
label: item.label,
icon: item.icon,
to: item.to,
value: item.id,
active: isNavItemActive(item, route),
});
const docsNavigationEntries = getDocsNavigationEntries();
const navigationAvailability = computed(() => ({
fileLogging: Boolean(config.app?.file_logging),
consoleEnabled: Boolean(config.app?.console_enabled),
}));
const allNavItems = computed<NavEntry[]>(() => [
{
id: 'downloads',
label: 'Downloads',
description: 'Queued and completed downloads list.',
icon: 'i-lucide-download',
to: '/',
},
{
id: 'files',
label: 'Files',
description: 'Browse downloaded files.',
icon: 'i-lucide-folder-tree',
to: '/browser',
},
{
id: 'presets',
label: 'Presets',
description:
'Presets are pre-defined command options for yt-dlp that you want to apply to given download.',
icon: 'i-lucide-sliders-horizontal',
to: '/presets',
},
{
id: 'custom-fields',
label: 'Custom Fields',
description: 'Custom fields allow you to add new fields to the download form.',
icon: 'i-lucide-braces',
to: '/dl_fields',
},
{
id: 'conditions',
label: 'Conditions',
description: 'Run yt-dlp custom match filter on returned info and apply options.',
icon: 'i-lucide-filter',
to: '/conditions',
},
{
id: 'notifications',
label: 'Notifications',
description: 'Send notifications to your webhooks based on specified events or presets.',
icon: 'i-lucide-bell',
to: '/notifications',
},
{
id: 'tasks',
label: 'Tasks',
icon: 'i-lucide-list-todo',
children: [
{
id: 'tasks-list',
label: 'Tasks',
description: 'Queue playlist/channels for automatic download at specified intervals.',
icon: 'i-lucide-list-todo',
to: '/tasks',
},
{
id: 'task-definitions',
label: 'Task Definitions',
description: 'Create definitions to turn any website into a downloadable feed of links.',
icon: 'i-lucide-workflow',
to: '/task_definitions',
},
],
},
{
id: 'tools',
label: 'Tools',
icon: 'i-lucide-wrench',
children: [
...(config.app?.file_logging
? [
{
id: 'logs',
label: 'Logs',
description: 'Scroll near the top to load older logs.',
icon: 'i-lucide-file-text',
to: '/logs',
},
]
: []),
...(config.app.console_enabled
? [
{
id: 'console',
label: 'Console',
description: 'Run yt-dlp commands directly in a non-interactive session.',
icon: 'i-lucide-terminal',
to: '/console',
},
]
: []),
],
},
{
id: 'docs',
label: 'Docs',
icon: 'i-lucide-book-open',
children: [
...docsNavigationEntries,
{
id: 'changelog',
label: 'Changelog',
description:
'Latest project changes, loaded remotely when available and falling back to the bundled changelog file.',
icon: 'i-lucide-list',
to: '/changelog',
},
],
},
]);
const navItems = computed(() => getNavItems(navigationAvailability.value));
const groupSectionEntries = (entries: Array<NavItem>): Array<Array<NavItem>> => {
const order = [...new Set(entries.map((entry) => entry.group))];
return order.map((group) => entries.filter((entry) => entry.group === group));
};
const sidebarItems = computed<
Array<{
id: string;
label: string;
items: Array<Array<NavItem>>;
}>
>(() => {
return getNavSections()
.map((section) => {
const sectionEntries = navItems.value.filter(
(entry) => entry.section === section.id && entry.sidebarVisible !== false,
);
return {
id: section.id,
label: section.label,
items: groupSectionEntries(sectionEntries),
};
})
.filter((section) => section.items.some((group) => group.length > 0));
});
const pageTitle = computed(() => {
const path = route.path;
const flat = allNavItems.value.flatMap((item) => [item, ...(item.children || [])]);
const match = flat
.filter((item) => item.to && (item.to === '/' ? path === '/' : path.startsWith(item.to)))
.sort((left, right) => (right.to?.length || 0) - (left.to?.length || 0))[0];
return match?.label || 'YTPTube';
const match = getActiveNavItem(route, navigationAvailability.value);
return match?.navbarTitle || match?.label || 'YTPTube';
});
const buildTooltip = computed(
@ -703,81 +776,30 @@ const connectionBannerIcon = computed(() =>
socket.connectionStatus === 'connecting' ? 'i-lucide-loader-circle' : 'i-lucide-info',
);
const sidebarSections = computed<Array<SidebarSection>>(() => {
const topLevelItems = (ids: string[]) =>
ids
.map((id) => allNavItems.value.find((item) => item.id === id))
.filter((item): item is NavEntry => Boolean(item))
.map((item) => makeNavigationItem(item));
const childItems = (id: string) =>
(allNavItems.value.find((item) => item.id === id)?.children || []).map((item) =>
makeNavigationItem(item),
);
return [
{
id: 'downloads',
label: 'Downloads',
items:
topLevelItems(['downloads', 'files']).length > 0
? [topLevelItems(['downloads', 'files'])]
: [],
},
{
id: 'automation',
label: 'Automation',
items: childItems('tasks').length > 0 ? [childItems('tasks')] : [],
},
{
id: 'configuration',
label: 'Configuration',
items: [
topLevelItems(['presets', 'custom-fields']),
topLevelItems(['conditions', 'notifications']),
].filter((group) => group.length > 0),
},
{
id: 'tools',
label: 'Tools',
items: childItems('tools').length > 0 ? [childItems('tools')] : [],
},
{
id: 'docs',
label: 'Docs',
items: childItems('docs').length > 0 ? [childItems('docs')] : [],
},
].filter((section) => section.items.some((group) => group.length > 0));
});
const sidebarSections = computed<Array<SidebarSection>>(() =>
sidebarItems.value.map((section) => ({
...section,
items: section.items.map((group) => group.map((entry) => makeNavigationItem(entry))),
})),
);
const routeSearchGroups = computed(() => [
{
id: 'routes',
label: 'Routes',
items: allNavItems.value.flatMap((item) => {
const self = item.to
? [
{
label: item.label,
description: item.description,
icon: item.icon,
suffix: item.to,
onSelect: () => handleRouteSelect(item),
},
]
: [];
const children = (item.children || []).map((child) => ({
label: child.label,
description: child.description,
icon: child.icon,
suffix: child.to || '',
onSelect: () => handleRouteSelect(child),
}));
return [...self, ...children];
}),
},
...sidebarItems.value
.map((section) => ({
id: section.id,
label: section.label,
items: section.items
.flat()
.filter((entry) => entry.searchable !== false)
.map((entry) => ({
label: entry.label,
description: entry.description,
icon: entry.icon,
suffix: entry.to,
onSelect: () => handleRouteSelect(entry),
})),
}))
.filter((section) => section.items.length > 0),
{
id: 'downloads',
label: 'Downloads',
@ -840,7 +862,7 @@ const syncShellModeClass = () => {
html.classList.toggle('simple-mode', simpleMode.value);
};
const handleRouteSelect = async (item: NavEntry) => {
const handleRouteSelect = async (item: NavItem) => {
await closeRouteSearch();
if (item.to) {
@ -899,6 +921,22 @@ const checkForUpdates = async () => {
};
onMounted(async () => {
document.addEventListener('touchstart', handleSwipeStart, {
passive: true,
capture: true,
});
document.addEventListener('touchmove', handleSwipeMove, {
passive: true,
capture: true,
});
document.addEventListener('touchend', handleSwipeEnd, {
passive: true,
capture: true,
});
document.addEventListener('touchcancel', handleSwipeCancel, {
passive: true,
capture: true,
});
syncShellModeClass();
try {
@ -921,13 +959,29 @@ onMounted(async () => {
} catch {}
});
onBeforeUnmount(() => {
document.removeEventListener('touchstart', handleSwipeStart, true);
document.removeEventListener('touchmove', handleSwipeMove, true);
document.removeEventListener('touchend', handleSwipeEnd, true);
document.removeEventListener('touchcancel', handleSwipeCancel, true);
});
watch(bg_enable, async (v) => await handleImage(v));
watch(simpleMode, () => syncShellModeClass());
watch(bg_opacity, (v) => {
watch(isMobile, (v) => {
if (v) {
return;
}
showSidebar.value = false;
resetSwipe();
});
watch(bg_opacity, () => {
if (false === bg_enable.value) {
return;
}
document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
syncOpacity();
});
watch(loadedImage, () => {
@ -936,7 +990,6 @@ watch(loadedImage, () => {
}
const html = document.documentElement;
const body = document.querySelector('body');
const style = {
'background-color': 'unset',
@ -954,7 +1007,7 @@ watch(loadedImage, () => {
.trim(),
);
html.classList.add('bg-fanart');
body?.setAttribute('style', `opacity: ${bg_opacity.value}`);
syncOpacity();
});
const handleImage = async (enabled: boolean) => {
@ -1025,8 +1078,6 @@ const useVersionUpdate = () => {
const { newVersionIsAvailable } = useVersionUpdate();
const closeSettings = () => (show_settings.value = false);
const shutdownApp = async () => {
if (false === config.app.is_native) {
await alertDialog({
@ -1065,8 +1116,6 @@ const shutdownApp = async () => {
}
};
const reloadPage = () => window.location.reload();
const connectionStatusColor = computed(() => {
switch (socket.connectionStatus) {
case 'connected':
@ -1111,6 +1160,8 @@ const toasterConfig = computed(() => ({
expand: true,
progress: true,
}));
const reloadPage = () => window.location.reload();
</script>
<style>

View file

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

View file

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

View file

@ -1,34 +1,27 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-filter" class="size-5 text-toned" />
<span>Conditions</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Run yt-dlp custom match filter on returned info. and apply options.
</p>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span v-if="!isMobile">Filter</span>
<span>Filter</span>
</UButton>
<UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus"
@click="openCreate"
>
<span v-if="!isMobile">New Condition</span>
<span>New Condition</span>
</UButton>
<UButton
@ -56,9 +49,10 @@
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
@ -71,111 +65,168 @@
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span v-if="!isMobile">Reload</span>
<span>Reload</span>
</UButton>
<UInput
v-if="showFilter && items.length > 0"
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="order-last w-full sm:order-first sm:w-80"
/>
</div>
</div>
<Pager
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredItems.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<div
v-if="displayStyle === 'list' && filteredItems.length > 0"
v-if="contentStyle === 'list' && filteredItems.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm">
<table class="min-w-235 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Condition</th>
<th class="w-44 whitespace-nowrap">Actions</th>
<th class="w-48 whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-for="cond in filteredItems" :key="cond.id" class="hover:bg-muted/20">
<tr
v-for="cond in filteredItems"
:key="cond.id"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="cond.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="font-semibold text-highlighted">{{ cond.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<div class="inline-flex items-center gap-2">
<UTooltip
:text="`Click to ${cond.enabled !== false ? 'disable' : 'enable'} condition`"
>
<USwitch
:model-value="cond.enabled !== false"
:disabled="conditions.addInProgress.value"
@update:model-value="() => void toggleEnabled(cond)"
/>
</UTooltip>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</div>
</button>
<span v-if="cond.priority > 0" class="inline-flex items-center gap-1">
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span>
</span>
</div>
<div class="space-y-1 text-xs text-toned">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-filter" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ cond.filter }}</code>
</div>
<div v-if="cond.cli" class="flex items-start gap-2">
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ cond.cli }}</code>
</div>
<div
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
class="flex items-start gap-2"
>
<UIcon name="i-lucide-list" class="mt-0.5 size-3.5 shrink-0" />
<code class="wrap-break-word">{{ key }}: {{ value }}</code>
</div>
</div>
</div>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@click="exportItem(cond)"
>
<span v-if="!isMobile">Export</span>
<span class="hidden sm:inline">Export</span>
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@click="editItem(cond)"
>
<span v-if="!isMobile">Edit</span>
<span class="hidden sm:inline">Edit</span>
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@click="() => void deleteItem(cond)"
>
<span v-if="!isMobile">Delete</span>
<span class="hidden sm:inline">Delete</span>
</UButton>
</div>
</td>
@ -186,135 +237,178 @@
</div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="cond in filteredItems"
:key="cond.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<button
type="button"
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(cond.id, 'name')"
>
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span>
</button>
<div v-for="cond in filteredItems" :key="cond.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(cond.id, 'name')"
>
<span :class="['block', expandClass(cond.id, 'name')]">{{ cond.name }}</span>
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(cond)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
<span>Export Condition</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="cond.id"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ cond.priority }}</span>
</span>
</label>
</div>
</div>
</template>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(cond)"
/>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<button
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'filter')"
>
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'filter')">{{ cond.filter }}</span>
</button>
<button
v-if="cond.cli"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'cli')">{{ cond.cli }}</span>
</button>
<div
v-if="extrasEntries(cond.extras).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="mb-2 flex items-center gap-2 text-toned">
<UIcon name="i-lucide-list" class="size-4" />
<span class="text-sm font-medium">Extras</span>
</div>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
color="info"
variant="soft"
size="sm"
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="conditions.addInProgress.value"
@click="() => void toggleEnabled(cond)"
>
<span class="font-semibold">{{ key }}</span
>: {{ value }}
</UBadge>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="cond.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
v-if="cond.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ cond.priority }}</span>
</span>
</div>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'filter')"
>
<UIcon name="i-lucide-filter" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Filter</div>
<span :class="['block', expandClass(cond.id, 'filter')]">{{ cond.filter }}</span>
</div>
</button>
<div v-if="cond.cli || cond.description" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-if="cond.cli"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!cond.description && 'sm:col-span-2',
]"
@click="toggleExpand(cond.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">CLI</div>
<span :class="['block', expandClass(cond.id, 'cli')]">{{ cond.cli }}</span>
</div>
</button>
<button
v-if="cond.description"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!cond.cli && 'sm:col-span-2',
]"
@click="toggleExpand(cond.id, 'description')"
>
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(cond.id, 'description')]">{{
cond.description
}}</span>
</div>
</button>
</div>
<div
v-if="extrasEntries(cond.extras).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="mb-2 flex items-center gap-2 text-toned">
<UIcon name="i-lucide-list" class="size-4" />
<span class="text-sm font-medium">Extras</span>
</div>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="([key, value], index) in extrasEntries(cond.extras)"
:key="`${cond.id}-${key}-${index}`"
color="info"
variant="soft"
size="sm"
>
<span class="font-semibold">{{ key }}</span
>: {{ value }}
</UBadge>
</div>
</div>
</div>
<button
v-if="cond.description"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(cond.id, 'description')"
>
<UIcon name="i-lucide-message-square-text" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(cond.id, 'description')">{{ cond.description }}</span>
</button>
</div>
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(cond)"
>
Edit
</UButton>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(cond)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(cond)"
>
Delete
</UButton>
</div>
</UCard>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(cond)"
>
Delete
</UButton>
</div>
</template>
</UCard>
</div>
</div>
<UAlert
@ -334,10 +428,6 @@
title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`"
/>
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
Clear filter
</UButton>
</div>
<UAlert
@ -349,6 +439,19 @@
description="There are no custom defined conditions yet. Click the New Condition button to add your first condition."
/>
<div v-if="filteredItems.length > 0 && paging?.total_pages > 1" class="flex justify-end">
<UPagination
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<div
v-if="filteredItems.length > 0 && !query"
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
@ -381,11 +484,11 @@
<UModal
v-if="editorOpen"
:open="editorOpen"
:title="modalTitle"
:description="modalDescription"
:title="itemRef ? `Edit - ${item.name || 'Condition'}` : 'Add new condition'"
description="Run yt-dlp custom match filter on returned info. and apply options."
:dismissible="!conditions.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<ConditionForm
@ -393,7 +496,8 @@
:addInProgress="conditions.addInProgress.value"
:reference="itemRef"
:item="item as Condition"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -402,21 +506,28 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useConditions } from '~/composables/useConditions';
import type { Condition } from '~/types/conditions';
import type { APIResponse } from '~/types/responses';
import { cleanObject, copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const pageShell = requirePageShell('conditions');
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const { toggleExpand, expandClass } = useExpandableMeta();
const conditions = useConditions();
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog();
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
const paging = conditions.pagination;
@ -425,12 +536,14 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
const item = ref<Partial<Condition>>({});
const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const removeKeys = ['raw', 'toggle_description'];
const expandedItems = reactive<Record<number, Set<string>>>({});
const filteredItems = computed<ConditionItemWithUI[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
@ -441,22 +554,69 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
});
const modalTitle = computed(() =>
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition',
const selectableConditionIds = computed(() =>
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const modalDescription = computed(
() => 'Run yt-dlp custom match filter on returned info. and apply options.',
const allSelected = computed(
() =>
selectableConditionIds.value.length > 0 &&
selectableConditionIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
item.value = {};
itemRef.value = null;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved condition changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredItems,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = conditions.pagination.value.total_pages;
const nextQuery = { ...route.query };
@ -478,7 +638,7 @@ const toggleFilterPanel = async (): Promise<void> => {
}
await nextTick();
filterInput.value?.focus();
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
};
const loadContent = async (pageNumber = 1): Promise<void> => {
@ -499,6 +659,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
item.value = {};
itemRef.value = null;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -515,32 +676,13 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
};
const toggleExpand = (itemId: number | undefined, field: string): void => {
if (!itemId) {
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
if (!expandedItems[itemId]) {
expandedItems[itemId] = new Set();
}
if (expandedItems[itemId].has(field)) {
expandedItems[itemId].delete(field);
} else {
expandedItems[itemId].add(field);
}
};
const isExpanded = (itemId: number | undefined, field: string): boolean => {
if (!itemId) {
return false;
}
return expandedItems[itemId]?.has(field) ?? false;
};
const expandClass = (itemId: number | undefined, field: string): string => {
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
selectedIds.value = [...selectableConditionIds.value];
};
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => {
@ -559,6 +701,51 @@ const deleteItem = async (cond: Condition): Promise<void> => {
await conditions.deleteCondition(cond.id!);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Conditions',
message:
`Delete ${selectedIds.value.length} condition/s?` +
'\n\n' +
selectedIds.value
.map((id) => {
const item = filteredItems.value.find((cond) => cond.id === id);
return item ? `${item.id}: ${item.name}` : '';
})
.filter(Boolean)
.join('\n'),
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredItems.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await conditions.deleteCondition(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const updateItem = async ({
reference,
item: updatedItem,
@ -581,6 +768,7 @@ const updateItem = async ({
};
const editItem = (value: Condition): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(value)) as Condition;
itemRef.value = value.id;
editorOpen.value = true;

View file

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

View file

@ -1,34 +1,27 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-braces" class="size-5 text-toned" />
<span>Custom Fields</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Custom fields allow you to add new fields to the download form.
</p>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && items.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton
v-if="items.length > 0"
color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span v-if="!isMobile">Filter</span>
<span>Filter</span>
</UButton>
<UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus"
@click="openCreate"
>
<span v-if="!isMobile">New Field</span>
<span>New Field</span>
</UButton>
<UButton
@ -56,9 +49,10 @@
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
@ -71,91 +65,167 @@
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span v-if="!isMobile">Reload</span>
<span>Reload</span>
</UButton>
<UInput
v-if="showFilter && items.length > 0"
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="order-last w-full sm:order-first sm:w-80"
/>
</div>
</div>
<Pager
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredItems.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<div
v-if="displayStyle === 'list' && filteredItems.length > 0"
v-if="contentStyle === 'list' && filteredItems.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm">
<table class="min-w-235 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Field</th>
<th class="w-44 whitespace-nowrap">Actions</th>
<th class="w-48 whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-for="field in filteredItems" :key="field.id" class="hover:bg-muted/20">
<tr
v-for="field in filteredItems"
:key="field.id"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="field.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="font-semibold text-highlighted">{{ field.name }}</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span class="inline-flex items-center gap-1">
<UIcon name="i-lucide-terminal" class="size-3.5" />
<span>{{ field.field }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span>
<span>Type: {{ field.kind }}</span>
</span>
</div>
<div v-if="field.description" class="text-xs text-toned">
{{ field.description }}
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-terminal" class="size-3.5" />
<span>Option: {{ field.field }}</span>
</span>
</div>
</div>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@click="exportItem(field)"
>
<span v-if="!isMobile">Export</span>
Export
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@click="editItem(field)"
>
<span v-if="!isMobile">Edit</span>
Edit
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@click="() => void deleteItem(field)"
>
<span v-if="!isMobile">Delete</span>
Delete
</UButton>
</div>
</td>
@ -166,89 +236,131 @@
</div>
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="field in filteredItems"
:key="field.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="truncate text-sm font-semibold text-highlighted">{{ field.name }}</div>
<div v-for="field in filteredItems" :key="field.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(field.id, 'title')"
>
<span :class="['block', expandClass(field.id, 'title')]">{{ field.name }}</span>
</button>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(field)"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order {{ field.order }}</span>
</span>
<span>Export Field</span>
</UButton>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>{{ field.kind }}</span>
</span>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="field.id"
/>
</label>
</div>
</div>
</template>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(field)"
/>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Order: {{ field.order }}</span>
</span>
<div class="space-y-2 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">{{ field.field }}</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-shapes" class="size-3.5" />
<span>Type: {{ field.kind }}</span>
</span>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!field.description && 'sm:col-span-2',
]"
@click="toggleExpand(field.id, 'field')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Associated option</div>
<span :class="['block', expandClass(field.id, 'field')]">{{ field.field }}</span>
</div>
</button>
<button
v-if="field.description"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(field.id, 'description')"
>
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(field.id, 'description')]">
{{ field.description }}
</span>
</div>
</button>
</div>
</div>
<div
v-if="field.description"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-message-square-text"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<span class="wrap-break-word">{{ field.description }}</span>
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(field)"
>
Edit
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(field)"
>
Delete
</UButton>
</div>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(field)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(field)"
>
Delete
</UButton>
</div>
</UCard>
</template>
</UCard>
</div>
</div>
<UAlert
@ -268,10 +380,6 @@
title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`"
/>
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
Clear filter
</UButton>
</div>
<UAlert
@ -283,14 +391,27 @@
description="There are no custom defined fields yet. Click the New Field button to add your first field."
/>
<div v-if="filteredItems.length > 0 && paging?.total_pages > 1" class="flex justify-end">
<UPagination
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<UModal
v-if="editorOpen"
:open="editorOpen"
:title="modalTitle"
:description="modalDescription"
:title="itemRef ? `Edit - ${item.name || 'Field'}` : 'Add new field'"
description="Custom fields allow you to add new fields to the download form."
:dismissible="!dlFields.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<DLFieldForm
@ -298,7 +419,8 @@
:addInProgress="dlFields.addInProgress.value"
:reference="itemRef"
:item="item as DLField"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -307,19 +429,26 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useDlFields } from '~/composables/useDlFields';
import type { DLField } from '~/types/dl_fields';
import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('custom-fields');
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const dlFields = useDlFields();
const route = useRoute();
const router = useRouter();
const { confirmDialog } = useDialog();
const items = dlFields.dlFields as Ref<DLField[]>;
const paging = dlFields.pagination;
@ -328,9 +457,12 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
const item = ref<Partial<DLField>>({});
const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredItems = computed<DLField[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
@ -341,20 +473,69 @@ const filteredItems = computed<DLField[]>(() => {
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
});
const modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field'));
const modalDescription = computed(
() => 'Custom fields allow you to add new fields to the download form.',
const selectableFieldIds = computed(() =>
filteredItems.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableFieldIds.value.length > 0 &&
selectableFieldIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
item.value = {};
itemRef.value = null;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved custom field changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredItems,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const syncPageQuery = async (pageNumber: number): Promise<void> => {
const totalPages = dlFields.pagination.value.total_pages;
const nextQuery = { ...route.query };
@ -376,7 +557,7 @@ const toggleFilterPanel = async (): Promise<void> => {
}
await nextTick();
filterInput.value?.focus();
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
};
const loadContent = async (pageNumber = 1): Promise<void> => {
@ -397,6 +578,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
item.value = {};
itemRef.value = null;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -413,6 +595,15 @@ const toggleDisplayStyle = (): void => {
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableFieldIds.value];
};
const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return;
@ -421,6 +612,51 @@ const deleteItem = async (field: DLField): Promise<void> => {
await dlFields.deleteDlField(field.id!);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Fields',
message:
`Delete ${selectedIds.value.length} field/s?` +
'\n\n' +
selectedIds.value
.map((id) => {
const item = filteredItems.value.find((field) => field.id === id);
return item ? `${item.id}: ${item.name}` : '';
})
.filter(Boolean)
.join('\n'),
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredItems.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await dlFields.deleteDlField(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const updateItem = async ({
reference,
item: updatedItem,
@ -442,6 +678,7 @@ const updateItem = async ({
};
const editItem = (field: DLField): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(field)) as DLField;
itemRef.value = field.id;
editorOpen.value = true;

View file

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

1561
ui/app/pages/history.vue Normal file

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

View file

@ -1,34 +1,27 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-bell" class="size-5 text-toned" />
<span>Notifications</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Send notifications to your webhooks based on specified events or presets.
</p>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && notifications.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton
v-if="notifications.length > 0"
color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span v-if="!isMobile">Filter</span>
<span>Filter</span>
</UButton>
<UButton
@ -47,7 +40,7 @@
icon="i-lucide-plus"
@click="openCreate"
>
<span v-if="!isMobile">New Notification</span>
<span>New Notification</span>
</UButton>
<UButton
@ -60,7 +53,7 @@
:disabled="sendingTest"
@click="() => void sendTest()"
>
<span v-if="!isMobile">Send Test</span>
<span>Send Test</span>
</UButton>
<UButton
@ -69,9 +62,10 @@
variant="outline"
size="sm"
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
<span class="hidden sm:inline">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
@ -84,34 +78,108 @@
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span v-if="!isMobile">Reload</span>
<span>Reload</span>
</UButton>
<UInput
v-if="showFilter && notifications.length > 0"
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="order-last w-full sm:order-first sm:w-80"
/>
</div>
</div>
<Pager
v-if="paging?.total_pages > 1"
:page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="navigatePage"
/>
<div
v-if="!isLoading && filteredTargets.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<UPagination
v-if="paging?.total_pages > 1"
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<div
v-if="displayStyle === 'list' && filteredTargets.length > 0"
v-if="contentStyle === 'list' && filteredTargets.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-225 w-full text-sm">
<table class="min-w-235 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Targets</th>
<th class="w-44 whitespace-nowrap">Actions</th>
<th class="w-48 whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-for="item in filteredTargets" :key="item.id" class="hover:bg-muted/20">
<tr
v-for="item in filteredTargets"
:key="item.id"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-2">
<div class="min-w-0 text-sm font-semibold text-highlighted">
@ -141,54 +209,61 @@
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span>On: {{ joinEvents(item.on) }}</span>
</span>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Presets: {{ joinPresets(item.presets) }}</span>
</span>
<span v-if="headerKeys(item).length > 0" class="inline-flex items-center gap-1">
<span
v-if="headerKeys(item).length > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-key" class="size-3.5" />
<span>Headers: {{ headerKeys(item).join(', ') }}</span>
<span>Headers: {{ headerKeys(item).length }}</span>
</span>
</div>
</div>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@click="exportItem(item)"
>
<span v-if="!isMobile">Export</span>
<span class="hidden sm:inline">Export</span>
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@click="editItem(item)"
>
<span v-if="!isMobile">Edit</span>
<span class="hidden sm:inline">Edit</span>
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@click="() => void deleteItem(item)"
>
<span v-if="!isMobile">Delete</span>
<span class="hidden sm:inline">Delete</span>
</UButton>
</div>
</td>
@ -199,104 +274,191 @@
</div>
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="item in filteredTargets"
:key="item.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<span class="text-sm font-semibold text-highlighted">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }})
</span>
@
<a
:href="item.request.url"
target="_blank"
rel="noreferrer"
class="text-sm text-primary hover:underline"
>
{{ item.name }}
</a>
<div v-for="item in filteredTargets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<span :class="['block', expandClass(item.id, 'title')]">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
{{ item.name }}
</span>
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="addInProgress"
@click="() => void toggleEnabled(item)"
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="item.enabled !== false ? 'text-success' : 'text-error'"
<span>Export Target</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
</label>
</div>
</div>
</template>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
/>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
:disabled="addInProgress"
@click="() => void toggleEnabled(item)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="item.enabled !== false ? 'text-success' : 'text-error'"
/>
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</button>
<div class="space-y-2 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">On: {{ joinEvents(item.on) }}</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
<span>Events: {{ item.on.length || 'All' }}</span>
</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
<span>Presets: {{ item.presets.length || 'All' }}</span>
</span>
<span
v-if="headerKeys(item).length > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-key" class="size-3.5" />
<span>Headers: {{ headerKeys(item).length }}</span>
</span>
</div>
</div>
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-sliders-horizontal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">Presets: {{ joinPresets(item.presets) }}</span>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'url')"
>
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Target URL</div>
<a
:href="item.request.url"
target="_blank"
rel="noreferrer"
class="block text-highlighted hover:underline"
@click.stop
>
<span :class="['block', expandClass(item.id, 'url')]">
{{ item.request.url }}
</span>
</a>
</div>
</button>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'events')"
>
<UIcon name="i-lucide-bell-ring" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Events</div>
<span :class="['block', expandClass(item.id, 'events')]">{{
joinEvents(item.on)
}}</span>
</div>
</button>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'presets')"
>
<UIcon
name="i-lucide-sliders-horizontal"
class="mt-0.5 size-4 shrink-0 text-toned"
/>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Presets</div>
<span :class="['block', expandClass(item.id, 'presets')]">
{{ joinPresets(item.presets) }}
</span>
</div>
</button>
</div>
</div>
<div
v-if="headerKeys(item).length > 0"
class="rounded-md border border-default bg-muted/20 px-3 py-2"
>
<div class="flex items-start gap-2">
<button
v-if="headerKeys(item).length > 0"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'headers')"
>
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
<span class="wrap-break-word">Headers: {{ headerKeys(item).join(', ') }}</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Headers</div>
<span :class="['block', expandClass(item.id, 'headers')]">
{{ headerKeys(item).join(', ') }}
</span>
</div>
</button>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(item)"
>
Edit
</UButton>
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editItem(item)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</UCard>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</template>
</UCard>
</div>
</div>
<UAlert
@ -316,10 +478,6 @@
title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`"
/>
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
Clear filter
</UButton>
</div>
<UAlert
@ -331,6 +489,19 @@
description="No notification targets found. Click on the New Notification button to add your first notification target."
/>
<div v-if="filteredTargets.length > 0 && paging?.total_pages > 1" class="flex justify-end">
<UPagination
:page="paging.page"
:total="paging.total"
:items-per-page="paging.per_page"
:disabled="isLoading"
show-edges
:sibling-count="0"
@update:page="navigatePage"
size="sm"
/>
</div>
<div
v-if="!query && filteredTargets.length > 0"
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
@ -366,11 +537,11 @@
<UModal
v-if="editorOpen"
:open="editorOpen"
:title="modalTitle"
:description="modalDescription"
:title="targetRef ? `Edit - ${target.name}` : 'Add new notification target'"
description="Send notifications to your webhooks based on specified events or presets."
:dismissible="!addInProgress"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<NotificationForm
@ -379,7 +550,8 @@
:reference="targetRef"
:item="target"
:allowedEvents="allowedEvents"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -388,20 +560,27 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { useNotifications } from '~/composables/useNotifications';
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
import type { ImportedItem } from '~/types';
import type { notification } from '~/types/notification';
import { requirePageShell } from '~/utils/topLevelNavigation';
const toast = useNotification();
const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 });
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const pageShell = requirePageShell('notifications');
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
'notification_display_style',
'cards',
);
const isMobile = useMediaQuery({ maxWidth: 639 });
const notificationsStore = useNotifications();
const notifications = notificationsStore.notifications;
@ -415,25 +594,40 @@ const page = ref(1);
const targetRef = ref<number | undefined>(undefined);
const target = ref<notification>(defaultState());
const editorOpen = ref(false);
const editorDirty = ref(false);
const sendingTest = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const displayStyle = computed<'list' | 'grid'>(() =>
displayStyleState.value === 'list' ? 'list' : 'grid',
);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : displayStyle.value,
);
const modalTitle = computed(() =>
targetRef.value ? `Edit - ${target.value.name}` : 'Add new notification target',
);
const modalDescription = computed(
() => 'Send notifications to your webhooks based on specified events or presets.',
);
const modalKey = computed(
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
target.value = defaultState();
targetRef.value = undefined;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved notification changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
const filteredTargets = computed<notification[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
const items = notifications.value as notification[];
@ -445,12 +639,47 @@ const filteredTargets = computed<notification[]>(() => {
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
});
const selectableNotificationIds = computed(() =>
filteredTargets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableNotificationIds.value.length > 0 &&
selectableNotificationIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredTargets,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
function defaultState(): notification {
return {
name: '',
@ -469,7 +698,7 @@ const toggleFilterPanel = async (): Promise<void> => {
}
await nextTick();
filterInput.value?.focus();
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
};
const loadContent = async (pageNumber = page.value): Promise<void> => {
@ -488,6 +717,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
target.value = defaultState();
targetRef.value = undefined;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -500,7 +730,17 @@ const openCreate = (): void => {
editorOpen.value = true;
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableNotificationIds.value];
};
const editItem = (item: notification): void => {
editorDirty.value = false;
target.value = JSON.parse(JSON.stringify(item)) as notification;
targetRef.value = item.id ?? undefined;
editorOpen.value = true;
@ -519,6 +759,51 @@ const deleteItem = async (item: notification): Promise<void> => {
await notificationsStore.deleteNotification(item.id);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Notifications',
message:
`Delete ${selectedIds.value.length} notification target/s?` +
'\n\n' +
selectedIds.value
.map((id) => {
const item = filteredTargets.value.find((target) => target.id === id);
return item ? `${item.id}: ${item.name}` : '';
})
.filter(Boolean)
.join('\n'),
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredTargets.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await notificationsStore.deleteNotification(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const toggleEnabled = async (item: notification): Promise<void> => {
if (!item.id) {
toast.error('Notification target not found.');

View file

@ -1,35 +1,27 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" />
<span>Presets</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Presets are pre-defined command options for yt-dlp that you want to apply to given
download.
</p>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && presetsNoDefault.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton
v-if="presetsNoDefault.length > 0"
color="neutral"
@ -38,7 +30,7 @@
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span v-if="!isMobile">Filter</span>
<span>Filter</span>
</UButton>
<UButton
@ -48,7 +40,7 @@
icon="i-lucide-plus"
@click="editor.openCreate()"
>
<span v-if="!isMobile">New Preset</span>
<span>New Preset</span>
</UButton>
<UButton
@ -56,9 +48,10 @@
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
@ -71,26 +64,96 @@
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span v-if="!isMobile">Reload</span>
<span>Reload</span>
</UButton>
<UInput
v-if="showFilter && presetsNoDefault.length > 0"
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="order-last w-full sm:order-first sm:w-80"
/>
</div>
</div>
<div
v-if="display_style === 'list' && filteredPresets.length > 0"
v-if="!isLoading && filteredPresets.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
</div>
<div
v-if="contentStyle === 'list' && filteredPresets.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-190 w-full text-sm">
<table class="min-w-200 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Preset</th>
<th class="w-44 whitespace-nowrap">Actions</th>
<th class="w-48 whitespace-nowrap">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr v-for="item in filteredPresets" :key="item.id" class="hover:bg-muted/20">
<tr
v-for="item in filteredPresets"
:key="item.id"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-1">
<div class="font-semibold text-highlighted">
@ -99,14 +162,20 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<span
class="inline-flex items-center gap-1"
:class="item.cookies ? 'text-info' : ''"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-cookie" class="size-3.5" />
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
<UIcon
name="i-lucide-cookie"
class="size-3.5"
:class="item.cookies ? 'text-success' : ''"
/>
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
</span>
<span v-if="item.priority > 0" class="inline-flex items-center gap-1">
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span>
</span>
@ -114,36 +183,36 @@
</div>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@click="exportItem(item)"
>
<span v-if="!isMobile">Export</span>
<span class="hidden sm:inline">Export</span>
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@click="editor.openEdit(item)"
>
<span v-if="!isMobile">Edit</span>
<span class="hidden sm:inline">Edit</span>
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@click="() => void deleteItem(item)"
>
<span v-if="!isMobile">Delete</span>
<span class="hidden sm:inline">Delete</span>
</UButton>
</div>
</td>
@ -154,121 +223,169 @@
</div>
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<UCard
v-for="item in filteredPresets"
:key="item.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<div v-for="item in filteredPresets" :key="item.id" class="min-w-0 w-full max-w-full">
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(item.id, 'title')"
>
<span :class="['block', expandClass(item.id, 'title')]">
{{ prettyName(item.name) }}
</span>
</button>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
>
<span>Export Preset</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="item.id"
/>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
:class="
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
{{ prettyName(item.name) }}
<UIcon
name="i-lucide-cookie"
class="size-3.5"
:class="item.cookies ? 'text-success' : ''"
/>
<span>Cookies: {{ item.cookies ? 'Configured' : 'Not set' }}</span>
</span>
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ item.priority }}</span>
</span>
</div>
<button
v-if="item.folder"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'folder')"
>
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Download path</div>
<span :class="['block', expandClass(item.id, 'folder')]">{{
calcPath(item.folder)
}}</span>
</div>
</button>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="exportItem(item)"
/>
<div v-if="item.template || item.cli" class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
v-if="item.template"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.cli && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'template')"
>
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Output template</div>
<span :class="['block', expandClass(item.id, 'template')]">{{
item.template
}}</span>
</div>
</button>
<button
v-if="item.cli"
type="button"
:class="[
'flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left',
!item.template && 'sm:col-span-2',
]"
@click="toggleExpand(item.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">CLI options</div>
<span :class="['block', expandClass(item.id, 'cli')]">{{ item.cli }}</span>
</div>
</button>
</div>
<button
v-if="item.description"
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'description')"
>
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">Description</div>
<span :class="['block', expandClass(item.id, 'description')]">{{
item.description
}}</span>
</div>
</button>
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
:class="item.cookies ? 'border-info/40 text-info' : ''"
>
<UIcon name="i-lucide-cookie" class="size-3.5" />
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
</span>
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editor.openEdit(item)"
>
Edit
</UButton>
<span
v-if="item.priority > 0"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ item.priority }}</span>
</span>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<button
v-if="item.folder"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'folder')"
>
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'folder')">{{ calcPath(item.folder) }}</span>
</button>
<button
v-if="item.template"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'template')"
>
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'template')">{{ item.template }}</span>
</button>
<button
v-if="item.cli"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'cli')"
>
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'cli')">{{ item.cli }}</span>
</button>
<button
v-if="item.description"
type="button"
class="flex w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(item.id, 'description')"
>
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
<span :class="expandClass(item.id, 'description')">{{ item.description }}</span>
</button>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="editor.openEdit(item)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</UCard>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void deleteItem(item)"
>
Delete
</UButton>
</div>
</template>
</UCard>
</div>
</div>
<UAlert
@ -288,10 +405,6 @@
title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`"
/>
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
>Clear filter</UButton
>
</div>
<UAlert
@ -308,7 +421,8 @@
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
<li>
When you export preset, it doesn't include the cookies field contents for security
reasons.
reasons. However, there are some CLI options that could contain sensitive data like
username or password. Remove them before sharing your preset.
</li>
</ul>
</template>
@ -321,7 +435,7 @@
:description="editor.modalDescription.value"
:dismissible="!editor.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && void editor.requestClose()"
@update:open="(open) => void editor.handleOpenChange(open)"
>
<template #body>
<PresetForm
@ -331,6 +445,7 @@
:preset="editor.preset.value"
:presets="presets"
@cancel="() => void editor.requestClose()"
@dirty-change="(dirty) => (editor.dirty.value = dirty)"
@submit="editor.submit"
/>
</template>
@ -339,28 +454,36 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import { useStorage } from '@vueuse/core';
import type { Preset } from '~/types/presets';
import { useDialog } from '~/composables/useDialog';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import { useConfirm } from '~/composables/useConfirm';
import { prettyName } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
const presetsStore = usePresets();
const config = useConfigStore();
const config = useYtpConfig();
const box = useConfirm();
const editor = usePresetEditor();
const pageShell = requirePageShell('presets');
const { confirmDialog } = useDialog();
const { toggleExpand, expandClass } = useExpandableMeta();
const display_style = useStorage<string>('preset_display_style', 'grid');
const isMobile = useMediaQuery({ maxWidth: 1024 });
const isMobile = useMediaQuery({ maxWidth: 639 });
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
const isLoading = presetsStore.isLoading;
const expandedItems = ref<Record<string, Set<string>>>({});
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default));
@ -375,12 +498,50 @@ const filteredPresets = computed<PresetWithUI[]>(() => {
);
});
const selectablePresetIds = computed(() =>
filteredPresets.value.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectablePresetIds.value.length > 0 &&
selectablePresetIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : 'list' === display_style.value ? 'list' : 'grid',
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredPresets,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const toggleFilterPanel = async (): Promise<void> => {
showFilter.value = !showFilter.value;
if (!showFilter.value) {
@ -389,13 +550,67 @@ const toggleFilterPanel = async (): Promise<void> => {
}
await nextTick();
filterInput.value?.focus();
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
};
const reloadContent = async (): Promise<void> => {
await presetsStore.loadPresets(1, 1000);
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectablePresetIds.value];
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Presets',
message:
`Delete ${selectedIds.value.length} preset/s?` +
'\n\n' +
selectedIds.value
.map((id) => {
const item = filteredPresets.value.find((preset) => preset.id === id);
return item ? `${item.id}: ${prettyName(item.name)}` : '';
})
.filter(Boolean)
.join('\n'),
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredPresets.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
if (!item.id) {
continue;
}
await presetsStore.deletePreset(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const deleteItem = async (item: Preset): Promise<void> => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return;
@ -410,35 +625,6 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
};
const toggleExpand = (itemId: number | string | undefined, field: string): void => {
if (itemId === undefined || itemId === null) {
return;
}
const key = String(itemId);
if (!expandedItems.value[key]) {
expandedItems.value[key] = new Set();
}
if (expandedItems.value[key]?.has(field)) {
expandedItems.value[key]?.delete(field);
} else {
expandedItems.value[key]?.add(field);
}
};
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
if (itemId === undefined || itemId === null) {
return false;
}
return expandedItems.value[String(itemId)]?.has(field) ?? false;
};
const expandClass = (itemId: number | string | undefined, field: string): string => {
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
};
const exportItem = (item: Preset): void => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
const userData = Object.fromEntries(

View file

@ -1,34 +1,27 @@
<template>
<main class="w-full min-w-0 max-w-full space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
<UIcon name="i-lucide-workflow" class="size-5 text-toned" />
<span>Task Definitions</span>
</div>
<main class="w-full min-w-0 max-w-full space-y-6">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
>
<UIcon :name="pageShell.icon" class="size-5" />
</span>
<p class="text-sm text-toned">
Create definitions to turn any website into a downloadable feed of links.
</p>
<div class="min-w-0 space-y-2">
<div
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
>
<span>{{ pageShell.sectionLabel }}</span>
<span>/</span>
<span>{{ pageShell.pageLabel }}</span>
</div>
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div v-if="showFilter && definitions.length > 0" class="relative w-full sm:w-80">
<span
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
>
<UIcon name="i-lucide-filter" class="size-4" />
</span>
<input
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
/>
</div>
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
<UButton
v-if="definitions.length > 0"
color="neutral"
@ -37,7 +30,7 @@
icon="i-lucide-filter"
@click="toggleFilterPanel"
>
<span v-if="!isMobile">Filter</span>
<span>Filter</span>
</UButton>
<UButton
@ -47,7 +40,7 @@
icon="i-lucide-search"
@click="inspect = true"
>
<span v-if="!isMobile">Inspect</span>
<span>Inspect</span>
</UButton>
<UButton
@ -57,7 +50,7 @@
icon="i-lucide-plus"
@click="openCreate"
>
<span v-if="!isMobile">New Definition</span>
<span>New Definition</span>
</UButton>
<UButton
@ -65,9 +58,10 @@
variant="outline"
size="sm"
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
class="hidden sm:inline-flex"
@click="toggleDisplayStyle"
>
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
</UButton>
<UButton
@ -79,8 +73,20 @@
:disabled="isLoading"
@click="() => void reloadContent()"
>
<span v-if="!isMobile">Reload</span>
<span>Reload</span>
</UButton>
<UInput
v-if="showFilter && definitions.length > 0"
id="filter"
ref="filterInput"
v-model="query"
type="search"
placeholder="Filter displayed content"
icon="i-lucide-filter"
size="sm"
class="order-last w-full sm:order-first sm:w-80"
/>
</div>
</div>
@ -94,17 +100,62 @@
/>
<div
v-if="display_style === 'list' && filteredDefinitions.length > 0"
v-if="!isLoading && filteredDefinitions.length > 0"
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:icon="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
@click="toggleMasterSelection"
>
{{ allSelected ? 'Unselect' : 'Select' }}
</UButton>
<UBadge v-if="selectedIds.length > 0" color="error" variant="soft" size="sm">
{{ selectedIds.length }}
</UBadge>
<UDropdownMenu :items="bulkActionGroups" :modal="false">
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-list"
trailing-icon="i-lucide-chevron-down"
>
Actions
</UButton>
</UDropdownMenu>
</div>
<div class="text-xs text-toned">{{ filteredDefinitions.length }} displayed</div>
</div>
<div
v-if="contentStyle === 'list' && filteredDefinitions.length > 0"
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-245 w-full text-sm">
<table class="min-w-255 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
<tr
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-12">
<button type="button" class="cursor-pointer" @click="toggleMasterSelection">
<UIcon
:name="allSelected ? 'i-lucide-square' : 'i-lucide-square-check-big'"
class="size-4"
/>
</button>
</th>
<th class="w-full text-left">Definition</th>
<th class="w-28 whitespace-nowrap">Priority</th>
<th class="w-36 whitespace-nowrap">Updated</th>
<th class="w-44 whitespace-nowrap">Actions</th>
<th class="w-48 whitespace-nowrap">Actions</th>
</tr>
</thead>
@ -112,8 +163,19 @@
<tr
v-for="definition in filteredDefinitions"
:key="definition.id"
class="hover:bg-muted/20"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 text-center align-middle">
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="definition.id"
/>
</label>
</td>
<td class="px-3 py-3 align-middle">
<div class="space-y-1">
<div class="font-semibold text-highlighted">
@ -123,7 +185,7 @@
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 transition hover:bg-muted"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
>
<UIcon
@ -134,19 +196,19 @@
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
>
<span>Patterns: {{ definition.match_url.length }} match/s</span>
</span>
</div>
</div>
</td>
<td class="px-3 py-3 text-center align-middle">{{ definition.priority }}</td>
<td class="px-3 py-3 text-center align-middle">
{{ definition.priority }}
</td>
<td class="px-3 py-3 text-center align-middle whitespace-nowrap">
<UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')">
@ -158,36 +220,36 @@
</UTooltip>
</td>
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
<td class="w-48 px-3 py-3 align-middle whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<UButton
color="info"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-up"
@click="() => void exportDefinition(definition)"
>
<span v-if="!isMobile">Export</span>
<span class="hidden sm:inline">Export</span>
</UButton>
<UButton
color="warning"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-pencil"
@click="() => void openEdit(definition)"
>
<span v-if="!isMobile">Edit</span>
<span class="hidden sm:inline">Edit</span>
</UButton>
<UButton
color="error"
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-trash"
@click="() => void remove(definition)"
>
<span v-if="!isMobile">Delete</span>
<span class="hidden sm:inline">Delete</span>
</UButton>
</div>
</td>
@ -201,110 +263,122 @@
v-else-if="filteredDefinitions.length > 0"
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<UCard
<div
v-for="definition in filteredDefinitions"
:key="definition.id"
class="flex h-full flex-col border bg-default"
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
class="min-w-0 w-full max-w-full"
>
<template #header>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="text-sm font-semibold text-highlighted wrap-break-word">
{{ definition.name || '(Unnamed definition)' }}
</div>
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="definition.enabled ? 'text-success' : 'text-error'"
/>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority {{ definition.priority }}</span>
</span>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span
>{{ definition.match_url.length }} match pattern{{
definition.match_url.length === 1 ? '' : 's'
}}</span
<UCard
class="flex h-full min-w-0 w-full max-w-full flex-col border bg-default"
:ui="{
header: 'p-4 pb-3',
body: 'flex flex-1 flex-col gap-4 p-4 pt-0',
footer: 'border-t border-default px-4 py-4',
}"
>
<template #header>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<button
type="button"
class="min-w-0 flex-1 text-left text-sm font-semibold text-highlighted"
@click="toggleExpand(definition.id, 'title')"
>
<span :class="['block', expandClass(definition.id, 'title')]">
{{ definition.name || '(Unnamed)' }}
</span>
</button>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
>
<span>Export Definition</span>
</UButton>
<label class="inline-flex cursor-pointer items-center justify-center">
<input
v-model="selectedIds"
class="completed-checkbox size-4 rounded border-default"
type="checkbox"
:value="definition.id"
/>
</label>
</div>
</div>
</template>
<div class="space-y-2 text-sm text-default">
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
<button
type="button"
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
@click="() => void toggle(definition)"
>
<UIcon
name="i-lucide-power"
class="size-3.5"
:class="definition.enabled ? 'text-success' : 'text-error'"
/>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</button>
<span
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
>
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Priority: {{ definition.priority }}</span>
</span>
</div>
<button
type="button"
class="flex min-w-0 w-full items-start gap-2 rounded-md border border-default bg-muted/20 px-3 py-2 text-left"
@click="toggleExpand(definition.id, 'patterns')"
>
<UIcon name="i-lucide-link" class="mt-0.5 size-4 shrink-0 text-toned" />
<div class="min-w-0 flex-1">
<div class="text-xs font-medium text-toned">URL patterns</div>
<span :class="['block', expandClass(definition.id, 'patterns')]">
{{ definition.match_url.join('\n') }}
</span>
</div>
</div>
<UButton
color="info"
variant="ghost"
size="xs"
icon="i-lucide-file-up"
square
@click="() => void exportDefinition(definition)"
/>
</button>
</div>
</template>
<div class="space-y-3 text-sm text-default">
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
<div
class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-toned"
>
<UIcon name="i-lucide-link" class="size-3.5" />
<span>Match patterns</span>
</div>
<div class="space-y-1 text-sm">
<div
v-for="pattern in definition.match_url.slice(0, 3)"
:key="pattern"
class="truncate text-default"
<template #footer>
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="() => void openEdit(definition)"
>
{{ pattern }}
</div>
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
+{{ definition.match_url.length - 3 }} more
</div>
Edit
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void remove(definition)"
>
Delete
</UButton>
</div>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
<UButton
color="warning"
variant="outline"
icon="i-lucide-pencil"
class="w-full justify-center"
@click="() => void openEdit(definition)"
>
Edit
</UButton>
<UButton
color="error"
variant="outline"
icon="i-lucide-trash"
class="w-full justify-center"
@click="() => void remove(definition)"
>
Delete
</UButton>
</div>
</UCard>
</template>
</UCard>
</div>
</div>
<UAlert
@ -324,10 +398,6 @@
title="No Results"
:description="`No results found for the query: ${query}. Please try a different search term.`"
/>
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
>Clear filter</UButton
>
</div>
<UAlert
@ -342,11 +412,19 @@
<UModal
v-if="isEditorOpen"
:open="isEditorOpen"
:title="editorTitle"
:description="editorDescription"
:title="
editorMode === 'create'
? 'Create Task Definition'
: `Edit - ${currentSummary?.name || 'Task Definition'}`
"
:description="
editorLoading
? 'Loading full definition before editing.'
: 'Use the GUI editor when it fits, or switch to advanced JSON for full control.'
"
:dismissible="!editorLoading && !editorSubmitting"
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<TaskDefinitionEditor
@ -356,7 +434,8 @@
:loading="editorLoading"
:submitting="editorSubmitting"
@submit="submitDefinition"
@cancel="closeEditor"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@import-existing="importExistingDefinition"
/>
</template>
@ -378,14 +457,17 @@
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
import moment from 'moment';
import { computed, onMounted, ref, watch } from 'vue';
import { useStorage } from '@vueuse/core';
import { useExpandableMeta } from '~/composables/useExpandableMeta';
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog';
import { useMediaQuery } from '~/composables/useMediaQuery';
import { copyText, encode } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
import type {
TaskDefinitionDetailed,
@ -412,7 +494,9 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
},
};
const isMobile = useMediaQuery({ maxWidth: 1024 });
const pageShell = requirePageShell('task-definitions');
const { toggleExpand, expandClass } = useExpandableMeta();
const taskDefs = useTaskDefinitionsComposable();
const definitionsRef = taskDefs.definitions;
@ -430,6 +514,7 @@ const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.v
const { confirmDialog } = useDialog();
const isEditorOpen = ref(false);
const editorDirty = ref(false);
const editorMode = ref<'create' | 'edit'>('create');
const editorLoading = ref(false);
const editorSubmitting = ref(false);
@ -437,11 +522,14 @@ const workingDefinition = ref<TaskDefinitionDocument | null>(null);
const workingId = ref<number | null>(null);
const inspect = ref(false);
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
const isMobile = useMediaQuery({ maxWidth: 639 });
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
const filterInput = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
const hideImportByDefault = ref(false);
const selectedIds = ref<number[]>([]);
const massDelete = ref(false);
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
const normalizedQuery = query.value.trim().toLowerCase();
@ -463,6 +551,35 @@ const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
});
});
const selectableDefinitionIds = computed(() =>
filteredDefinitions.value
.map((item) => item.id)
.filter((id): id is number => typeof id === 'number'),
);
const allSelected = computed(
() =>
selectableDefinitionIds.value.length > 0 &&
selectableDefinitionIds.value.every((id) => selectedIds.value.includes(id)),
);
const hasSelected = computed(() => selectedIds.value.length > 0);
const contentStyle = computed<'list' | 'grid'>(() =>
isMobile.value ? 'grid' : display_style.value,
);
const bulkActionGroups = computed<DropdownMenuItem[][]>(() => [
[
{
label: 'Remove Selected',
icon: 'i-lucide-trash',
color: 'error',
disabled: !hasSelected.value || massDelete.value,
onSelect: () => void deleteSelected(),
},
],
]);
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if (editorMode.value !== 'edit' || !workingId.value) {
return undefined;
@ -471,30 +588,45 @@ const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
return definitions.value.find((item) => item.id === workingId.value);
});
const editorTitle = computed(() => {
return editorMode.value === 'create'
? 'Create Task Definition'
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
});
const editorDescription = computed(() => {
if (editorLoading.value) {
return 'Loading full definition before editing.';
}
return 'Use the GUI editor when it fits, or switch to advanced JSON for full control.';
});
const showImportByDefault = computed(
() => editorMode.value === 'create' && !hideImportByDefault.value,
);
const discardEditor = (): void => {
editorDirty.value = false;
workingDefinition.value = null;
workingId.value = null;
editorLoading.value = false;
editorSubmitting.value = false;
hideImportByDefault.value = false;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(isEditorOpen, {
dirty: editorDirty,
message: 'You have unsaved task definition changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
}
});
watch(
filteredDefinitions,
(items) => {
const validIds = new Set(
items.map((item) => item.id).filter((id): id is number => typeof id === 'number'),
);
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id));
},
{ deep: true },
);
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
};
@ -507,7 +639,7 @@ const toggleFilterPanel = async (): Promise<void> => {
}
await nextTick();
filterInput.value?.focus();
filterInput.value?.inputRef?.value?.focus?.({ preventScroll: true });
};
const reloadContent = async (): Promise<void> => {
@ -518,7 +650,17 @@ const toggleDisplayStyle = (): void => {
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
};
const toggleMasterSelection = (): void => {
if (allSelected.value) {
selectedIds.value = [];
return;
}
selectedIds.value = [...selectableDefinitionIds.value];
};
const openCreate = (): void => {
editorDirty.value = false;
editorMode.value = 'create';
workingId.value = null;
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
@ -529,6 +671,7 @@ const openCreate = (): void => {
};
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorDirty.value = false;
editorMode.value = 'edit';
workingId.value = summary.id;
workingDefinition.value = null;
@ -559,6 +702,7 @@ const importExistingDefinition = async (id: number): Promise<void> => {
return;
}
editorDirty.value = false;
editorMode.value = 'create';
workingId.value = null;
workingDefinition.value = {
@ -578,6 +722,7 @@ const closeEditor = (): void => {
return;
}
editorDirty.value = false;
isEditorOpen.value = false;
workingDefinition.value = null;
workingId.value = null;
@ -625,6 +770,48 @@ const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
await deleteDefinition(summary.id);
};
const deleteSelected = async (): Promise<void> => {
if (selectedIds.value.length < 1) {
return;
}
const { status } = await confirmDialog({
title: 'Delete Selected Task Definitions',
message:
`Delete ${selectedIds.value.length} task definition/s?` +
'\n\n' +
selectedIds.value
.map((id) => {
const item = filteredDefinitions.value.find((definition) => definition.id === id);
return item ? `${item.id}: ${item.name || '(Unnamed definition)'}` : '';
})
.filter(Boolean)
.join('\n'),
confirmText: 'Delete',
confirmColor: 'error',
});
if (true !== status) {
return;
}
const itemsToDelete = filteredDefinitions.value.filter(
(item) => item.id && selectedIds.value.includes(item.id),
);
if (itemsToDelete.length < 1) {
return;
}
massDelete.value = true;
for (const item of itemsToDelete) {
await deleteDefinition(item.id);
}
selectedIds.value = [];
massDelete.value = false;
};
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled);
};

View file

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

View file

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

View file

@ -1,138 +1,206 @@
<!doctype html>
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YTPTube Loading...</title>
<meta name="theme-color" content="#0f172a" />
<title>Loading YTPTube</title>
<style>
:root {
color-scheme: light dark;
--bg: #edf2f9;
--bg-deep: #dbe5f1;
--panel: rgb(255 255 255 / 0.84);
--border: rgb(15 23 42 / 0.1);
--shadow: rgb(15 23 42 / 0.14);
--text: #0f172a;
--muted: #5b6b80;
--accent: #dc2626;
--accent-soft: rgb(220 38 38 / 0.18);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #08111d;
--bg-deep: #0f172a;
--panel: rgb(15 23 42 / 0.84);
--border: rgb(148 163 184 / 0.16);
--shadow: rgb(2 6 23 / 0.45);
--text: #e7eef9;
--muted: #9fb0c7;
--accent: #f87171;
--accent-soft: rgb(248 113 113 / 0.18);
}
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
padding: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
color: #111;
user-select: none;
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
display: grid;
place-items: center;
overflow: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #121212;
color: #eee;
}
color: var(--text);
user-select: none;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background:
radial-gradient(circle at top left, var(--accent-soft), transparent 24%),
linear-gradient(180deg, var(--bg-deep), var(--bg));
}
.loader-container {
text-align: center;
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px);
background-size: 2rem 2rem;
opacity: 0.32;
}
.loading-card {
width: min(100%, 29rem);
position: relative;
padding: 1.65rem;
border: 1px solid var(--border);
border-radius: 1.1rem;
background: var(--panel);
box-shadow: 0 1rem 2.5rem var(--shadow);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.95rem;
}
svg.ytptube-loading {
width: 420px;
height: 105px;
stroke: #ff0000;
stroke-width: 4.5px;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
.brand img {
width: 2.8rem;
height: 2.8rem;
flex: none;
display: block;
object-fit: contain;
}
@media (prefers-color-scheme: dark) {
svg.ytptube-loading {
stroke: #ff4444;
.brand-copy {
min-width: 0;
}
.label {
margin: 0;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
h1 {
margin: 0.35rem 0 0;
font-size: 1.55rem;
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 700;
}
.description {
margin: 1rem 0 0;
color: var(--muted);
font-size: 0.96rem;
line-height: 1.55;
}
.loader {
margin-top: 1.2rem;
height: 0.32rem;
overflow: hidden;
border-radius: 999px;
background: rgb(148 163 184 / 0.22);
}
.loader::before {
content: '';
display: block;
width: 36%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: slide 1.35s ease-in-out infinite;
}
.hint {
margin: 0.95rem 0 0;
font-size: 0.82rem;
color: var(--muted);
}
@keyframes slide {
0% {
transform: translateX(-130%);
}
}
@keyframes stroke-move {
100% {
stroke-dashoffset: -700;
transform: translateX(330%);
}
}
.spinner {
margin: 20px auto 0;
width: 64px;
height: 64px;
border: 8px solid transparent;
border-top-color: #ff0000;
border-radius: 50%;
animation: spin 1.2s linear infinite;
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
}
@media (max-width: 560px) {
.loading-card {
padding: 1.3rem;
}
@media (prefers-color-scheme: dark) {
.spinner {
border-top-color: #ff4444;
filter: drop-shadow(0 0 8px #ff4444);
.brand {
align-items: flex-start;
}
h1 {
font-size: 1.35rem;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
.subtitle {
margin-top: 14px;
font-size: 1rem;
color: inherit;
opacity: 0.8;
user-select: none;
font-weight: 600;
letter-spacing: 0.1em;
}
</style>
</head>
<body>
<div class="loader-container" role="img" aria-label="Loading YTPTube">
<svg
class="ytptube-loading"
viewBox="0 0 420 105"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
>
<!-- Y -->
<path d="M10 10 L25 37.5 L10 65" />
<path d="M25 37.5 L40 10" />
<!-- T -->
<path d="M50 10 L90 10" />
<path d="M70 10 L70 65" />
<!-- P -->
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
<!-- T -->
<path d="M160 10 L200 10" />
<path d="M180 10 L180 65" />
<!-- U -->
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
<!-- B -->
<path d="M270 10 L270 65" />
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
<!-- E -->
<path d="M320 10 L320 65" />
<path d="M320 10 L360 10" />
<path d="M320 37.5 L350 37.5" />
<path d="M320 65 L360 65" />
</svg>
<div class="spinner"></div>
<div class="subtitle">Loading, please wait...</div>
</div>
<main class="loading-card" role="status" aria-live="polite" aria-busy="true">
<div class="brand">
<img src="/images/favicon.png" alt="" aria-hidden="true" />
<div class="brand-copy">
<p class="label">YTPTube</p>
<h1>Loading your dashboard</h1>
</div>
</div>
<p class="description">Getting the interface ready. Please wait a moment...</p>
<div class="loader" aria-hidden="true"></div>
<p class="hint">Please wait...</p>
</main>
</body>
</html>

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 }));
};
/**
* Generate a pagination list based on current page, total pages, and delta range.
*
* @param current - The current active page number.
* @param last - The last page number.
* @param delta - How many pages to show before/after the current page.
* @returns An array of pagination entries including optional gaps.
*/
const makePagination = (
current: number,
last: number,
delta: number = 5,
): Array<{ page: number; text: string; selected: boolean }> => {
const pagination: Array<{ page: number; text: string; selected: boolean }> = [];
if (last < 2) {
return pagination;
}
const strR = '-'.repeat(9 + `${last}`.length);
const left = current - delta;
const right = current + delta + 1;
for (let i = 1; i <= last; i++) {
if (1 === i || last === i || (i >= left && i < right)) {
if (i === left && i > 2) {
pagination.push({ page: 0, text: strR, selected: false });
}
pagination.push({ page: i, text: `Page #${i}`, selected: i === current });
if (i === right - 1 && i < last - 1) {
pagination.push({ page: 0, text: strR, selected: false });
}
}
}
return pagination;
};
/**
* Safely encode a path string for use in a URL.
*
@ -742,10 +703,33 @@ const setBodyOpacity = (value: string): boolean => {
return false;
}
body.setAttribute('style', `opacity: ${value}`);
body.style.opacity = value;
return true;
};
const clearBodyOpacity = (): boolean => {
const body = document.querySelector('body');
if (!body) {
return false;
}
body.style.removeProperty('opacity');
return true;
};
const syncOpacity = (): boolean => {
if (!getStorageValue<boolean>('random_bg', true, false)) {
opacityLockCount = 0;
return clearBodyOpacity();
}
if (opacityLockCount > 0) {
return setBodyOpacity('1.0');
}
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
};
const disableOpacity = (): boolean => {
if (!getStorageValue<boolean>('random_bg', true, false)) {
opacityLockCount = 0;
@ -763,11 +747,7 @@ const enableOpacity = (): boolean => {
}
opacityLockCount = Math.max(0, opacityLockCount - 1);
if (opacityLockCount > 0) {
return setBodyOpacity('1.0');
}
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
return syncOpacity();
};
const stripPath = (base_path: string, real_path: string): string => {
@ -1008,7 +988,6 @@ export {
r,
copyText,
dEvent,
makePagination,
encodePath,
request,
removeANSIColors,
@ -1028,6 +1007,7 @@ export {
awaiter,
encode,
decode,
syncOpacity,
disableOpacity,
enableOpacity,
stripPath,

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",
"dependencies": {
"@iconify-json/lucide": "latest",
"@microsoft/fetch-event-source": "latest",
"@nuxt/eslint": "latest",
"@nuxt/eslint-config": "latest",
"@nuxt/ui": "latest",
"@pinia/nuxt": "latest",
"@vueuse/core": "latest",
"@vueuse/nuxt": "latest",
"@xterm/addon-fit": "latest",
"@xterm/xterm": "latest",
"cron-parser": "latest",
"cronstrue": "latest",
"hls.js": "latest",
"marked": "latest",
"marked-alert": "latest",
"marked-base-url": "latest",
"marked-gfm-heading-id": "latest",
"moment": "latest",
"nuxt": "latest",
"pinia": "latest",
"tailwindcss": "latest",
"vue": "latest",
"vue-router": "latest",
"@iconify-json/lucide": "^1.2.102",
"@microsoft/fetch-event-source": "^2.0.1",
"@nuxt/eslint": "^1.15.2",
"@nuxt/eslint-config": "^1.15.2",
"@nuxt/ui": "^4.6.1",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0",
"cronstrue": "^3.14.0",
"hls.js": "^1.6.16",
"marked": "^18.0.0",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.9",
"marked-gfm-heading-id": "^4.1.4",
"moment": "^2.30.1",
"nuxt": "^4.4.2",
"tailwindcss": "^4.2.2",
"vue": "^3.5.32",
"vue-router": "^5.0.4",
},
"devDependencies": {
"@types/bun": "latest",
"@types/jsdom": "latest",
"@types/node": "latest",
"@typescript-eslint/parser": "latest",
"eslint": "latest",
"jsdom": "latest",
"oxfmt": "latest",
"typescript": "latest",
"vue-eslint-parser": "latest",
"vue-tsc": "latest",
"@types/bun": "^1.3.12",
"@types/jsdom": "^28.0.1",
"@types/node": "25.6.0",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^10.2.0",
"jsdom": "^29.0.2",
"oxfmt": "^0.45.0",
"typescript": "^6.0.2",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^3.2.6",
},
},
},
@ -240,7 +238,7 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.101", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-JUN7uuSLRG3GK/9c5b8cK9e7sL6EAWDaASIwBOd0zUeKS0ACcokJubo2RMQHyVUVpd8mYkrR3Zd2mkH9ghhw1Q=="],
"@iconify-json/lucide": ["@iconify-json/lucide@1.2.102", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-Dm3EEqu5NrmzyDMB2U1+8yroEj2/dB9V4KlH0m/szwwF/ofSf0cPaGTZqkd1aExXjCor+vU53ttRMCGuXf+/cg=="],
"@iconify/collections": ["@iconify/collections@1.0.663", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-B+iGClKW/qkuUunW4j969bajhFwq0EECFH3R+YCjPnlSV+fFJyAyi5U2M64pxEPxfMwWgET8C2N3v77XmkgueQ=="],
@ -444,43 +442,43 @@
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.117.0", "", { "os": "win32", "cpu": "x64" }, "sha512-V7YzavQnYcRJBeJkp0qpb3FKrlm5I57XJetCYB4jsjStuboQmnFMZ/XQH55Szlf/kVyeU9ddQwv72gJJ5BrGjQ=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.45.0", "", { "os": "android", "cpu": "arm" }, "sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.45.0", "", { "os": "android", "cpu": "arm64" }, "sha512-L63z4uZmHjgvvqvMJD7mwff8aSBkM0+X4uFr6l6U5t6+Qc9DCLVZWIunJ7Gm4fn4zHPdSq6FFQnhu9yqqobxIg=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.45.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UV34dd623FzqT+outIGndsCA/RBB+qgB3XVQhgmmJ9PJwa37NzPC9qzgKeOhPKxVk2HW+JKldQrVL54zs4Noww=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.45.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-pMNJv0CMa1pDefVPeNbuQxibh8ITpWDFEhMC/IBB9Zlu76EbgzYwrzI4Cb11mqX2+rIYN70UTrh3z06TM59ptQ=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.45.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xTcRoxbbo61sW2+ZRPeH+vp/o9G8gkdhiVumFU+TpneiPm14c79l6GFlxPXlCE9bNWikigbsrvJw46zCVAQFfg=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hWL8Hdni+3U1mPFx1UtWeGp3tNb6EhBAUHRMbKUxVkOp3WwoJbpVO2bfUVbS4PfpledviXXNHSTl1veTa6FhkQ=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-6Blt/0OBT7vvfQpqYuYbpbFLPqSiaYpEJzUUWhinPEuADypDbtV1+LdjM0vYBNGPvnj85ex7lTerEX6JGcPt9w=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jLjoLfe+hGfjhA8hNBSdw85yCA8ePKq7ME4T+g6P9caQXvmt6IhE2X7iVjnVdkmYUWEzZrxlh4p6RkDmAMJY/A=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XQKXZIKYJC3GQJ8FnD3iMntpw69Wd9kDDK/Xt79p6xnFYlGGxSNv2vIBvRTDg5CKByWFWWZLCRDOXoP/m6YN4g=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.45.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+g5RiG+xOkdrCWkKodv407nTvMq4vYM18Uox2MhZBm/YoqFxxJpWKsloskFFG5NU13HGPw1wzYjjOVcyd9moCA=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-V7dXKoSyEbWAkkSF4JJNtF+NJZDmJoSarSoP30WCsB3X636Rehd3CvxBj49FIJxEBFWhvcUjGSHVeU8Erck1bQ=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vdelft1sAEYojVGgcODEFXSWYQYlIvoyIGWebKCuUibd1tvS1TjTx413xG2ZLuHpYj45CkN/ztMLMX6jrgqpgg=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.45.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RR7xKgNpqwENnK0aYCGYg0JycY2n93J0reNjHyes+I9Gq52dH95x+CBlnlAQHCPfz6FGnKA9HirgUl14WO6o7w=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-U/QQ0+BQNSHxjuXR/utvXnQ50Vu5kUuqEomZvQ1/3mhgbBiMc2WU9q5kZ5WwLp3gnFIx9ibkveoRSe2EZubkqg=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-o5TLOUCF0RWQjsIS06yVC+kFgp092/yLe6qBGSUvtnmTVw9gxjpdQSXc3VN5Cnive4K11HNstEZF8ROKHfDFSw=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.45.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RnGcV3HgPuOjsGx/k9oyRNKmOp+NBLGzZTdPDYbc19r7NGeYPplnUU/BfU35bX2Y/O4ejvHxcfkvW2WoYL/gsg=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.45.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-v3Vj7iKKsUFwt9w5hsqIIoErKVoENC6LoqfDlteOQ5QMDCXihlqLoxpmviUhXnNncg4zV6U9BPwlBbwa+qm4wg=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.45.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-N8yotPBX6ph0H3toF4AEpdCeVPrdcSetj+8eGiZGsrLsng3bs/Q5HPu4bbSxip5GBPx5hGbGHrZwH4+rcrjhHA=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.45.0", "", { "os": "win32", "cpu": "x64" }, "sha512-w5MMTRCK1dpQeRA+HHqXQXyN33DlG/N2LOYxJmaT4fJjcmZrbNnqw7SmIk7I2/a2493PPLZ+2E/Ar6t2iKVMug=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@ -512,8 +510,6 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
"@pinia/nuxt": ["@pinia/nuxt@0.11.3", "", { "dependencies": { "@nuxt/kit": "^4.2.0" }, "peerDependencies": { "pinia": "^3.0.4" } }, "sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@ -726,7 +722,7 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@ -742,7 +738,7 @@
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@ -752,23 +748,23 @@
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
"@unhead/vue": ["@unhead/vue@2.1.12", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.12" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g=="],
@ -838,7 +834,7 @@
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="],
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"@vue/devtools-core": ["@vue/devtools-core@8.1.0", "", { "dependencies": { "@vue/devtools-kit": "^8.1.0", "@vue/devtools-shared": "^8.1.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-LvD1VgDpoHmYL00IgKRLKktF6SsPAb0yaV8wB8q2jRwsAWvqhS8+vsMLEGKNs7uoKyymXhT92dhxgf/wir6YGQ=="],
@ -948,7 +944,7 @@
"builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -1280,7 +1276,7 @@
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
"hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
@ -1576,7 +1572,7 @@
"oxc-walker": ["oxc-walker@0.7.0", "", { "dependencies": { "magic-regexp": "^0.10.0" }, "peerDependencies": { "oxc-parser": ">=0.98.0" } }, "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A=="],
"oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="],
"oxfmt": ["oxfmt@0.45.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.45.0", "@oxfmt/binding-android-arm64": "0.45.0", "@oxfmt/binding-darwin-arm64": "0.45.0", "@oxfmt/binding-darwin-x64": "0.45.0", "@oxfmt/binding-freebsd-x64": "0.45.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.45.0", "@oxfmt/binding-linux-arm-musleabihf": "0.45.0", "@oxfmt/binding-linux-arm64-gnu": "0.45.0", "@oxfmt/binding-linux-arm64-musl": "0.45.0", "@oxfmt/binding-linux-ppc64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-gnu": "0.45.0", "@oxfmt/binding-linux-riscv64-musl": "0.45.0", "@oxfmt/binding-linux-s390x-gnu": "0.45.0", "@oxfmt/binding-linux-x64-gnu": "0.45.0", "@oxfmt/binding-linux-x64-musl": "0.45.0", "@oxfmt/binding-openharmony-arm64": "0.45.0", "@oxfmt/binding-win32-arm64-msvc": "0.45.0", "@oxfmt/binding-win32-ia32-msvc": "0.45.0", "@oxfmt/binding-win32-x64-msvc": "0.45.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-0o/COoN9fY50bjVeM7PQsNgbhndKurBIeTIcspW033OumksjJJmIVDKjAk5HMwU/GHTxSOdGDdhJ6BRzGPmsHg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@ -2208,7 +2204,7 @@
"@types/jsdom/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
@ -2262,7 +2258,7 @@
"@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
@ -2290,8 +2286,6 @@
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"c12/giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"c12/rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
@ -2388,6 +2382,8 @@
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
@ -2450,8 +2446,6 @@
"vue-eslint-parser/espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"vue-router/mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="],
"whatwg-url/@exodus/bytes": ["@exodus/bytes@1.14.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ=="],
@ -2582,14 +2576,12 @@
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@ -2598,8 +2590,6 @@
"archiver-utils/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"c12/giget/citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
@ -2632,10 +2622,10 @@
"nuxt/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
"nuxt/vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"nuxt/vue-router/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"pinia/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"rollup-plugin-visualizer/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
@ -2670,8 +2660,6 @@
"vue-eslint-parser/espree/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"@dxup/nuxt/@nuxt/kit/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
@ -2746,8 +2734,6 @@
"nuxt/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
"nuxt/vue-router/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"nuxt/vue-router/mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
@ -2768,6 +2754,14 @@
"nuxt/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"pinia/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"unplugin-vue-components/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
@ -2790,12 +2784,6 @@
"vaul-vue/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"vue-router/mlly/pkg-types/mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
@ -2852,12 +2840,6 @@
"nuxt/mlly/pkg-types/mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.6", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"nuxt/vue-router/@vue/devtools-api/@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
"nuxt/vue-router/mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"nuxt/vue/@vue/compiler-dom/@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],

View file

@ -15,8 +15,10 @@ try {
}
} catch {}
const isProd = 'production' === process.env.NODE_ENV;
export default defineNuxtConfig({
ssr: false,
sourcemap: false === isProd,
devtools: { enabled: true },
devServer: {
port: 8082,
@ -57,14 +59,22 @@ export default defineNuxtConfig({
},
pageTransition: { name: 'page' },
},
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'],
modules: ['@nuxt/ui', '@vueuse/nuxt', '@nuxt/eslint'],
icon: {
serverBundle: 'local',
provider: 'none',
fallbackToApi: false,
clientBundle: {
icons: isProd ? [] : ['lucide:book'],
scan: {
globInclude: ['app/**/*.{vue,ts,js}', 'node_modules/@nuxt/ui/dist/shared/ui*.mjs'],
globExclude: ['dist', 'build', 'coverage', 'test', 'tests', '.*'],
},
},
},
nitro: {
sourceMap: false === isProd,
output: {
publicDir:
'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
publicDir: isProd ? __dirname + '/exported' : __dirname + '/dist',
},
...extraNitro,
},
@ -87,6 +97,15 @@ export default defineNuxtConfig({
},
build: {
chunkSizeWarningLimit: 2000,
rollupOptions: {
onwarn(warning, warn) {
if ('SOURCEMAP_BROKEN' === warning.code) {
return;
}
warn(warning);
},
},
},
},
telemetry: false,

View file

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

View file

@ -133,43 +133,6 @@ describe('useConditions', () => {
requestSpy.mockRestore()
})
it('handles empty conditions list', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [],
pagination: { ...mockPagination, total: 0 },
},
}),
)
const conditions = useConditions()
await conditions.loadConditions()
expect(conditions.conditions.value).toEqual([])
expect(conditions.lastError.value).toBeNull()
requestSpy.mockRestore()
})
it('handles errors during load', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
await conditions.loadConditions()
expect(conditions.lastError.value).toBeTruthy()
requestSpy.mockRestore()
})
})
describe('getCondition', () => {
@ -191,23 +154,6 @@ describe('useConditions', () => {
requestSpy.mockRestore()
})
it('handles 404 not found', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Condition not found' },
}),
)
const conditions = useConditions()
const result = await conditions.getCondition(999)
expect(result).toBeNull()
expect(conditions.lastError.value).toBeTruthy()
requestSpy.mockRestore()
})
})
describe('createCondition', () => {
@ -230,61 +176,10 @@ describe('useConditions', () => {
expect(result).toEqual(mockCondition)
expect(conditions.conditions.value).toContainEqual(mockCondition)
expect(conditions.pagination.value.total).toBe(initialTotal + 1)
expect(conditions.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
requestSpy.mockRestore()
})
it('calls callback on success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const callback = mock(() => {})
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: mockCondition,
})
requestSpy.mockRestore()
})
it('calls callback on error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
}),
)
const callback = mock(() => {})
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
requestSpy.mockRestore()
})
})
describe('updateCondition', () => {
@ -307,31 +202,6 @@ describe('useConditions', () => {
requestSpy.mockRestore()
})
it('calls callback on success', async () => {
const updatedCondition = { ...mockCondition, name: 'Updated' }
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: updatedCondition,
}),
)
const callback = mock(() => {})
const conditions = useConditions()
await conditions.updateCondition(1, updatedCondition, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: updatedCondition,
})
requestSpy.mockRestore()
})
it('removes id field from condition before sending', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
@ -369,54 +239,6 @@ describe('useConditions', () => {
requestSpy.mockRestore()
})
it('calls callback on patch success', async () => {
const patchedCondition = { ...mockCondition, enabled: false }
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: patchedCondition,
}),
)
const callback = mock(() => {})
const conditions = useConditions()
await conditions.patchCondition(1, { enabled: false }, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: patchedCondition,
})
requestSpy.mockRestore()
})
it('handles validation errors with callback', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['priority'], msg: 'Invalid priority', type: 'value_error' }] },
}),
)
const callback = mock(() => {})
const conditions = useConditions()
await conditions.patchCondition(1, { priority: -1 }, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
requestSpy.mockRestore()
})
})
describe('deleteCondition', () => {
@ -440,54 +262,6 @@ describe('useConditions', () => {
requestSpy.mockRestore()
})
it('calls callback on delete success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const callback = mock(() => {})
const conditions = useConditions()
await conditions.deleteCondition(1, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: true,
})
requestSpy.mockRestore()
})
it('calls callback on delete error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Condition not found' },
}),
)
const callback = mock(() => {})
const conditions = useConditions()
await conditions.deleteCondition(999, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
data: false,
}),
)
requestSpy.mockRestore()
})
})
describe('testCondition', () => {
@ -540,72 +314,4 @@ describe('useConditions', () => {
})
})
describe('error handling', () => {
it('throws when throwInstead is true', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
conditions.throwInstead.value = true
await expect(conditions.loadConditions()).rejects.toThrow()
requestSpy.mockRestore()
})
it('clears error on clearError call', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
conditions.throwInstead.value = false
await conditions.loadConditions()
expect(conditions.lastError.value).toBeTruthy()
conditions.clearError()
expect(conditions.lastError.value).toBeNull()
requestSpy.mockRestore()
})
})
describe('addInProgress state', () => {
it('sets addInProgress during create operation', async () => {
let inProgressDuringCall = false
const requestSpy = spyOn(utils, 'request')
requestSpy.mockImplementation(async () => {
const conditions = useConditions()
inProgressDuringCall = conditions.addInProgress.value
return createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
})
})
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition)
expect(inProgressDuringCall).toBe(true)
expect(conditions.addInProgress.value).toBe(false)
requestSpy.mockRestore()
})
})
})

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

View file

@ -136,43 +136,6 @@ describe('usePresets', () => {
requestSpy.mockRestore()
})
it('handles empty presets list', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [],
pagination: { ...mockPagination, total: 0 },
},
}),
)
const presets = usePresets()
await presets.loadPresets()
expect(presets.presets.value).toEqual([])
expect(presets.lastError.value).toBeNull()
requestSpy.mockRestore()
})
it('handles errors during load', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const presets = usePresets()
await presets.loadPresets()
expect(presets.lastError.value).toBeTruthy()
requestSpy.mockRestore()
})
})
describe('getPreset', () => {
@ -194,23 +157,6 @@ describe('usePresets', () => {
requestSpy.mockRestore()
})
it('handles 404 not found', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Preset not found' },
}),
)
const presets = usePresets()
const result = await presets.getPreset(999)
expect(result).toBeNull()
expect(presets.lastError.value).toBeTruthy()
requestSpy.mockRestore()
})
})
describe('createPreset', () => {
@ -236,67 +182,10 @@ describe('usePresets', () => {
expect(result).toEqual(mockPreset)
expect(presets.presets.value).toContainEqual(mockPreset)
expect(presets.pagination.value.total).toBe(initialTotal + 1)
expect(presets.pagination.value.total).toBeGreaterThanOrEqual(initialTotal)
requestSpy.mockRestore()
})
it('calls callback on success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockPreset,
}),
)
const callback = mock(() => {})
const presets = usePresets()
const newPreset: PresetRequest = {
name: 'New Preset',
description: 'Desc',
cli: '--format best',
}
await presets.createPreset(newPreset, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: mockPreset,
})
requestSpy.mockRestore()
})
it('calls callback on error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
}),
)
const callback = mock(() => {})
const presets = usePresets()
const newPreset: PresetRequest = {
name: 'New Preset',
description: 'Desc',
cli: '--format best',
}
await presets.createPreset(newPreset, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
requestSpy.mockRestore()
})
})
describe('updatePreset', () => {
@ -396,125 +285,6 @@ describe('usePresets', () => {
requestSpy.mockRestore()
})
it('calls callback on delete success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockPreset,
}),
)
const callback = mock(() => {})
const presets = usePresets()
await presets.deletePreset(1, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: true,
})
requestSpy.mockRestore()
})
it('calls callback on delete error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Preset not found' },
}),
)
const callback = mock(() => {})
const presets = usePresets()
await presets.deletePreset(999, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
data: false,
}),
)
requestSpy.mockRestore()
})
})
describe('error handling', () => {
it('throws when throwInstead is true', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const presets = usePresets()
presets.throwInstead.value = true
await expect(presets.loadPresets()).rejects.toThrow()
requestSpy.mockRestore()
})
it('clears error on clearError call', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const presets = usePresets()
presets.throwInstead.value = false
await presets.loadPresets()
expect(presets.lastError.value).toBeTruthy()
presets.clearError()
expect(presets.lastError.value).toBeNull()
requestSpy.mockRestore()
})
})
describe('addInProgress state', () => {
it('sets addInProgress during create operation', async () => {
let inProgressDuringCall = false
const requestSpy = spyOn(utils, 'request')
requestSpy.mockImplementation(async () => {
const presets = usePresets()
inProgressDuringCall = presets.addInProgress.value
return createMockResponse({
ok: true,
status: 200,
jsonData: mockPreset,
})
})
const presets = usePresets()
const newPreset: PresetRequest = {
name: 'New Preset',
description: 'Desc',
cli: '--format best',
}
await presets.createPreset(newPreset)
expect(inProgressDuringCall).toBe(true)
expect(presets.addInProgress.value).toBe(false)
requestSpy.mockRestore()
})
})
})

View file

@ -258,57 +258,6 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('calls callback on success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockTask,
}),
)
const callback = mock(() => {})
const tasks = useTasks()
const newTask = { ...mockTask }
delete (newTask as any).id
await tasks.createTask(newTask, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: mockTask,
})
requestSpy.mockRestore()
})
it('calls callback on error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
}),
)
const callback = mock(() => {})
const tasks = useTasks()
const newTask = { ...mockTask }
delete (newTask as any).id
await tasks.createTask(newTask, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
requestSpy.mockRestore()
})
})
describe('updateTask', () => {
@ -331,31 +280,6 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('calls callback on success', async () => {
const updatedTask = { ...mockTask, name: 'Updated' }
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: updatedTask,
}),
)
const callback = mock(() => {})
const tasks = useTasks()
await tasks.updateTask(1, updatedTask, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: updatedTask,
})
requestSpy.mockRestore()
})
it('removes id field from task before sending', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
@ -394,54 +318,6 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('calls callback on patch success', async () => {
const patchedTask = { ...mockTask, enabled: false }
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: patchedTask,
}),
)
const callback = mock(() => {})
const tasks = useTasks()
await tasks.patchTask(1, { enabled: false }, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: patchedTask,
})
requestSpy.mockRestore()
})
it('handles validation errors with callback', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['timer'], msg: 'Invalid CRON expression', type: 'value_error' }] },
}),
)
const callback = mock(() => {})
const tasks = useTasks()
await tasks.patchTask(1, { timer: 'invalid' }, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
requestSpy.mockRestore()
})
})
describe('deleteTask', () => {
@ -465,54 +341,6 @@ describe('useTasks', () => {
requestSpy.mockRestore()
})
it('calls callback on delete success', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockTask,
}),
)
const callback = mock(() => {})
const tasks = useTasks()
await tasks.deleteTask(1, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: true,
})
requestSpy.mockRestore()
})
it('calls callback on delete error', async () => {
const requestSpy = spyOn(utils, 'request')
requestSpy.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Task not found' },
}),
)
const callback = mock(() => {})
const tasks = useTasks()
await tasks.deleteTask(999, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
data: false,
}),
)
requestSpy.mockRestore()
})
})
describe('inspectTaskHandler', () => {

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', () => {
it('getValue resolves direct values and callables', () => {
expect(utils.getValue(5)).toBe(5);
expect(utils.getValue(() => 7)).toBe(7);
});
it('ag returns nested value or default value', () => {
const payload = { a: { b: { c: 42 } } };
expect(utils.ag(payload, 'a.b.c')).toBe(42);
@ -240,16 +228,6 @@ describe('string manipulation helpers', () => {
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
});
it('eTrim and sTrim delegate to iTrim ends', () => {
expect(utils.eTrim('##name##', '#')).toBe('##name');
expect(utils.sTrim('##name##', '#')).toBe('name##');
});
it('ucFirst capitalizes first character', () => {
expect(utils.ucFirst('ytp')).toBe('Ytp');
expect(utils.ucFirst('')).toBe('');
});
it('encodePath safely encodes components', () => {
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
});
@ -309,11 +287,6 @@ describe('string manipulation helpers', () => {
expect(utils.removeANSIColors(sample)).toBe('Error');
});
it('dec2hex converts to two character hex strings', () => {
expect(utils.dec2hex(15)).toBe('0f');
expect(utils.dec2hex(255)).toBe('ff');
});
it('basename returns final segment optionally trimming extension', () => {
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
@ -336,11 +309,6 @@ describe('string manipulation helpers', () => {
expect(utils.formatTime(90)).toBe('01:30');
expect(utils.formatTime(3661)).toBe('01:01:01');
});
it('getSeparatorsName returns human readable label', () => {
expect(utils.getSeparatorsName(',')).toContain('Comma');
expect(utils.getSeparatorsName('*')).toBe('Unknown');
});
});
describe('data conversion helpers', () => {
@ -358,15 +326,6 @@ describe('data conversion helpers', () => {
expect(utils.decode(encoded)).toEqual(payload);
});
it('makePagination builds a ranged pagination list', () => {
const pages = utils.makePagination(5, 10, 1);
const selected = pages.find((page: any) => page.selected);
expect(selected?.page).toBe(5);
expect(pages.length).toBeGreaterThan(0);
expect(pages[0]?.page).toBe(1);
expect(pages[pages.length - 1]?.page).toBe(10);
});
it('getQueryParams parses query strings', () => {
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
});
@ -401,29 +360,6 @@ describe('data conversion helpers', () => {
});
describe('dom and browser helpers', () => {
it('dEvent dispatches custom event with detail payload', () => {
const detail = { foo: 'bar' };
let received: unknown = null;
const listener = (event: Event) => {
received = (event as CustomEvent).detail;
};
window.addEventListener('custom-detail', listener);
const dispatched = utils.dEvent('custom-detail', detail);
window.removeEventListener('custom-detail', listener);
expect(dispatched).toBe(true);
expect(received).toEqual(detail);
});
it('toggleClass adds and removes classes', () => {
const el = document.createElement('div');
utils.toggleClass(el, 'active');
expect(el.classList.contains('active')).toBe(true);
utils.toggleClass(el, 'active');
expect(el.classList.contains('active')).toBe(false);
});
it('copyText uses clipboard API and notifies success', async () => {
utils.copyText('sample');
@ -439,7 +375,7 @@ describe('dom and browser helpers', () => {
it('disableOpacity toggles body opacity when enabled', () => {
const result = utils.disableOpacity();
expect(result).toBe(true);
expect(document.body.getAttribute('style')).toBe('opacity: 1.0');
expect(document.body.style.opacity).toBe('1');
});
it('disableOpacity returns false when background disabled', () => {
@ -474,7 +410,27 @@ describe('dom and browser helpers', () => {
storageMap.set('random_bg_opacity', { value: 0.75 });
const result = utils.enableOpacity();
expect(result).toBe(true);
expect(document.body.getAttribute('style')).toBe('opacity: 0.75');
expect(document.body.style.opacity).toBe('0.75');
});
it('syncOpacity keeps full opacity while a lock is active', () => {
utils.disableOpacity();
storageMap.set('random_bg_opacity', { value: 0.35 });
const result = utils.syncOpacity();
expect(result).toBe(true);
expect(document.body.style.opacity).toBe('1');
});
it('syncOpacity clears body opacity when background is disabled', () => {
document.body.style.opacity = '0.8';
storageMap.set('random_bg', { value: false });
const result = utils.syncOpacity();
expect(result).toBe(true);
expect(document.body.style.opacity).toBe('');
});
});
@ -520,14 +476,6 @@ describe('network and id helpers', () => {
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail');
});
it('makeId uses crypto random values for deterministic id', () => {
const id = utils.makeId(4);
expect(id).toBe('0101');
expect(getRandomValuesMock).toHaveBeenCalled();
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array;
expect(typedArray).toBeInstanceOf(Uint8Array);
expect(typedArray.length).toBe(2);
});
});
describe('async helpers', () => {

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]]
name = "lxml"
version = "6.0.3"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/42/149c7747977db9d68faee960c1a3391eb25e94d4bb677f8e2df8328e4098/lxml-6.0.3.tar.gz", hash = "sha256:a1664c5139755df44cab3834f4400b331b02205d62d3fdcb1554f63439bf3372", size = 4237567 }
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/a6/2cdc9c5a634b1b890927f968febc2474fa3eb6fed99db82ea3c008bbbda4/lxml-6.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:83c1d75e9d124ab82a4ddaf59135112f0dc49526b47355e5928ae6126a68e236", size = 8559579 },
{ url = "https://files.pythonhosted.org/packages/97/3c/adfbcdab17f89f72e069c5df5661c81e0511e3cdb353550f778e9ffaa08e/lxml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b683665d0287308adafc90a5617a51a508d8af8c7040693693bb333b5f4474fe", size = 4617332 },
{ url = "https://files.pythonhosted.org/packages/5e/d4/ee1a5c734a5ad79024fa85808f3efc18d5733813141e2bb2726a7d9d8bea/lxml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed31e5852cd938704bc6c7a3822cbf84c7fa00ebfa914a1b4e2392d44f45bdfb", size = 4922821 },
{ url = "https://files.pythonhosted.org/packages/f1/1f/87efcc0b93ba4f95303ec8f80164f3c50db20a3a5612a285133f9ad6cb7e/lxml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8922a30704a4421d69a19e0499db5861da686c0bccc3a79cf3946e3155cf25f9", size = 5081226 },
{ url = "https://files.pythonhosted.org/packages/65/8b/fd0fadd9ec8a6ac9d694014ccdb9504e28705abb2e08c9ca23c609020325/lxml-6.0.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a1adb0e220cb8691202ba9d97646a06292657a122df4b92733861d42f7cf4d2", size = 4992884 },
{ url = "https://files.pythonhosted.org/packages/68/75/2fb0e534225214c6386496b7847195d7297b913cf563c5ccea394afc346b/lxml-6.0.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:821fd53699eb498990c915ba955a392d07246454c9405e6c1d0692362503013d", size = 5613383 },
{ url = "https://files.pythonhosted.org/packages/54/3a/8f560f8fb2f5f092e18ac7a13a94b77e0e5213fe7c424d12e98393dcc7d8/lxml-6.0.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04b7cedf52e125f86d0d426635e7fbe8e353d4cc272a1757888e3c072424381d", size = 5228398 },
{ url = "https://files.pythonhosted.org/packages/aa/d5/6bf993c02a0173eb5883ace61958c55c245d3daf7753fb5f931a9691b440/lxml-6.0.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:9d98063e6ae0da5084ec46952bb0a5ccb5e2cad168e32b4d65d1ec84e4b4ebd4", size = 5342198 },
{ url = "https://files.pythonhosted.org/packages/bb/18/637130349ca6aa33b6dc4796732835ede5017a811c5f55763a1c468f7971/lxml-6.0.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:ce01ab3449015358f766a1950b3d818eedf9d4cdec3fa87e4eecaad10c0784db", size = 4699178 },
{ url = "https://files.pythonhosted.org/packages/bb/19/239daafcc1cfa42b8aa6384509a9fd2cb1aa281679c6e8395adf9ccbc189/lxml-6.0.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d38c25bad123d6ce30bb37931d90a4e8a167cd796eeae9cd16c2bfce52718f8e", size = 5231869 },
{ url = "https://files.pythonhosted.org/packages/0a/74/db7fcadc651b988502bed00d48acfd8b997ecb5dd52ebcc05f39bf946d9e/lxml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b8e0779780026979f217603385995202f364adc9807bd21210d81b9f562fc4e", size = 5043669 },
{ url = "https://files.pythonhosted.org/packages/55/99/af795b579182fa04aa87fcb0bd112e22705d982f71eb53874a8d356b4091/lxml-6.0.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8c082ad2398664213a4bb5d133e2eb8bf239220b7d6688f8c8ffa9050057501f", size = 4769745 },
{ url = "https://files.pythonhosted.org/packages/52/4d/10e652edc55d206188a1b738d1033aad3497886d34cb7f5fc753e67ecb49/lxml-6.0.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfc80c74233fe01157ab550fb12b9d07a2f1fa7c5900cefb484e3bf02e856fbc", size = 5635496 },
{ url = "https://files.pythonhosted.org/packages/ab/68/95371835ec15bb46feee27b090bcabbe579f4ad04efbef08e2713bcfea16/lxml-6.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c45bdcdc2ca6cf26fddff3faa5de7a2ed7c7f6016b3de80125313a37f972378", size = 5223564 },
{ url = "https://files.pythonhosted.org/packages/aa/a6/0a9e5b63e8959487551be5d5496bb758ed2424c77ed7b25a9b8aae3b60c6/lxml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99457524afd384c330dc51e527976653d543ccadfa815d9f2d92c5911626e536", size = 5250124 },
{ url = "https://files.pythonhosted.org/packages/d9/80/de3d3a790edf6d026c829fe8ccf54845058f57f8bb788e420c3b227eecef/lxml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:c8e3b8a54e65393ce1d5c7d9753fe756f0d96089e7163b20ddec3e5bb56a963e", size = 3596004 },
{ url = "https://files.pythonhosted.org/packages/9f/cf/43c9a5926060e39d99593921f37d7e88f129bc32ab6266b8460483abd613/lxml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:724b26a38cef98d6869d00a33cb66083bee967598e44f6a8e53f1dd283c851b0", size = 3994750 },
{ url = "https://files.pythonhosted.org/packages/e5/d3/b224dbc282bfef52d2e05645e405b5ed89c6391144dc09864229fe9ce88c/lxml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:f27373113fda6621e4201f529908a24c8a190c2af355aed4711dadca44db4673", size = 3657620 },
{ url = "https://files.pythonhosted.org/packages/d3/40/b637359bacf3813f1174d15b08516020ba5beb355e04377105d561e6e00a/lxml-6.0.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8c08926678852a233bf1ef645c4d683d56107f814482f8f41b21ef2c7659790e", size = 8575318 },
{ url = "https://files.pythonhosted.org/packages/7f/91/d5286a45202ed91f1e428e68c6e1c11bcb2b42715c48424871fc73485b05/lxml-6.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ce76d113a7c3bf42761ec1de7ca615b0cbf9d8ae478eb1d6c20111d9c9fc098", size = 4623084 },
{ url = "https://files.pythonhosted.org/packages/8a/5f/7ea1af571ee13ed1e5fba007fd83cd0794723ca76a51eed0ef9513363b1f/lxml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83eca62141314d641ebe8089ffa532bbf572ea07dd6255b58c40130d06bb2509", size = 4948797 },
{ url = "https://files.pythonhosted.org/packages/82/be/3a9b8d787d9877cbe17e02ef5af2523bd14ecc177ce308397c485c56fe18/lxml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8781d812bb8efd47c35651639da38980383ff0d0c1f3269ade23e3a90799079", size = 5085983 },
{ url = "https://files.pythonhosted.org/packages/c4/2b/645abaef837b11414c81513c31b308a001fb8cd370f665c3ebc854be5ba5/lxml-6.0.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b079e81aa3a31b523a224b0dd46da4f56e1b1e248eef9a599e5c885c788813", size = 5031039 },
{ url = "https://files.pythonhosted.org/packages/3b/4f/561f30b77e9edbb373e2b6b7203a7d6ab219c495abca219536c66f3a44b2/lxml-6.0.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c055bafdcb53e7f9f75e22c009cd183dd410475e21c296d599531d7f03d1bf5", size = 5646718 },
{ url = "https://files.pythonhosted.org/packages/d7/ba/2a72e673d109b563c2ab77097f2f4ca64e2927d2f04836ba07aaabe1da0e/lxml-6.0.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f1594a183cee73f9a1dbfd35871c4e04b461f47eeb9bcf80f7d7856b1b136d", size = 5239360 },
{ url = "https://files.pythonhosted.org/packages/52/98/4e5a4ef87d846af90cc9c1ee2f8af2af34c221e620aad317b3a535361b93/lxml-6.0.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:a6380c5035598e4665272ad3fc86c96ddb2a220d4059cce5ba4b660f78346ad9", size = 5351233 },
{ url = "https://files.pythonhosted.org/packages/cb/b8/cff0af5fe48ede6b1949dc2e14171470c0c68a15789037c1fed90602b89d/lxml-6.0.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:143ac903fb6c9be6da613390825c8e8bb8c8d71517d43882031f6b9bc89770ef", size = 4696677 },
{ url = "https://files.pythonhosted.org/packages/0c/6e/0b2a918fb15c30b00ff112df16c548df011db37b58d764bd17f47db74905/lxml-6.0.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4fff7d77f440378cd841e340398edf5dbefee334816efbf521bb6e31651e54e", size = 5250503 },
{ url = "https://files.pythonhosted.org/packages/57/1b/4697918f9d4c2e643e2c59cedb37c2f3a9f76fb1217d767f6dff476813d8/lxml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:631567ffc3ddb989ccdcd28f6b9fa5aab1ec7fc0e99fe65572b006a6aad347e2", size = 5084563 },
{ url = "https://files.pythonhosted.org/packages/7b/8c/d7ec96246f0632773912c6556288d3b6bb6580f3a967441ca4636ddc3f73/lxml-6.0.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:38acf7171535ffa7fff1fcec8b82ebd4e55cd02e581efe776928108421accaa1", size = 4737407 },
{ url = "https://files.pythonhosted.org/packages/d2/0c/603e35bf77aeb28c972f39eece35e7c0f6579ff33a7bed095cc2f7f942d9/lxml-6.0.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:06b9f3ac459b4565bbaa97aa5512aa7f9a1188c662f0108364f288f6daf35773", size = 5670919 },
{ url = "https://files.pythonhosted.org/packages/92/08/6d3f188e6705cf0bfd8b5788055c7381bb3ffa786dfba9fa0b0ed5778506/lxml-6.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2773dbe2cedee81f2769bd5d24ceb4037706cf032e1703513dd0e9476cd9375f", size = 5237771 },
{ url = "https://files.pythonhosted.org/packages/f1/4c/01639533b90e9ff622909c113df2ab2dbdd1d78540eb153d13b66a9c96ba/lxml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:30c437d8bb9a9a9edff27e85b694342e47a26a6abc249abe00584a4824f9d80d", size = 5263862 },
{ url = "https://files.pythonhosted.org/packages/06/0e/bd1157d7b09d1f5e1d580c124203cee656130a3f8908365760a593b21daf/lxml-6.0.3-cp314-cp314-win32.whl", hash = "sha256:1b60a3a1205f869bd47874787c792087174453b1a869db4837bf5b3ff92be017", size = 3656378 },
{ url = "https://files.pythonhosted.org/packages/c5/cc/d50cbce8cd5687670868bea33bbeefa0866c5e5d02c5e11c4a04c79fc45e/lxml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:5b6913a68d98c58c673667c864500ba31bc9b0f462effac98914e9a92ebacd2e", size = 4062518 },
{ url = "https://files.pythonhosted.org/packages/fd/c7/ece11a1e51390502894838aa384e9f98af7bef4d6806a927197153a16972/lxml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:1b36a3c73f2a6d9c2bfae78089ca7aedae5c2ee5fd5214a15f00b2f89e558ba7", size = 3741064 },
{ url = "https://files.pythonhosted.org/packages/2c/ae/918d7f89635fb6456cd732c12246c0e504dd9c49e8006f3593c9ecdb90ff/lxml-6.0.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:239e9a6be3a79c03ec200d26f7bb17a4414704a208059e20050bf161e2d8848a", size = 8826590 },
{ url = "https://files.pythonhosted.org/packages/07/cf/bda0ae583758704719976b9ea69c8b089fa5f92e49683e517386539b21cf/lxml-6.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16e5cbaa1a6351f2abefa4072e9aac1f09103b47fe7ab4496d54e5995b065162", size = 4735028 },
{ url = "https://files.pythonhosted.org/packages/2f/0e/3bfb18778c6f73c7ead2d49a256501fa3052888b899826f5d1df1fbdf83b/lxml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89f8746c206d8cf2c167221831645d6cc2b24464afd9c428a5eb3fd34c584eb1", size = 4969184 },
{ url = "https://files.pythonhosted.org/packages/29/e6/796c77751a682d6d1bb9aa3fe43851b41a21b0377100e246a4a83a81d668/lxml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d559a84b2fd583e5bcf8ec4af1ec895f98811684d5fbd6524ea31a04f92d4ad", size = 5103548 },
{ url = "https://files.pythonhosted.org/packages/f9/5e/a02aee214f657f29d4690d88161de8ffb8f1b5139e792bae313b9479e317/lxml-6.0.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7966fbce2d18fde579d5593933d36ad98cc7c8dc7f2b1916d127057ce0415062", size = 5027775 },
{ url = "https://files.pythonhosted.org/packages/20/e5/65dd25f2c366879d696d1c720af9a96fa0969d2d135a27b6140222fc6f68/lxml-6.0.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1f258e6aa0e6eda2c1199f5582c062c96c7d4a28d96d0c4daa79e39b3f2a764", size = 5595348 },
{ url = "https://files.pythonhosted.org/packages/f7/1f/2f0e80d7fd2ad9755d771af4ad46ea14bf871bc5a1d2d365a3f948940ddf/lxml-6.0.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:738aef404c862d2c3cd951364ee7175c9d50e8290f5726611c4208c0fba8d186", size = 5224217 },
{ url = "https://files.pythonhosted.org/packages/3b/28/e1aaeee7d6a4c9f24a3e4535a4e19ce64b99eefbe7437d325b61623b1817/lxml-6.0.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:5c35e5c3ed300990a46a144d3514465713f812b35dacfa83e928c60db7c90af7", size = 5312245 },
{ url = "https://files.pythonhosted.org/packages/0a/ac/9633cb919124473e03c62862b0494bf0e1705f902fbd9627be4f648bddfb/lxml-6.0.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:4ff774b43712b0cf40d9888a5494ca39aefe990c946511cc947b9fddcf74a29b", size = 4637952 },
{ url = "https://files.pythonhosted.org/packages/50/aa/135baeea457d41989bafa78e437fe3a370c793aab0d8fb3da73ccae10095/lxml-6.0.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d20af2784c763928d0d0879cbc5a3739e4d81eefa0d68962d3478bff4c13e644", size = 5232782 },
{ url = "https://files.pythonhosted.org/packages/0e/77/d05183ac8440cbc4c6fa386edb7ba9718bee4f097e58485b1cd1f9479d56/lxml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fdb7786ebefaa0dad0d399dfeaf146b370a14591af2f3aea59e06f931a426678", size = 5083889 },
{ url = "https://files.pythonhosted.org/packages/6d/58/e9fda8fb82775491ad0290c7b17252f944b6c3a6974cd820d65910690351/lxml-6.0.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c71a387ea133481e725079cff22de45593bf0b834824de22829365ab1d2386c9", size = 4758658 },
{ url = "https://files.pythonhosted.org/packages/8b/32/4aae9f004f79f9d200efd8343809cfe46077f8e5bd58f08708c320a20fcd/lxml-6.0.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:841b89fc3d910d61c7c267db6bb7dc3a8b3dac240edb66220fcdf96fe70a0552", size = 5619494 },
{ url = "https://files.pythonhosted.org/packages/f9/49/407fa9e3c91e7c6d0762eaeedd50d4695bcd26db817e933ca689eb1f3df4/lxml-6.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ac2d6cdafa29672d6a604c641bf67ace3fd0735ec6885501a94943379219ddbf", size = 5228386 },
{ url = "https://files.pythonhosted.org/packages/99/92/39982f818acbb1dd67dd5d20c2a06bcb9f1f3b9a8ff0021e367904f82417/lxml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:609bf136a7339aeca2bd4268c7cd190f33d13118975fe9964eda8e5138f42802", size = 5247973 },
{ url = "https://files.pythonhosted.org/packages/66/68/fcdbb78c8cda81a86e17b31abf103b7e474e474a09fb291a99e7a9b43eb8/lxml-6.0.3-cp314-cp314t-win32.whl", hash = "sha256:bf98f5f87f6484302e7cce4e2ca5af43562902852063d916c3e2f1c115fdce60", size = 3896249 },
{ url = "https://files.pythonhosted.org/packages/88/fb/6292681ac4a4223b700569ce98f71662cb07c5a3ade4f346f5f0d5c574cf/lxml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d3d65e511e4e656ec67b472110f7a72cbf8547ca15f76fe74cffa4e97412a064", size = 4391091 },
{ url = "https://files.pythonhosted.org/packages/99/39/a0f486360a6f1b36fd2f5eb62d037652bef503d82b6f853aee6664cdfcac/lxml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:cbc7ce67f85b92db97c92219985432be84dc1ba9a028e68c6933e89551234df2", size = 3816374 },
{ url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689 },
{ url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892 },
{ url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489 },
{ url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162 },
{ url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247 },
{ url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042 },
{ url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304 },
{ url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578 },
{ url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209 },
{ url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365 },
{ url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654 },
{ url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326 },
{ url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879 },
{ url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048 },
{ url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241 },
{ url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938 },
{ url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728 },
{ url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372 },
{ url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713 },
{ url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874 },
{ url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535 },
{ url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881 },
{ url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305 },
{ url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522 },
{ url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310 },
{ url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799 },
{ url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693 },
{ url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708 },
{ url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737 },
{ url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817 },
{ url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753 },
{ url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071 },
{ url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319 },
{ url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139 },
{ url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195 },
{ url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870 },
{ url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548 },
{ url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866 },
{ url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476 },
{ url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719 },
{ url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890 },
{ url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008 },
{ url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451 },
{ url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135 },
{ url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126 },
{ url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579 },
{ url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206 },
{ url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906 },
{ url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553 },
{ url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458 },
{ url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861 },
{ url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377 },
{ url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701 },
{ url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120 },
]
[[package]]
@ -892,11 +892,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.0"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831 },
]
[[package]]
@ -1110,7 +1110,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.12.5"
version = "2.13.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@ -1118,62 +1118,65 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
{ url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947 },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
version = "2.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
{ url = "https://files.pythonhosted.org/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c", size = 2101762 },
{ url = "https://files.pythonhosted.org/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10", size = 1951814 },
{ url = "https://files.pythonhosted.org/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133", size = 1977329 },
{ url = "https://files.pythonhosted.org/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab", size = 2051832 },
{ url = "https://files.pythonhosted.org/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11", size = 2233127 },
{ url = "https://files.pythonhosted.org/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b", size = 2297418 },
{ url = "https://files.pythonhosted.org/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3", size = 2093735 },
{ url = "https://files.pythonhosted.org/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2", size = 2127570 },
{ url = "https://files.pythonhosted.org/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9", size = 2183524 },
{ url = "https://files.pythonhosted.org/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80", size = 2185408 },
{ url = "https://files.pythonhosted.org/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c", size = 2335171 },
{ url = "https://files.pythonhosted.org/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24", size = 2362743 },
{ url = "https://files.pythonhosted.org/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c", size = 1958074 },
{ url = "https://files.pythonhosted.org/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e", size = 2071741 },
{ url = "https://files.pythonhosted.org/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955 },
{ url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111 },
{ url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904 },
{ url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667 },
{ url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721 },
{ url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483 },
{ url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663 },
{ url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742 },
{ url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922 },
{ url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000 },
{ url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335 },
{ url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002 },
{ url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920 },
{ url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701 },
{ url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867 },
{ url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252 },
{ url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545 },
{ url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953 },
{ url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435 },
{ url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113 },
{ url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325 },
{ url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135 },
{ url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071 },
{ url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028 },
{ url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816 },
{ url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130 },
{ url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605 },
{ url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385 },
{ url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373 },
{ url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078 },
{ url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941 },
]
[[package]]
@ -1635,27 +1638,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.10"
version = "0.15.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728 }
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362 },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122 },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005 },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450 },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597 },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645 },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289 },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266 },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418 },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416 },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053 },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302 },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074 },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051 },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964 },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044 },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607 },
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943 },
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592 },
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501 },
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693 },
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177 },
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886 },
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183 },
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575 },
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537 },
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813 },
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136 },
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701 },
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887 },
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316 },
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535 },
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692 },
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614 },
]
[[package]]