Merge pull request #579 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled
Feat: Rebase the UI on top of nuxt/ui
This commit is contained in:
commit
4bea8a315a
92 changed files with 16060 additions and 56447 deletions
6
API.md
6
API.md
|
|
@ -1771,6 +1771,8 @@ Binary image data with appropriate headers
|
||||||
**Query Parameters**:
|
**Query Parameters**:
|
||||||
- `page` (optional): Page number (1-indexed). Default: `1`.
|
- `page` (optional): Page number (1-indexed). Default: `1`.
|
||||||
- `per_page` (optional): Items per page. Default: `config.default_pagination`.
|
- `per_page` (optional): Items per page. Default: `config.default_pagination`.
|
||||||
|
- `sort` (optional): Comma-separated sort fields. Accepted values: `id`, `name`, `priority`, `default`, `created_at`, `updated_at`. Default: `priority,name`.
|
||||||
|
- `order` (optional): Comma-separated sort directions matching `sort`, or a single direction applied to every requested sort field. Accepted values: `asc`, `desc`. Default: `desc,asc`.
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
```json
|
```json
|
||||||
|
|
@ -1800,6 +1802,10 @@ Binary image data with appropriate headers
|
||||||
|
|
||||||
**Notes**:
|
**Notes**:
|
||||||
- `default: true` indicates this is a system default preset (cannot be modified or deleted)
|
- `default: true` indicates this is a system default preset (cannot be modified or deleted)
|
||||||
|
- Default ordering remains `priority desc, name asc`
|
||||||
|
|
||||||
|
**Error Responses**:
|
||||||
|
- `400 Bad Request` - Invalid pagination or sorting query parameters
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ from app.library.Services import Services
|
||||||
from app.library.Singleton import Singleton
|
from app.library.Singleton import Singleton
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import Callable
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from sqlalchemy.engine.result import Result
|
from sqlalchemy.engine.result import Result
|
||||||
|
|
@ -24,13 +25,34 @@ if TYPE_CHECKING:
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
from sqlalchemy.sql.selectable import Select
|
from sqlalchemy.sql.selectable import Select
|
||||||
|
|
||||||
|
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PresetsRepository(metaclass=Singleton):
|
class PresetsRepository(metaclass=Singleton):
|
||||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
SORT_FIELDS: dict[str, Any] = {
|
||||||
|
"id": PresetModel.id,
|
||||||
|
"name": PresetModel.name,
|
||||||
|
"priority": PresetModel.priority,
|
||||||
|
"default": PresetModel.default,
|
||||||
|
"created_at": PresetModel.created_at,
|
||||||
|
"updated_at": PresetModel.updated_at,
|
||||||
|
}
|
||||||
|
SORT_DIRECTIONS: tuple[str, str] = ("asc", "desc")
|
||||||
|
DEFAULT_SORT_ORDER: tuple[tuple[str, str], ...] = (("priority", "desc"), ("name", "asc"))
|
||||||
|
FIELD_DEFAULT_DIRECTIONS: dict[str, str] = {
|
||||||
|
"id": "asc",
|
||||||
|
"name": "asc",
|
||||||
|
"priority": "desc",
|
||||||
|
"default": "desc",
|
||||||
|
"created_at": "desc",
|
||||||
|
"updated_at": "desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||||
self._migrated = False
|
self._migrated = False
|
||||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
self.session: SessionFactory = session or get_session
|
||||||
|
|
||||||
async def run_migrations(self) -> None:
|
async def run_migrations(self) -> None:
|
||||||
if self._migrated:
|
if self._migrated:
|
||||||
|
|
@ -82,7 +104,85 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[PresetModel], int, int, int]:
|
@classmethod
|
||||||
|
def parse_sorting(cls, sort: str | None = None, order: str | None = None) -> tuple[tuple[str, str], ...]:
|
||||||
|
sort_value = (sort or "").strip()
|
||||||
|
order_value = (order or "").strip()
|
||||||
|
|
||||||
|
if not sort_value and not order_value:
|
||||||
|
return cls.DEFAULT_SORT_ORDER
|
||||||
|
|
||||||
|
if order_value and not sort_value:
|
||||||
|
msg = "sort is required when order is provided."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
fields: list[str] = []
|
||||||
|
for raw_field in sort_value.split(","):
|
||||||
|
field = raw_field.strip().lower()
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
if field not in cls.SORT_FIELDS:
|
||||||
|
msg = f"sort must use supported fields: {', '.join(cls.SORT_FIELDS)}."
|
||||||
|
raise ValueError(msg)
|
||||||
|
if field not in fields:
|
||||||
|
fields.append(field)
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
msg = "sort must include at least one field."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
directions: list[str] = []
|
||||||
|
if order_value:
|
||||||
|
for raw_direction in order_value.split(","):
|
||||||
|
direction = raw_direction.strip().lower()
|
||||||
|
if not direction:
|
||||||
|
continue
|
||||||
|
if direction not in cls.SORT_DIRECTIONS:
|
||||||
|
msg = "order must be 'asc' or 'desc'."
|
||||||
|
raise ValueError(msg)
|
||||||
|
directions.append(direction)
|
||||||
|
|
||||||
|
if not directions:
|
||||||
|
msg = "order must include at least one direction."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
if len(directions) not in {1, len(fields)}:
|
||||||
|
msg = "order must provide one direction or match the number of sort fields."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
if not directions:
|
||||||
|
return tuple((field, cls.FIELD_DEFAULT_DIRECTIONS.get(field, "asc")) for field in fields)
|
||||||
|
|
||||||
|
if len(directions) == 1:
|
||||||
|
return tuple((field, directions[0]) for field in fields)
|
||||||
|
|
||||||
|
return tuple((field, directions[index]) for index, field in enumerate(fields))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _apply_sort_direction(cls, field: str, direction: str) -> Any:
|
||||||
|
column = cls.SORT_FIELDS[field]
|
||||||
|
return column.asc() if direction == "asc" else column.desc()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_order_by(cls, sort: str | None = None, order: str | None = None) -> list[Any]:
|
||||||
|
sorting = cls.parse_sorting(sort, order)
|
||||||
|
|
||||||
|
order_by: list[Any] = [cls._apply_sort_direction(field, direction) for field, direction in sorting]
|
||||||
|
|
||||||
|
if all(field != "id" for field, _ in sorting):
|
||||||
|
order_by.append(PresetModel.id.asc())
|
||||||
|
|
||||||
|
return order_by
|
||||||
|
|
||||||
|
async def list_paginated(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
sort: str | None = None,
|
||||||
|
order: str | None = None,
|
||||||
|
) -> tuple[list[PresetModel], int, int, int]:
|
||||||
|
order_by = self._build_order_by(sort, order)
|
||||||
|
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
total: int = await self.count()
|
total: int = await self.count()
|
||||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||||
|
|
@ -91,10 +191,7 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
page = total_pages
|
page = total_pages
|
||||||
|
|
||||||
query: Select[tuple[PresetModel]] = (
|
query: Select[tuple[PresetModel]] = (
|
||||||
select(PresetModel)
|
select(PresetModel).order_by(*order_by).limit(per_page).offset((page - 1) * per_page)
|
||||||
.order_by(PresetModel.priority.desc(), PresetModel.name.asc())
|
|
||||||
.limit(per_page)
|
|
||||||
.offset((page - 1) * per_page)
|
|
||||||
)
|
)
|
||||||
result: Result[tuple[PresetModel]] = await session.execute(query)
|
result: Result[tuple[PresetModel]] = await session.execute(query)
|
||||||
return list(result.scalars().all()), total, page, total_pages
|
return list(result.scalars().all()), total, page, total_pages
|
||||||
|
|
@ -136,9 +233,25 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
|
|
||||||
async def create(self, payload: PresetModel | dict) -> PresetModel:
|
async def create(self, payload: PresetModel | dict) -> PresetModel:
|
||||||
async with self.session() as session:
|
async with self.session() as session:
|
||||||
model: PresetModel = PresetModel(**payload) if isinstance(payload, dict) else payload
|
data: dict[str, Any]
|
||||||
if model.id is not None:
|
if isinstance(payload, dict):
|
||||||
model.id = None
|
data = dict(payload)
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
"name": payload.name,
|
||||||
|
"description": payload.description,
|
||||||
|
"folder": payload.folder,
|
||||||
|
"template": payload.template,
|
||||||
|
"cookies": payload.cookies,
|
||||||
|
"cli": payload.cli,
|
||||||
|
"default": payload.default,
|
||||||
|
"priority": payload.priority,
|
||||||
|
"created_at": payload.created_at,
|
||||||
|
"updated_at": payload.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pop("id", None)
|
||||||
|
model = PresetModel(**data)
|
||||||
|
|
||||||
model.name = preset_name(model.name)
|
model.name = preset_name(model.name)
|
||||||
|
|
||||||
|
|
@ -163,12 +276,10 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||||
model: PresetModel | None = result.scalar_one_or_none()
|
model: PresetModel | None = result.scalar_one_or_none()
|
||||||
|
|
||||||
if None is model:
|
if model is None:
|
||||||
msg: str = f"Preset '{identifier}' not found."
|
msg: str = f"Preset '{identifier}' not found."
|
||||||
raise KeyError(msg)
|
raise KeyError(msg)
|
||||||
|
|
||||||
assert None is not model
|
|
||||||
|
|
||||||
payload.pop("id", None)
|
payload.pop("id", None)
|
||||||
payload.pop("created_at", None)
|
payload.pop("created_at", None)
|
||||||
payload.pop("updated_at", None)
|
payload.pop("updated_at", None)
|
||||||
|
|
@ -177,9 +288,10 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
if hasattr(model, key):
|
if hasattr(model, key):
|
||||||
setattr(model, key, value)
|
setattr(model, key, value)
|
||||||
|
|
||||||
model.name = preset_name(model.name)
|
normalized_name = preset_name(model.name)
|
||||||
if await self.get_by_name(name=model.name, exclude_id=model.id) is not None:
|
model.name = normalized_name
|
||||||
msg = f"Preset with name '{model.name}' already exists."
|
if await self.get_by_name(name=normalized_name, exclude_id=model.id) is not None:
|
||||||
|
msg = f"Preset with name '{normalized_name}' already exists."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -196,12 +308,11 @@ class PresetsRepository(metaclass=Singleton):
|
||||||
clause = PresetModel.name == preset_name(identifier)
|
clause = PresetModel.name == preset_name(identifier)
|
||||||
|
|
||||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||||
if not (model := result.scalar_one_or_none()):
|
model: PresetModel | None = result.scalar_one_or_none()
|
||||||
|
if model is None:
|
||||||
msg: str = f"Preset '{identifier}' not found."
|
msg: str = f"Preset '{identifier}' not found."
|
||||||
raise KeyError(msg)
|
raise KeyError(msg)
|
||||||
|
|
||||||
assert None is not model
|
|
||||||
|
|
||||||
await session.delete(model)
|
await session.delete(model)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return model
|
return model
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,17 @@ def _serialize(model: Any) -> dict:
|
||||||
|
|
||||||
@route("GET", "api/presets/", "presets")
|
@route("GET", "api/presets/", "presets")
|
||||||
async def presets_list(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
async def presets_list(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
||||||
|
try:
|
||||||
page, per_page = normalize_pagination(request)
|
page, per_page = normalize_pagination(request)
|
||||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
items, total, current_page, total_pages = await repo.list_paginated(
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
sort=request.query.get("sort"),
|
||||||
|
order=request.query.get("order"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data=PresetList(
|
data=PresetList(
|
||||||
items=[_model(model) for model in items],
|
items=[_model(model) for model in items],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web import Request
|
||||||
|
|
||||||
from app.features.presets.repository import PresetsRepository
|
from app.features.presets.repository import PresetsRepository
|
||||||
|
from app.features.presets.router import presets_list
|
||||||
|
from app.library.encoder import Encoder
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,3 +76,80 @@ class TestPresetsRepository:
|
||||||
assert total == 5, "Should report total count"
|
assert total == 5, "Should report total count"
|
||||||
assert page == 1, "Should be on page 1"
|
assert page == 1, "Should be on page 1"
|
||||||
assert total_pages == 3, "Should have 3 pages total"
|
assert total_pages == 3, "Should have 3 pages total"
|
||||||
|
assert [item.priority for item in items] == [4, 3], "Should keep default priority-desc order"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated_sorts_by_name_desc(self, repo):
|
||||||
|
await repo.create({"name": "Alpha", "priority": 1})
|
||||||
|
await repo.create({"name": "Gamma", "priority": 3})
|
||||||
|
await repo.create({"name": "Beta", "priority": 2})
|
||||||
|
|
||||||
|
items, _, _, _ = await repo.list_paginated(page=1, per_page=10, sort="name", order="desc")
|
||||||
|
|
||||||
|
assert [item.name for item in items] == ["gamma", "beta", "alpha"], "Should sort by requested field"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated_supports_multiple_sort_fields(self, repo):
|
||||||
|
await repo.create({"name": "Charlie", "priority": 2})
|
||||||
|
await repo.create({"name": "Alpha", "priority": 1})
|
||||||
|
await repo.create({"name": "Bravo", "priority": 1})
|
||||||
|
|
||||||
|
items, _, _, _ = await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc")
|
||||||
|
|
||||||
|
assert [(item.priority, item.name) for item in items] == [
|
||||||
|
(1, "bravo"),
|
||||||
|
(1, "alpha"),
|
||||||
|
(2, "charlie"),
|
||||||
|
], "Should support multiple sort fields and directions"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated_rejects_invalid_sort_field(self, repo):
|
||||||
|
with pytest.raises(ValueError, match="sort must use supported fields"):
|
||||||
|
await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated_rejects_invalid_sort_direction(self, repo):
|
||||||
|
with pytest.raises(ValueError, match="order must be 'asc' or 'desc'"):
|
||||||
|
await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated_rejects_mismatched_sort_and_order_lengths(self, repo):
|
||||||
|
with pytest.raises(ValueError, match="order must provide one direction or match the number of sort fields"):
|
||||||
|
await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestPresetRoutes:
|
||||||
|
async def test_list_route_supports_sort_params(self, repo):
|
||||||
|
await repo.create({"name": "Alpha", "priority": 1})
|
||||||
|
await repo.create({"name": "Bravo", "priority": 1})
|
||||||
|
await repo.create({"name": "Charlie", "priority": 2})
|
||||||
|
|
||||||
|
request = MagicMock(spec=Request)
|
||||||
|
request.query = {"page": "1", "per_page": "10", "sort": "priority,name", "order": "asc,desc"}
|
||||||
|
|
||||||
|
response = await presets_list(request, Encoder(), repo)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid sorting"
|
||||||
|
assert [item["name"] for item in payload["items"]] == ["bravo", "alpha", "charlie"], "Should sort response"
|
||||||
|
|
||||||
|
async def test_list_route_rejects_invalid_sort_field(self, repo):
|
||||||
|
request = MagicMock(spec=Request)
|
||||||
|
request.query = {"sort": "cli", "order": "asc"}
|
||||||
|
|
||||||
|
response = await presets_list(request, Encoder(), repo)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort field"
|
||||||
|
assert "sort" in payload["error"], "Should explain invalid sort field"
|
||||||
|
|
||||||
|
async def test_list_route_rejects_invalid_sort_direction(self, repo):
|
||||||
|
request = MagicMock(spec=Request)
|
||||||
|
request.query = {"sort": "name", "order": "sideways"}
|
||||||
|
|
||||||
|
response = await presets_list(request, Encoder(), repo)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction"
|
||||||
|
assert "order" in payload["error"], "Should explain invalid sort direction"
|
||||||
|
|
|
||||||
BIN
sc_short.jpg
BIN
sc_short.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 339 KiB |
BIN
sc_simple.jpg
BIN
sc_simple.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 176 KiB |
11
ui/app/app.config.ts
Normal file
11
ui/app/app.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
primary: 'indigo',
|
||||||
|
colors: {
|
||||||
|
primary: 'indigo',
|
||||||
|
secondary: 'amber',
|
||||||
|
success: 'emerald',
|
||||||
|
neutral: 'stone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
31052
ui/app/assets/css/bulma.css
vendored
31052
ui/app/assets/css/bulma.css
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -1,374 +0,0 @@
|
||||||
* {
|
|
||||||
unicode-bidi: plaintext;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 1em;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container,
|
|
||||||
.card,
|
|
||||||
.box,
|
|
||||||
.navbar {
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-bordered-danger {
|
|
||||||
border: 1px solid red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-bordered-info {
|
|
||||||
border: 1px solid #3e8ed0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: #eaeaea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-unselectable {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-text-overflow {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-tooltip {
|
|
||||||
cursor: help;
|
|
||||||
border-bottom: 1px dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.has-tooltip {
|
|
||||||
border-bottom-color: #000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
* {
|
|
||||||
unicode-bidi: plaintext;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-tooltip {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 1em;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container,
|
|
||||||
.card,
|
|
||||||
.box,
|
|
||||||
.navbar {
|
|
||||||
border-radius: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-bordered-danger {
|
|
||||||
border: 1px solid red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-bordered-info {
|
|
||||||
border: 1px solid #3e8ed0;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background-color: #0f1010;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-unselectable {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-text-overflow {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-borderless {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-marginless {
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-paddingless {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
padding: 3rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section.is-medium {
|
|
||||||
padding: 9rem 4.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section.is-large {
|
|
||||||
padding: 18rem 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
background-color: #fafafa;
|
|
||||||
padding: 3rem 1.5rem 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 30px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress,
|
|
||||||
.progress-percentage {
|
|
||||||
border-radius: 15px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-percentage {
|
|
||||||
text-align: center;
|
|
||||||
z-index: 2;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
z-index: 1;
|
|
||||||
background-color: #00d1b2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.is-purple {
|
|
||||||
background-color: #5f00d1;
|
|
||||||
border-color: transparent;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-text-purple {
|
|
||||||
color: #5f00d1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress,
|
|
||||||
.progress-percentage {
|
|
||||||
border-radius: unset !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.footer {
|
|
||||||
background-color: #121212;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
border-radius: 15px;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 30px;
|
|
||||||
background-color: #383636;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress,
|
|
||||||
.progress-percentage {
|
|
||||||
border-radius: 15px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-percentage {
|
|
||||||
text-align: center;
|
|
||||||
z-index: 2;
|
|
||||||
line-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
z-index: 1;
|
|
||||||
background-color: #087363;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-notification {
|
|
||||||
background: unset;
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-title {
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vue-notification-wrapper {
|
|
||||||
cursor: pointer;
|
|
||||||
padding-top: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-overlay,
|
|
||||||
.is-pointer {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-icon,
|
|
||||||
.embed-icon {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
|
|
||||||
background-image: url(/images/play-icon.png);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 100% 100%;
|
|
||||||
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-icon {
|
|
||||||
background-image: url(/images/embed-icon.png);
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-icon:hover,
|
|
||||||
.play-icon:focus,
|
|
||||||
.play-active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-portrait {
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-pre {
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-pre-wrap {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-pre-wrap-force {
|
|
||||||
white-space: pre-wrap !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-text-bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transparent-bg {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-fanart {
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-attachment: fixed;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-blend-mode: darken;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-unbounded-model {
|
|
||||||
max-height: unset !important;
|
|
||||||
width: unset !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-auto {
|
|
||||||
unicode-bidi: embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-justify-self-end {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-full-height {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-ellipsis {
|
|
||||||
max-width: calc(100% - 10px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-max-width {
|
|
||||||
max-width: calc(100% - 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fa-spin-10 {
|
|
||||||
--fa-animation-iteration-count: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Vue-Toastification__toast-body {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-word-break {
|
|
||||||
word-break: break-word;
|
|
||||||
text-wrap: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
table.is-fixed {
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.is-centered {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-overflow-visible {
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content-max {
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
max-width: 1344px;
|
|
||||||
min-width: 320px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-rounded-less {
|
|
||||||
border-radius: 0px !important;
|
|
||||||
}
|
|
||||||
121
ui/app/assets/css/tailwind.css
Normal file
121
ui/app/assets/css/tailwind.css
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@import '@nuxt/ui';
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__nuxt {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.bg-fanart {
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.simple-mode.bg-fanart,
|
||||||
|
html.simple-mode.bg-fanart body,
|
||||||
|
html.simple-mode.bg-fanart #__nuxt {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.simple-mode.bg-fanart body,
|
||||||
|
html.simple-mode.bg-fanart #__nuxt {
|
||||||
|
background-image: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.play-overlay {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(0 0 0 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 0.18);
|
||||||
|
transition: background-color 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay:hover::after {
|
||||||
|
background: rgb(0 0 0 / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay:hover img {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
height: 3.5rem;
|
||||||
|
width: 3.5rem;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 0.62);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgb(0 0 0 / 0.24);
|
||||||
|
box-shadow: 0 0.5rem 1.25rem rgb(0 0 0 / 0.16);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
transition:
|
||||||
|
transform 0.22s ease,
|
||||||
|
background-color 0.22s ease,
|
||||||
|
border-color 0.22s ease,
|
||||||
|
box-shadow 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay:hover .play-icon {
|
||||||
|
transform: translate(-50%, -50%) scale(1.05);
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
border-color: rgb(255 255 255 / 0.82);
|
||||||
|
box-shadow: 0 0.75rem 1.75rem rgb(0 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon.embed-icon {
|
||||||
|
border-color: rgb(239 68 68 / 0.68);
|
||||||
|
background: rgb(127 29 29 / 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(239 68 68 / 0.2),
|
||||||
|
0 0.5rem 1.25rem rgb(0 0 0 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay:hover .play-icon.embed-icon {
|
||||||
|
background: rgb(127 29 29 / 0.74);
|
||||||
|
border-color: rgb(239 68 68 / 0.95);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(239 68 68 / 0.3),
|
||||||
|
0 0.75rem 1.75rem rgb(0 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--ui-radius: 0.5rem;
|
||||||
|
--ui-container: 96rem;
|
||||||
|
--ui-header-height: 4.25rem;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,133 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="visible" class="modal is-active">
|
|
||||||
<div class="modal-background" @click="cancel" />
|
|
||||||
<div class="modal-card">
|
|
||||||
<header class="modal-card-head">
|
|
||||||
<p class="modal-card-title">{{ title }}</p>
|
|
||||||
<button class="delete" aria-label="close" @click="cancel" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="modal-card-body">
|
|
||||||
<p class="mb-3 title is-5" v-if="!html_message">{{ message }}</p>
|
|
||||||
<div
|
|
||||||
class="content"
|
|
||||||
v-if="html_message"
|
|
||||||
v-html="html_message"
|
|
||||||
style="max-height: 40vh; overflow: auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="options?.length">
|
|
||||||
<hr class="" />
|
|
||||||
<label
|
|
||||||
v-for="opt in options"
|
|
||||||
:key="opt.key"
|
|
||||||
class="checkbox is-block mb-2 is-unselectable"
|
|
||||||
>
|
|
||||||
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
|
|
||||||
{{ opt.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="modal-card-foot p-5">
|
|
||||||
<div class="field is-grouped" style="width: 100%">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<button
|
|
||||||
ref="confirmBtn"
|
|
||||||
class="button is-fullwidth"
|
|
||||||
:class="confirm_button_color"
|
|
||||||
@click="handleConfirm"
|
|
||||||
>
|
|
||||||
{{ confirm_button_label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<button
|
|
||||||
class="button is-success is-fullwidth"
|
|
||||||
:class="cancel_button_color"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
{{ cancel_button_label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: 'Confirm',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: 'Are you sure?',
|
|
||||||
},
|
|
||||||
html_message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
type: Array as () => Array<{ key: string; label: string; checked?: boolean }>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
confirm_button_label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Confirm',
|
|
||||||
},
|
|
||||||
cancel_button_label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Cancel',
|
|
||||||
},
|
|
||||||
confirm_button_color: {
|
|
||||||
type: String,
|
|
||||||
default: 'is-danger',
|
|
||||||
},
|
|
||||||
cancel_button_color: {
|
|
||||||
type: String,
|
|
||||||
default: 'is-success',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'confirm', options: Record<string, boolean>): void;
|
|
||||||
(e: 'cancel'): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const confirmBtn = ref<HTMLButtonElement | null>(null);
|
|
||||||
const selected = reactive<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
async (visible) => {
|
|
||||||
if (!visible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.options) {
|
|
||||||
for (const opt of props.options) {
|
|
||||||
selected[opt.key] ??= false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
confirmBtn.value?.focus();
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirm = () => emit('confirm', { ...selected });
|
|
||||||
const cancel = () => emit('cancel');
|
|
||||||
|
|
||||||
onMounted(() => disableOpacity());
|
|
||||||
onBeforeUnmount(() => enableOpacity());
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="message is-warning" v-if="status !== 'connected'">
|
|
||||||
<div class="message-body">
|
|
||||||
<span class="icon"
|
|
||||||
><i
|
|
||||||
class="fas"
|
|
||||||
:class="{
|
|
||||||
'fa-info-circle': status === 'disconnected',
|
|
||||||
'fa-spinner fa-pulse': status === 'connecting',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="status === 'disconnected'">
|
|
||||||
Websocket connection lost,
|
|
||||||
<NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here </NuxtLink>
|
|
||||||
to reconnect.
|
|
||||||
</span>
|
|
||||||
<span v-else-if="status === 'connecting'"> Connecting to websocket server... </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { connectionStatus } from '~/stores/SocketStore';
|
|
||||||
defineProps<{ status: connectionStatus }>();
|
|
||||||
defineEmits<{ (e: 'reconnect'): void }>();
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,128 +1,140 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="columns mt-2 is-multiline">
|
<form id="dlFieldForm" autocomplete="off" class="space-y-6" @submit.prevent="checkInfo">
|
||||||
<div class="column is-12">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<form autocomplete="off" id="dlFieldForm" @submit.prevent="checkInfo">
|
<div v-if="reference" class="md:col-span-2 flex justify-end">
|
||||||
<div class="card">
|
<UButton
|
||||||
<div class="card-header">
|
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
|
||||||
/></span>
|
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-header-icon" v-if="reference">
|
|
||||||
<button type="button" @click="showImport = !showImport">
|
|
||||||
<span class="icon"
|
|
||||||
><i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-arrow-down': !showImport,
|
|
||||||
'fa-arrow-up': showImport,
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-12" v-if="showImport || !reference">
|
|
||||||
<label class="label is-inline" for="import_string">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
|
||||||
Import string
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="field has-addons">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
id="import_string"
|
|
||||||
v-model="import_string"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
:disabled="!import_string"
|
|
||||||
type="button"
|
type="button"
|
||||||
@click="importItem"
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
@click="showImport = !showImport"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
{{ showImport ? 'Hide' : 'Show' }} import
|
||||||
<span>Import</span>
|
</UButton>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showImport || !reference">
|
||||||
|
<UFormField class="w-full md:col-span-2" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-import" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Import string</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
</template>
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
|
<template #description>
|
||||||
<span>You can use this field to populate the data, using shared string.</span>
|
<span>You can use this field to populate the data, using shared string.</span>
|
||||||
</span>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<div class="field">
|
<UInput
|
||||||
<label class="label">
|
id="import_string"
|
||||||
<span class="icon"><i class="fas fa-font" /></span>
|
v-model="importString"
|
||||||
<span>Field Name</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" v-model="form.name" class="input" :disabled="addInProgress" />
|
|
||||||
<span class="help is-bold">
|
|
||||||
The name of the field, it will be shown in the UI.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
|
||||||
<span>Field Description</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
v-model="form.description"
|
autocomplete="off"
|
||||||
class="input"
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
icon="i-lucide-import"
|
||||||
|
size="lg"
|
||||||
|
class="justify-center sm:min-w-28"
|
||||||
|
:disabled="!importString"
|
||||||
|
@click="() => void importItem()"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-type" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Field Name</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
<span>The name of the field, it will be shown in the UI.</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
<span class="help is-bold">
|
</UFormField>
|
||||||
A short description of the field, it will be shown in the UI.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
<div class="field">
|
<template #label>
|
||||||
<label class="label">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
<UIcon name="i-lucide-message-square-text" class="size-4 text-toned" />
|
||||||
<span>Field Type</span>
|
<span class="font-semibold text-default">Field Description</span>
|
||||||
</label>
|
</div>
|
||||||
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
|
</template>
|
||||||
<select v-model="form.kind" :disabled="addInProgress" class="is-capitalized">
|
|
||||||
<option
|
<template #description>
|
||||||
v-for="kind in fieldTypes"
|
<span>A short description of the field, it will be shown in the UI.</span>
|
||||||
:key="`kind-${kind}`"
|
</template>
|
||||||
:value="kind"
|
|
||||||
v-text="kind"
|
<UInput
|
||||||
|
v-model="form.description"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
</select>
|
</UFormField>
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
Field Type. String is a single line input, Text is a multi-line input, Bool is a
|
<template #label>
|
||||||
checkbox.
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
<UIcon name="i-lucide-shapes" class="size-4 text-toned" />
|
||||||
</div>
|
<span class="font-semibold text-default">Field Type</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
<span>
|
||||||
|
Field Type. String is a single line, Text is a multi-line, Bool is a checkbox.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<USelect
|
||||||
|
v-model="form.kind"
|
||||||
|
:items="fieldTypeItems"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
:ui="selectUi"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-terminal" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Associated yt-dlp option</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<span>
|
||||||
|
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
||||||
|
<code>-w</code>.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
|
||||||
<span>Associated yt-dlp option</span>
|
|
||||||
</label>
|
|
||||||
<InputAutocomplete
|
<InputAutocomplete
|
||||||
v-model="form.field"
|
v-model="form.field"
|
||||||
:options="ytDlpOptions"
|
:options="ytDlpOptions"
|
||||||
|
|
@ -131,92 +143,107 @@
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:openOnFocus="true"
|
:openOnFocus="true"
|
||||||
/>
|
/>
|
||||||
<span class="help is-bold">
|
</UFormField>
|
||||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
|
||||||
<code>-w</code>.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
<div class="field">
|
<template #label>
|
||||||
<label class="label">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
<UIcon name="i-lucide-list-ordered" class="size-4 text-toned" />
|
||||||
<span>Field Order</span>
|
<span class="font-semibold text-default">Field Order</span>
|
||||||
</label>
|
</div>
|
||||||
<input
|
</template>
|
||||||
type="number"
|
|
||||||
|
<template #description>
|
||||||
|
<span>
|
||||||
|
The order of the field, used to sort the fields in the UI. Lower numbers will appear
|
||||||
|
first.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
v-model.number="form.order"
|
v-model.number="form.order"
|
||||||
class="input"
|
type="number"
|
||||||
|
min="1"
|
||||||
|
size="lg"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
<span class="help is-bold">
|
</UFormField>
|
||||||
The order of the field, used to sort the fields in the UI. Lower numbers will
|
|
||||||
appear first.
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
</span>
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-image" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Field Icon</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<span>
|
||||||
|
The icon of the field must use a
|
||||||
|
<a
|
||||||
|
href="https://icones.js.org/collection/lucide"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Lucide icon
|
||||||
|
</a>
|
||||||
|
name in the <code>i-lucide-*</code> format, e.g. <code>i-lucide-image</code>. Leave
|
||||||
|
empty for no icon.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
v-model="form.icon"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
<div
|
||||||
<div class="field">
|
class="flex flex-col-reverse gap-2 border-t border-default pt-5 sm:flex-row sm:justify-end"
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-image" /></span>
|
|
||||||
<span>Field Icon</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
|
|
||||||
<span class="help is-bold">
|
|
||||||
The icon of the field, must be from
|
|
||||||
<NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
|
|
||||||
font-awesome</NuxtLink
|
|
||||||
>
|
>
|
||||||
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
<UButton
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button
|
|
||||||
class="button is-fullwidth is-primary"
|
|
||||||
:disabled="addInProgress"
|
|
||||||
type="submit"
|
|
||||||
:class="{ 'is-loading': addInProgress }"
|
|
||||||
form="dlFieldForm"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button
|
|
||||||
class="button is-fullwidth is-danger"
|
|
||||||
@click="emitter('cancel')"
|
|
||||||
:disabled="addInProgress"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="justify-center"
|
||||||
|
@click="emitter('cancel')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
Cancel
|
||||||
<span>Cancel</span>
|
</UButton>
|
||||||
</button>
|
|
||||||
</div>
|
<UButton
|
||||||
</div>
|
type="submit"
|
||||||
</div>
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
:loading="addInProgress"
|
||||||
|
class="justify-center"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||||
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
|
import type { ImportedItem } from '~/types';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import type { DLField } from '~/types/dl_fields';
|
import type { DLField } from '~/types/dl_fields';
|
||||||
import type { ImportedItem } from '~/types';
|
|
||||||
import { decode } from '~/utils';
|
import { decode } from '~/utils';
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
|
|
@ -234,35 +261,35 @@ const box = useConfirm();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
|
|
||||||
const fieldTypes = ['string', 'text', 'bool'] as const;
|
const fieldTypes = ['string', 'text', 'bool'] as const;
|
||||||
|
const fieldTypeItems = [...fieldTypes];
|
||||||
const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item)));
|
const form = reactive<DLField>(normalizeField(props.item));
|
||||||
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
||||||
const showImport = useStorage('showDlFieldsImport', false);
|
const showImport = useStorage('showDlFieldsImport', false);
|
||||||
const import_string = ref('');
|
const importString = ref('');
|
||||||
|
|
||||||
if (!form.extras) {
|
const fieldUi = {
|
||||||
form.extras = {};
|
label: 'font-semibold text-default',
|
||||||
}
|
container: 'space-y-2',
|
||||||
|
description: 'text-sm text-toned',
|
||||||
|
hint: 'text-sm text-toned',
|
||||||
|
};
|
||||||
|
|
||||||
if (!form.kind) {
|
const inputUi = {
|
||||||
form.kind = 'string';
|
root: 'w-full',
|
||||||
}
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
|
};
|
||||||
|
|
||||||
if (!form.description) {
|
const selectUi = {
|
||||||
form.description = '';
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!form.value) {
|
watch(
|
||||||
form.value = '';
|
() => props.item,
|
||||||
}
|
(value) => {
|
||||||
|
Object.assign(form, normalizeField(value));
|
||||||
if (!form.icon) {
|
},
|
||||||
form.icon = '';
|
{ deep: true },
|
||||||
}
|
);
|
||||||
|
|
||||||
if (!form.order) {
|
|
||||||
form.order = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.ytdlp_options,
|
() => config.ytdlp_options,
|
||||||
|
|
@ -277,15 +304,42 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function normalizeField(value?: Partial<DLField> | null): DLField {
|
||||||
|
const item = JSON.parse(JSON.stringify(value || {})) as Partial<DLField>;
|
||||||
|
const normalized: Partial<DLField> = {
|
||||||
|
...item,
|
||||||
|
description: item.description ?? '',
|
||||||
|
kind: item.kind ?? 'string',
|
||||||
|
value: item.value ?? '',
|
||||||
|
icon: item.icon ?? '',
|
||||||
|
order: item.order ?? 1,
|
||||||
|
extras: item.extras ? { ...item.extras } : {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.assign(
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
kind: 'string',
|
||||||
|
field: '',
|
||||||
|
value: '',
|
||||||
|
icon: '',
|
||||||
|
order: 1,
|
||||||
|
extras: {},
|
||||||
|
},
|
||||||
|
normalized,
|
||||||
|
) as DLField;
|
||||||
|
}
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
const val = import_string.value.trim();
|
const value = importString.value.trim();
|
||||||
if (!val) {
|
if (!value) {
|
||||||
toast.error('The import string is required.');
|
toast.error('The import string is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as DLField & ImportedItem;
|
const item = decode(value) as DLField & ImportedItem;
|
||||||
|
|
||||||
if (!item._type || item._type !== 'dl_field') {
|
if (!item._type || item._type !== 'dl_field') {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|
@ -301,50 +355,16 @@ const importItem = async (): Promise<void> => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name) {
|
Object.assign(form, normalizeField(item));
|
||||||
form.name = item.name;
|
importString.value = '';
|
||||||
}
|
|
||||||
|
|
||||||
if (item.field) {
|
|
||||||
form.field = item.field;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.description !== undefined) {
|
|
||||||
form.description = item.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.kind) {
|
|
||||||
form.kind = item.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.icon !== undefined) {
|
|
||||||
form.icon = item.icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.order !== undefined) {
|
|
||||||
form.order = item.order;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.value !== undefined) {
|
|
||||||
form.value = item.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.extras) {
|
|
||||||
form.extras = { ...item.extras };
|
|
||||||
}
|
|
||||||
|
|
||||||
import_string.value = '';
|
|
||||||
showImport.value = false;
|
showImport.value = false;
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
console.error(e);
|
toast.error(`Failed to parse import string. ${error.message}`);
|
||||||
toast.error(`Failed to parse import string. ${e.message}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkInfo = (): void => {
|
const checkInfo = (): void => {
|
||||||
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description'];
|
for (const key of ['name', 'field', 'kind', 'description'] as const) {
|
||||||
|
|
||||||
for (const key of required) {
|
|
||||||
if (!form[key]) {
|
if (!form[key]) {
|
||||||
toast.error(`The ${key} field is required.`);
|
toast.error(`The ${key} field is required.`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -367,13 +387,14 @@ const checkInfo = (): void => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: DLField = JSON.parse(JSON.stringify(form));
|
const copy: DLField = JSON.parse(JSON.stringify(form));
|
||||||
|
const entries = copy as Record<string, unknown>;
|
||||||
|
|
||||||
const entries = copy as unknown as Record<string, unknown>;
|
|
||||||
for (const key in entries) {
|
for (const key in entries) {
|
||||||
if ('string' !== typeof entries[key]) {
|
if ('string' !== typeof entries[key]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entries[key] = entries[key].trim();
|
|
||||||
|
entries[key] = String(entries[key]).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||||
|
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
<template>
|
|
||||||
<form class="modal is-active" @submit.prevent="updateItems">
|
|
||||||
<div class="modal-background" @click="cancel" />
|
|
||||||
<div class="modal-card" style="min-width: calc(100vw - 30vw)">
|
|
||||||
<header class="modal-card-head">
|
|
||||||
<p class="modal-card-title">Manage Custom Fields</p>
|
|
||||||
<button type="button" class="delete" aria-label="close" @click="cancel" />
|
|
||||||
</header>
|
|
||||||
<section class="modal-card-body">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
|
||||||
<span class="title is-4">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fas fa-plus" /></span>
|
|
||||||
<span>Custom Fields</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button is-primary"
|
|
||||||
@click="addNewField"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button is-info"
|
|
||||||
@click="loadContent()"
|
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile">
|
|
||||||
<span class="subtitle"
|
|
||||||
>The idea behind the custom fields is to allow you to add new fields to the download
|
|
||||||
form to automate some of the yt-dlp options.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12" v-for="(item, index) in sortedDLFields" :key="item.id || index">
|
|
||||||
<div class="box">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-12 is-12-mobile">
|
|
||||||
<NuxtLink
|
|
||||||
@click="deleteField(index)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
class="has-text-danger"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-trash" /></span>
|
|
||||||
<span>Delete Field</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
|
||||||
<span>Field Type</span>
|
|
||||||
</label>
|
|
||||||
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
|
|
||||||
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
|
|
||||||
<option
|
|
||||||
v-for="kind in Object.values(FieldTypes)"
|
|
||||||
:key="`kind-${kind}`"
|
|
||||||
:value="kind"
|
|
||||||
v-text="kind"
|
|
||||||
/>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
|
||||||
Field Type. String is a single line input, Text is a multi-line input, Bool is
|
|
||||||
a checkbox.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-font" /></span>
|
|
||||||
<span>Field Name</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" v-model="item.name" class="input" :disabled="isLoading" />
|
|
||||||
<span class="help is-bold">
|
|
||||||
The name of the field, it will be shown in the UI.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
|
||||||
<span>Associated yt-dlp option</span>
|
|
||||||
</label>
|
|
||||||
<InputAutocomplete
|
|
||||||
v-model="item.field"
|
|
||||||
:options="ytDlpOptions"
|
|
||||||
:disabled="isLoading"
|
|
||||||
placeholder="Type or select a yt-dlp option"
|
|
||||||
:multiple="false"
|
|
||||||
:openOnFocus="true"
|
|
||||||
/>
|
|
||||||
<span class="help is-bold">
|
|
||||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
|
||||||
<code>-w</code>.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
|
||||||
<span>Field Description</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="item.description"
|
|
||||||
class="input"
|
|
||||||
:disabled="isLoading"
|
|
||||||
/>
|
|
||||||
<span class="help is-bold">
|
|
||||||
A short description of the field, it will be shown in the UI.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
|
||||||
<span>Field Order</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
|
|
||||||
<span class="help is-bold">
|
|
||||||
The order of the field, used to sort the fields in the UI. Lower numbers will
|
|
||||||
appear first.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">
|
|
||||||
<span class="icon"><i class="fas fa-image" /></span>
|
|
||||||
<span>Field Icon</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
|
|
||||||
<span class="help is-bold">
|
|
||||||
The icon of the field, must be from
|
|
||||||
<NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
|
|
||||||
font-awesome</NuxtLink
|
|
||||||
>
|
|
||||||
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-12">
|
|
||||||
<Message class="is-warning" v-if="items.length === 0">
|
|
||||||
<span class="icon"><i class="fas fa-exclamation-circle" /></span>
|
|
||||||
<span>No custom fields found, you can add new fields using the button above.</span>
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<footer class="modal-card-foot p-5">
|
|
||||||
<div class="field is-grouped" style="width: 100%">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
|
|
||||||
<span class="icon"><i class="fas fa-save" /></span>
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button is-fullwidth is-danger"
|
|
||||||
@click="cancel"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-times" /></span>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
|
||||||
|
|
||||||
import type { DLField } from '~/types/dl_fields';
|
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'cancel'): void }>();
|
|
||||||
|
|
||||||
const toast = useNotification();
|
|
||||||
|
|
||||||
const isLoading = ref<boolean>(false);
|
|
||||||
const items = ref<DLField[]>([]);
|
|
||||||
const config = useConfigStore();
|
|
||||||
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
|
||||||
|
|
||||||
const FieldTypes = {
|
|
||||||
STRING: 'string',
|
|
||||||
TEXT: 'text',
|
|
||||||
BOOL: 'bool',
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => emitter('cancel');
|
|
||||||
|
|
||||||
const loadContent = async () => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const response = await request('/api/dl_fields');
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (0 === data.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.value = data;
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error('Failed to fetch page content.');
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateItems = async () => {
|
|
||||||
for (const item of items.value) {
|
|
||||||
if (validateItem(item, items.value.indexOf(item) + 1)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const resp = await request('/api/dl_fields', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(items.value),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const error = await resp.json();
|
|
||||||
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Fields updated successfully.');
|
|
||||||
emitter('cancel');
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addNewField = () =>
|
|
||||||
items.value.push({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
kind: 'string',
|
|
||||||
field: '',
|
|
||||||
value: '',
|
|
||||||
icon: '',
|
|
||||||
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
|
|
||||||
extras: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteField = (index: number) => items.value.splice(index, 1);
|
|
||||||
|
|
||||||
const validateItem = (item: DLField, index: number): boolean => {
|
|
||||||
const requiredFields = ['name', 'field', 'kind', 'description'];
|
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
|
||||||
if (!item[field as keyof DLField]) {
|
|
||||||
toast.error(`${item.name || index}: Field ${field} is required.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.order || item.order < 1) {
|
|
||||||
toast.error(`${item.name || index}: Order must be a positive number.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.values(FieldTypes).includes(item.kind)) {
|
|
||||||
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
|
||||||
toast.error(
|
|
||||||
`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
|
||||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)));
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => config.ytdlp_options,
|
|
||||||
(newOptions) =>
|
|
||||||
(ytDlpOptions.value = newOptions
|
|
||||||
.filter((opt) => !opt.ignored)
|
|
||||||
.flatMap((opt) =>
|
|
||||||
opt.flags
|
|
||||||
.filter((flag) => flag.startsWith('--'))
|
|
||||||
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
|
||||||
)),
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
onMounted(async () => {
|
|
||||||
disableOpacity();
|
|
||||||
await loadContent();
|
|
||||||
});
|
|
||||||
onBeforeUnmount(() => enableOpacity());
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,65 +1,120 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="field">
|
<div v-if="'bool' === type && compact" class="w-full space-y-1.5">
|
||||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
<div
|
||||||
|
class="flex min-h-11 items-start justify-between gap-3 rounded-md border border-default bg-default/80 px-3 py-2"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
<UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
|
||||||
|
<span class="truncate" :class="{ 'has-tooltip': field }">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.description" class="mt-0.5 text-xs text-toned">
|
||||||
|
<slot name="description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="description" class="mt-0.5 text-xs text-toned">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USwitch
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="boolModel"
|
||||||
|
:disabled="disabled"
|
||||||
|
color="success"
|
||||||
|
:label="boolModel ? 'Yes' : 'No'"
|
||||||
|
size="lg"
|
||||||
|
class="shrink-0"
|
||||||
|
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-sm' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.help" class="text-xs text-toned">
|
||||||
|
<slot name="help" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
v-else
|
||||||
|
:name="String(id)"
|
||||||
|
:description="$slots.description ? undefined : description"
|
||||||
|
class="w-full"
|
||||||
|
:ui="fieldUi"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
<template v-if="$slots.title">
|
<template v-if="$slots.title">
|
||||||
<slot name="title"></slot>
|
<slot name="title" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="icon" class="icon"><i :class="icon" /></span>
|
<span class="inline-flex items-center gap-2 font-semibold">
|
||||||
<span
|
<UIcon v-if="resolvedIcon" :name="resolvedIcon" class="size-4 text-toned" />
|
||||||
v-tooltip="field ? `yt-dlp option: ${field}` : null"
|
<UTooltip :text="field ? `yt-dlp option: ${field}` : undefined">
|
||||||
:class="{ 'has-tooltip': field }"
|
<span :class="{ 'has-tooltip': field }">
|
||||||
>{{ label }}</span
|
{{ label }}
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</label>
|
|
||||||
<div class="control is-expanded" v-if="'string' === type">
|
|
||||||
<input
|
|
||||||
:id="`dlf-${id}`"
|
|
||||||
:type="type"
|
|
||||||
class="input"
|
|
||||||
v-model="model"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:disabled="disabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control is-expanded" v-if="'text' === type">
|
|
||||||
<textarea
|
|
||||||
class="textarea is-pre"
|
|
||||||
:id="`dlf-${id}`"
|
|
||||||
v-model="model"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:disabled="disabled"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="control is-expanded" v-if="'bool' === type">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:id="`dlf-${id}`"
|
|
||||||
v-model="model"
|
|
||||||
class="switch is-success"
|
|
||||||
:disabled="disabled"
|
|
||||||
/>
|
|
||||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
|
||||||
{{ model ? 'Yes' : 'No' }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold is-unselectable">
|
|
||||||
<template v-if="description">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span v-text="description" />
|
|
||||||
</template>
|
|
||||||
<template v-if="$slots.help">
|
|
||||||
<slot name="help"></slot>
|
|
||||||
</template>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</UTooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
v-if="'string' === type"
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="stringModel"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full', base: 'w-full bg-default/90' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UTextarea
|
||||||
|
v-else-if="'text' === type"
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="stringModel"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
autoresize
|
||||||
|
:rows="4"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'w-full bg-elevated/60 font-mono text-sm whitespace-pre-wrap ring-default',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USwitch
|
||||||
|
v-else-if="'bool' === type"
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="boolModel"
|
||||||
|
:disabled="disabled"
|
||||||
|
color="success"
|
||||||
|
:label="boolModel ? 'Yes' : 'No'"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full items-start justify-between gap-4', wrapper: 'ms-0 flex-1 text-sm' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="$slots.description" #description>
|
||||||
|
<slot name="description" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.help" #help>
|
||||||
|
<slot name="help" />
|
||||||
|
</template>
|
||||||
|
</UFormField>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelRef } from 'vue';
|
|
||||||
import type { DLFieldType } from '~/types/dl_fields';
|
import type { DLFieldType } from '~/types/dl_fields';
|
||||||
defineProps<{
|
|
||||||
|
const props = defineProps<{
|
||||||
id: number | string;
|
id: number | string;
|
||||||
label: string;
|
label: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
|
|
@ -68,6 +123,36 @@ defineProps<{
|
||||||
icon?: string;
|
icon?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const model = defineModel() as ModelRef<string>;
|
|
||||||
|
const model = defineModel<string | boolean>();
|
||||||
|
|
||||||
|
const stringModel = computed({
|
||||||
|
get: () => (typeof model.value === 'string' ? model.value : ''),
|
||||||
|
set: (value: string) => {
|
||||||
|
model.value = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const boolModel = computed({
|
||||||
|
get: () => Boolean(model.value),
|
||||||
|
set: (value: boolean) => {
|
||||||
|
model.value = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
help: props.compact ? 'mt-1 text-xs text-toned' : 'mt-2 text-sm text-toned',
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,136 +1,94 @@
|
||||||
<style scoped>
|
|
||||||
/* container fades */
|
|
||||||
.dialog-enter-active,
|
|
||||||
.dialog-leave-active {
|
|
||||||
transition: opacity 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-enter-from,
|
|
||||||
.dialog-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* animate the card itself */
|
|
||||||
.dialog-enter-active .modal-card,
|
|
||||||
.dialog-leave-active .modal-card {
|
|
||||||
transition:
|
|
||||||
transform 0.18s ease,
|
|
||||||
opacity 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-enter-from .modal-card {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
opacity: 0.98;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-leave-to .modal-card {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
opacity: 0.98;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<UModal
|
||||||
<transition name="dialog" @after-enter="focusInput">
|
|
||||||
<div
|
|
||||||
id="app-dialog-host"
|
|
||||||
v-if="state.current"
|
v-if="state.current"
|
||||||
class="modal is-active"
|
:open="true"
|
||||||
@keydown.esc="onCancel"
|
:title="state.current?.opts.title ?? defaultTitle"
|
||||||
|
:dismissible="true"
|
||||||
|
:ui="{ content: 'max-w-lg', body: 'space-y-4', footer: 'justify-end gap-2' }"
|
||||||
|
@update:open="(open) => !open && onCancel()"
|
||||||
|
@after:enter="focusInput"
|
||||||
>
|
>
|
||||||
<div class="modal-background" @click="onCancel" />
|
<template #body>
|
||||||
<div class="modal-card" @keydown.enter.stop.prevent="onEnter">
|
<p v-if="state.current?.opts.message">
|
||||||
<header class="modal-card-head p-4">
|
|
||||||
<p class="modal-card-title">{{ state.current?.opts.title ?? defaultTitle }}</p>
|
|
||||||
<button class="delete" aria-label="close" @click="onCancel" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="modal-card-body">
|
|
||||||
<p v-if="state.current?.opts.message" class="mb-3">
|
|
||||||
{{ state.current?.opts.message }}
|
{{ state.current?.opts.message }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- prompt input -->
|
<UFormField v-if="'prompt' === state.current?.type" :error="state.errorMsg || undefined">
|
||||||
<div v-if="'prompt' === state.current?.type" class="field">
|
<UInput
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
v-model="localInput"
|
v-model="localInput"
|
||||||
:placeholder="(state.current?.opts as any)?.placeholder ?? ''"
|
:placeholder="(state.current?.opts as PromptOptions)?.placeholder ?? ''"
|
||||||
@keyup.stop
|
class="w-full"
|
||||||
|
@keydown.enter.stop.prevent="onEnter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
|
||||||
<span>{{ state.errorMsg }}</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-else-if="
|
v-else-if="
|
||||||
'confirm' === state.current?.type &&
|
'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML
|
||||||
(state.current?.opts as ConfirmOptions)?.rawHTML
|
|
||||||
"
|
"
|
||||||
class="content"
|
class="max-h-[40vh] overflow-auto text-sm text-default"
|
||||||
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
|
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
<div
|
||||||
|
v-if="
|
||||||
|
'confirm' === state.current?.type &&
|
||||||
|
(state.current?.opts as ConfirmOptions)?.options?.length
|
||||||
|
"
|
||||||
|
class="space-y-3 border-t border-default pt-4"
|
||||||
|
>
|
||||||
|
<UCheckbox
|
||||||
|
v-for="opt in (state.current?.opts as ConfirmOptions).options"
|
||||||
|
:key="opt.key"
|
||||||
|
v-model="selected[opt.key]"
|
||||||
|
:label="opt.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
<template v-if="'alert' === state.current?.type">
|
<template v-if="'alert' === state.current?.type">
|
||||||
<button id="primaryButton" class="button is-danger" @click="onEnter">
|
<UButton
|
||||||
<span class="icon-text">
|
id="primaryButton"
|
||||||
<span class="icon"><i class="fas fa-check" /></span>
|
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
|
||||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
@click="onEnter"
|
||||||
</span>
|
>
|
||||||
</button>
|
{{ state.current?.opts.confirmText ?? 'OK' }}
|
||||||
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template
|
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
|
||||||
v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type"
|
<UButton
|
||||||
>
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
id="primaryButton"
|
id="primaryButton"
|
||||||
class="button"
|
:color="resolveConfirmColor(state.current?.opts.confirmColor)"
|
||||||
|
:disabled="
|
||||||
|
'prompt' === state.current?.type &&
|
||||||
|
localInput === (state.current?.opts as PromptOptions)?.initial
|
||||||
|
"
|
||||||
@click="onEnter"
|
@click="onEnter"
|
||||||
:class="state.current?.opts.confirmColor ?? 'is-primary'"
|
|
||||||
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial"
|
|
||||||
>
|
>
|
||||||
<span class="icon-text">
|
{{ state.current?.opts.confirmText ?? 'OK' }}
|
||||||
<span class="icon"><i class="fas fa-check" /></span>
|
</UButton>
|
||||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
|
||||||
</span>
|
<UButton color="neutral" variant="outline" @click="onCancel">
|
||||||
</button>
|
{{ (state.current?.opts as PromptOptions | ConfirmOptions)?.cancelText ?? 'Cancel' }}
|
||||||
</div>
|
</UButton>
|
||||||
<div class="control">
|
|
||||||
<button class="button is-info" @click="onCancel">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fas fa-times" /></span>
|
|
||||||
<span>{{ (state.current?.opts as any)?.cancelText ?? 'Cancel' }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</footer>
|
</template>
|
||||||
</div>
|
</UModal>
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed } from 'vue';
|
import { ref, watch, nextTick, computed } from 'vue';
|
||||||
|
import { UButton, UCheckbox, UInput } from '#components';
|
||||||
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog';
|
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog';
|
||||||
|
|
||||||
const { state, confirm, cancel } = useDialog();
|
const { state, confirm, cancel } = useDialog();
|
||||||
|
|
||||||
const localInput = ref('');
|
const localInput = ref('');
|
||||||
|
const selected = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => state.current,
|
() => state.current,
|
||||||
|
|
@ -141,31 +99,41 @@ watch(
|
||||||
enableOpacity();
|
enableOpacity();
|
||||||
}
|
}
|
||||||
|
|
||||||
localInput.value = 'prompt' === cur?.type ? ((cur.opts as any).initial ?? '') : '';
|
localInput.value = 'prompt' === cur?.type ? ((cur.opts as PromptOptions).initial ?? '') : '';
|
||||||
|
|
||||||
|
if ('confirm' === cur?.type) {
|
||||||
|
selected.value = Object.fromEntries(
|
||||||
|
((cur.opts as ConfirmOptions).options ?? []).map((opt) => [opt.key, Boolean(opt.checked)]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.value = {};
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const inputEl = ref<HTMLInputElement>();
|
const inputEl = ref<{ inputRef?: { value?: HTMLInputElement | null } } | null>(null);
|
||||||
|
|
||||||
const focusPrimary = () => {
|
const focusPrimary = () => {
|
||||||
const root = document.getElementById('app-dialog-host');
|
const root = document.getElementById('primaryButton');
|
||||||
if (!root) {
|
root?.focus();
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = root.querySelector<HTMLButtonElement>('#primaryButton');
|
|
||||||
btn?.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusInput = async () => {
|
const focusInput = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if ('prompt' === state.current?.type) {
|
if ('prompt' === state.current?.type) {
|
||||||
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }));
|
requestAnimationFrame(() => inputEl.value?.inputRef?.value?.focus?.({ preventScroll: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(focusPrimary);
|
requestAnimationFrame(focusPrimary);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveConfirmColor = (color?: ConfirmOptions['confirmColor']) => color ?? 'primary';
|
||||||
|
|
||||||
const onCancel = () => cancel();
|
const onCancel = () => cancel();
|
||||||
const onEnter = () => confirm(localInput.value);
|
const onEnter = () =>
|
||||||
|
confirm('confirm' === state.current?.type ? selected.value : localInput.value);
|
||||||
|
|
||||||
const defaultTitle = computed(() => {
|
const defaultTitle = computed(() => {
|
||||||
if (!state.current) {
|
if (!state.current) {
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
|
||||||
<div class="dropdown-trigger">
|
|
||||||
<button
|
|
||||||
class="button is-fullwidth is-justify-content-space-between"
|
|
||||||
aria-haspopup="true"
|
|
||||||
type="button"
|
|
||||||
aria-controls="dropdown-menu"
|
|
||||||
@click="toggle"
|
|
||||||
:class="button_classes"
|
|
||||||
>
|
|
||||||
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
|
||||||
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
|
|
||||||
<div class="is-pulled-right">
|
|
||||||
<span class="icon"><i class="fas fa-angle-down" aria-hidden="true" /></span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="isOpen" class="dropdown is-active dropdown-portal" ref="menu" :style="menuStyle">
|
|
||||||
<div class="dropdown-menu" role="menu">
|
|
||||||
<div class="dropdown-content is-unselectable" @click="handle_slot_click">
|
|
||||||
<template v-if="hideLabel">
|
|
||||||
<div class="dropdown-label">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
|
||||||
<span>{{ label }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
</template>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue';
|
|
||||||
|
|
||||||
const emitter = defineEmits(['open_state']);
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: 'Select',
|
|
||||||
},
|
|
||||||
icons: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
button_classes: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
hide_label_on_mobile: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const dropUp = ref(false);
|
|
||||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown');
|
|
||||||
const menu = useTemplateRef<HTMLDivElement>('menu');
|
|
||||||
const menuStyle = ref<Record<string, string>>({});
|
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
|
||||||
|
|
||||||
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile);
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (!dropdown.value || !isOpen.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
|
||||||
const menuHeight = menu.value?.offsetHeight || 300;
|
|
||||||
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
|
||||||
const spaceAbove = triggerRect.top;
|
|
||||||
|
|
||||||
// Determine if dropdown should appear above or below
|
|
||||||
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow;
|
|
||||||
dropUp.value = shouldDropUp;
|
|
||||||
|
|
||||||
// Calculate position
|
|
||||||
const left = triggerRect.left;
|
|
||||||
const width = triggerRect.width;
|
|
||||||
|
|
||||||
if (shouldDropUp) {
|
|
||||||
// Position above the trigger
|
|
||||||
const bottom = window.innerHeight - triggerRect.top;
|
|
||||||
menuStyle.value = {
|
|
||||||
position: 'fixed',
|
|
||||||
left: `${left}px`,
|
|
||||||
bottom: `${bottom}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
top: 'auto',
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Position below the trigger
|
|
||||||
const top = triggerRect.bottom;
|
|
||||||
menuStyle.value = {
|
|
||||||
position: 'fixed',
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
bottom: 'auto',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggle = async () => {
|
|
||||||
isOpen.value = !isOpen.value;
|
|
||||||
|
|
||||||
if (isOpen.value) {
|
|
||||||
await nextTick();
|
|
||||||
updatePosition();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handle_slot_click = (event: MouseEvent) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.closest('.dropdown-item')) {
|
|
||||||
isOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handle_event = (event: MouseEvent) => {
|
|
||||||
if (!dropdown.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
|
|
||||||
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
|
|
||||||
isOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (isOpen.value) {
|
|
||||||
updatePosition();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
if (isOpen.value) {
|
|
||||||
updatePosition();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watchEffect(() => emitter('open_state', isOpen.value));
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handle_event);
|
|
||||||
window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handle_event);
|
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dropdown {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-trigger {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dropdown.dropdown-portal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown.dropdown-portal .dropdown-menu {
|
|
||||||
display: block !important;
|
|
||||||
/* Override Bulma's display: none */
|
|
||||||
position: static;
|
|
||||||
/* Don't use absolute positioning inside fixed container */
|
|
||||||
max-height: 350px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
min-width: max-content;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown.dropdown-portal .dropdown-content {
|
|
||||||
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
|
||||||
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
|
|
||||||
box-shadow: var(
|
|
||||||
--bulma-dropdown-content-shadow,
|
|
||||||
var(
|
|
||||||
--bulma-shadow,
|
|
||||||
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
|
|
||||||
0 0px 0 1px rgba(10, 10, 10, 0.02)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
|
|
||||||
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-label {
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
color: var(--bulma-dropdown-label-color, var(--bulma-text-color, #4a4a4a));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 80vh;
|
height: 80vh;
|
||||||
width: 80vw;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,405 @@
|
||||||
<template>
|
<template>
|
||||||
<ModalText
|
<UModal
|
||||||
:isLoading="isLoading"
|
v-if="!externalModel"
|
||||||
:data="data"
|
:open="true"
|
||||||
:externalModel="externalModel"
|
:title="resolvedTitle"
|
||||||
@closeModel="() => emitter('closeModel')"
|
:dismissible="true"
|
||||||
:code_classes="code_classes"
|
:ui="{ content: 'w-full sm:max-w-5xl', body: 'p-0 overflow-hidden' }"
|
||||||
|
@update:open="(open) => !open && emitter('closeModel')"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span class="sr-only">{{ modalDescription }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex h-[75vh] min-h-96 min-w-0 flex-col">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon :name="contentIcon" class="size-4 text-toned" />
|
||||||
|
<span>{{ contentLabel }}</span>
|
||||||
|
<UBadge color="neutral" variant="soft" size="sm">
|
||||||
|
{{ isStructuredData ? 'JSON' : 'Text' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="isLoading || !hasDisplayText"
|
||||||
|
@click="wrapText = !wrapText"
|
||||||
|
>
|
||||||
|
{{ wrapText ? 'No wrap' : 'Wrap text' }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
:disabled="isLoading || !hasDisplayText"
|
||||||
|
@click="copyData"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 p-4 sm:p-5">
|
||||||
|
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
||||||
|
<span class="text-sm">Loading information...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<div class="w-full max-w-2xl space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
title="Unable to load information"
|
||||||
|
:description="errorMessage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-rotate-ccw"
|
||||||
|
@click="() => void loadInfo()"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<UAlert
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="No content returned"
|
||||||
|
description="The request completed successfully but did not return any visible content."
|
||||||
|
class="w-full max-w-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
|
||||||
|
<div v-else class="flex h-[75vh] min-h-96 min-w-0 flex-col">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 border-b border-default bg-muted/20 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon :name="contentIcon" class="size-4 text-toned" />
|
||||||
|
<span>{{ contentLabel }}</span>
|
||||||
|
<UBadge color="neutral" variant="soft" size="sm">
|
||||||
|
{{ isStructuredData ? 'JSON' : 'Text' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<p class="truncate text-xs text-toned">{{ sourceSummary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="isLoading || !hasDisplayText"
|
||||||
|
@click="wrapText = !wrapText"
|
||||||
|
>
|
||||||
|
{{ wrapText ? 'No wrap' : 'Wrap text' }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
:disabled="isLoading || !hasDisplayText"
|
||||||
|
@click="copyData"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 p-4 sm:p-5">
|
||||||
|
<div v-if="isLoading" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-toned">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
|
||||||
|
<span class="text-sm">Loading information...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<div class="w-full max-w-2xl space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
title="Unable to load information"
|
||||||
|
:description="errorMessage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-rotate-ccw"
|
||||||
|
@click="() => void loadInfo()"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!hasDisplayText" class="flex h-full min-h-64 items-center justify-center">
|
||||||
|
<UAlert
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="No content returned"
|
||||||
|
description="The request completed successfully but did not return any visible content."
|
||||||
|
class="w-full max-w-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre :class="preClasses"><code class="block min-w-full" v-text="displayText" /></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
link?: string;
|
link?: string;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
cli?: string;
|
cli?: string;
|
||||||
useUrl?: boolean;
|
useUrl?: boolean;
|
||||||
externalModel?: boolean;
|
externalModel?: boolean;
|
||||||
code_classes?: string;
|
code_classes?: string;
|
||||||
}>();
|
}>(),
|
||||||
|
{
|
||||||
|
link: '',
|
||||||
|
preset: '',
|
||||||
|
cli: '',
|
||||||
|
useUrl: false,
|
||||||
|
externalModel: false,
|
||||||
|
code_classes: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = ref<boolean>(true);
|
const isLoading = ref(true);
|
||||||
const data = ref<any>({});
|
const data = ref<unknown>(null);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const wrapText = useStorage<boolean>('get_info_wrap_text', false);
|
||||||
|
let latestRequestId = 0;
|
||||||
|
|
||||||
onMounted(async (): Promise<void> => {
|
const resolvedTitle = computed(() => {
|
||||||
let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info';
|
if (props.useUrl && props.link.startsWith('/api/history/')) {
|
||||||
|
return 'Local Information';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.useUrl) {
|
||||||
|
return 'File Contents';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'yt-dlp Information';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalDescription = computed(() => {
|
||||||
|
if (props.useUrl && props.link.startsWith('/api/history/')) {
|
||||||
|
return 'View the stored local information payload for this item.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.useUrl) {
|
||||||
|
return 'Preview the fetched text response.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'View the yt-dlp information payload for this URL.';
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (typeof data.value === 'string') {
|
||||||
|
return data.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.value === null || data.value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data.value, null, 2) ?? '';
|
||||||
|
} catch {
|
||||||
|
return String(data.value ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDisplayText = computed(() => displayText.value.length > 0);
|
||||||
|
|
||||||
|
const isStructuredData = computed(() => {
|
||||||
|
return data.value !== null && data.value !== undefined && typeof data.value !== 'string';
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentIcon = computed(() => {
|
||||||
|
return isStructuredData.value ? 'i-lucide-braces' : 'i-lucide-file-text';
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentLabel = computed(() => {
|
||||||
|
return isStructuredData.value ? 'Structured output' : 'Plain text output';
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceSummary = computed(() => {
|
||||||
|
if (props.useUrl) {
|
||||||
|
return props.link || 'Direct response source';
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
|
||||||
|
if (props.preset) {
|
||||||
|
details.push(`Preset: ${props.preset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.cli) {
|
||||||
|
details.push('Custom yt-dlp args applied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return details.join(' | ') || 'yt-dlp response payload';
|
||||||
|
});
|
||||||
|
|
||||||
|
const preClasses = computed(() => [
|
||||||
|
'h-full max-h-full overflow-auto rounded-xl border border-default bg-elevated/40 p-4 text-sm text-default',
|
||||||
|
'font-mono leading-6',
|
||||||
|
wrapText.value ? 'whitespace-pre-wrap break-words' : 'whitespace-pre',
|
||||||
|
props.code_classes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buildRequestUrl = (): string => {
|
||||||
|
if (props.useUrl) {
|
||||||
|
return props.link;
|
||||||
|
}
|
||||||
|
|
||||||
if (!props.useUrl) {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (props.preset) {
|
if (props.preset) {
|
||||||
params.append('preset', props.preset);
|
params.append('preset', props.preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.cli) {
|
if (props.cli) {
|
||||||
params.append('args', props.cli);
|
params.append('args', props.cli);
|
||||||
}
|
}
|
||||||
params.append('url', props.link || '');
|
|
||||||
url += '?' + params.toString();
|
params.append('url', props.link);
|
||||||
|
|
||||||
|
return `/api/yt-dlp/url/info?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseResponseBody = (body: string): unknown => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (body: string, status: number): string => {
|
||||||
|
if (!body) {
|
||||||
|
return `Request failed with status ${status}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request(url);
|
const parsed = JSON.parse(body) as { message?: string; error?: string };
|
||||||
|
return parsed.message || parsed.error || body;
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadInfo = async (): Promise<void> => {
|
||||||
|
latestRequestId += 1;
|
||||||
|
const requestId = latestRequestId;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
data.value = null;
|
||||||
|
|
||||||
|
if (!props.link) {
|
||||||
|
errorMessage.value = 'No source URL was provided.';
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(buildRequestUrl());
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
|
|
||||||
try {
|
if (requestId !== latestRequestId) {
|
||||||
data.value = JSON.parse(body);
|
return;
|
||||||
} catch {
|
|
||||||
data.value = body;
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
if (!response.ok) {
|
||||||
toast.error(`Error: ${e.message}`);
|
throw new Error(getErrorMessage(body, response.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
data.value = parseResponseBody(body);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (requestId !== latestRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load information.';
|
||||||
|
errorMessage.value = message;
|
||||||
|
toast.error(`Error: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === latestRequestId) {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyData = (): void => {
|
||||||
|
if (!displayText.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(displayText.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.link, props.preset, props.cli, props.useUrl],
|
||||||
|
() => {
|
||||||
|
void loadInfo();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,8 +9,8 @@ img {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center">
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<UIcon name="i-lucide-loader-circle" class="size-20 animate-spin text-toned sm:size-24" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<img :src="image" />
|
<img :src="image" />
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,60 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative w-full">
|
||||||
class="dropdown"
|
<UInput
|
||||||
:class="{ 'is-active': showList && filteredOptions.length }"
|
:id="id"
|
||||||
style="width: 100%"
|
ref="inputRef"
|
||||||
>
|
|
||||||
<div class="control" style="width: 100%">
|
|
||||||
<input
|
|
||||||
v-model="model"
|
v-model="model"
|
||||||
@focus="onFocus"
|
|
||||||
@blur="hideList"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
@input="onInput"
|
|
||||||
class="input"
|
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
style="width: 100%; position: relative; z-index: 2"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:id="id"
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ root: 'w-full', base: 'w-full bg-default/90' }"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="hideList"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@keyup="updateCaretFromInput"
|
||||||
|
@click="updateCaretFromInput"
|
||||||
|
@mouseup="updateCaretFromInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
|
<div
|
||||||
<div class="dropdown-content" style="width: 100%; max-height: 10em; overflow-y: auto">
|
v-if="showList && filteredOptions.length"
|
||||||
<a
|
ref="dropdownRef"
|
||||||
|
class="absolute inset-x-0 top-full z-20 mt-1 max-h-40 overflow-y-auto rounded-md border border-default bg-default shadow-lg"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
v-for="(option, idx) in filteredOptions"
|
v-for="(option, idx) in filteredOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start justify-between gap-4 px-3 py-2 text-left text-sm transition-colors"
|
||||||
|
:class="
|
||||||
|
idx === highlightedIndex
|
||||||
|
? 'bg-elevated text-highlighted'
|
||||||
|
: 'text-default hover:bg-elevated/60'
|
||||||
|
"
|
||||||
@mousedown.prevent="selectOption(option.value)"
|
@mousedown.prevent="selectOption(option.value)"
|
||||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
|
||||||
style="display: flex; justify-content: space-between"
|
|
||||||
:ref="(el) => setDropdownItemRef(el, idx)"
|
:ref="(el) => setDropdownItemRef(el, idx)"
|
||||||
>
|
>
|
||||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
<span class="shrink-0 font-semibold text-highlighted">{{ option.value }}</span>
|
||||||
<abbr
|
<abbr
|
||||||
class="has-text-grey-light is-text-overflow"
|
class="min-w-0 flex-1 truncate text-xs text-toned no-underline"
|
||||||
:title="option.description"
|
:title="option.description"
|
||||||
style="margin-left: 1em"
|
|
||||||
>
|
>
|
||||||
{{ option.description }}
|
{{ option.description }}
|
||||||
</abbr>
|
</abbr>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, nextTick } from 'vue';
|
import { ref, watch, computed, nextTick, toRefs } from 'vue';
|
||||||
|
import type { ComponentPublicInstance } from 'vue';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
|
@ -67,21 +77,31 @@ const props = withDefaults(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const model = defineModel<string>();
|
const { placeholder, disabled, id, multiple, openOnFocus, allowShortFlags } = toRefs(props);
|
||||||
|
|
||||||
const onInput = () => {
|
const model = defineModel<string>();
|
||||||
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
|
|
||||||
highlightedIndex.value = showList.value ? 0 : -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const showList = ref(false);
|
const showList = ref(false);
|
||||||
const highlightedIndex = ref(-1);
|
const highlightedIndex = ref(-1);
|
||||||
const dropdownItemRefs = ref<(HTMLElement | null)[]>([]);
|
const dropdownItemRefs = ref<(HTMLElement | null)[]>([]);
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null);
|
||||||
|
const inputRef = ref<{
|
||||||
|
inputRef?: { value?: HTMLInputElement | null };
|
||||||
|
$el?: Element | null;
|
||||||
|
} | null>(null);
|
||||||
|
const caretIndex = ref(0);
|
||||||
|
|
||||||
// Extract the last non-space token and its bounds
|
const getNativeInput = () => {
|
||||||
const getLastToken = (value: string) => {
|
const direct = inputRef.value?.inputRef?.value;
|
||||||
// If multiple is disabled, treat the entire input as a single token
|
if (direct) {
|
||||||
if (!props.multiple) {
|
return direct;
|
||||||
|
}
|
||||||
|
const fallback = inputRef.value?.$el?.querySelector('input');
|
||||||
|
return fallback instanceof HTMLInputElement ? fallback : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentToken = (value: string) => {
|
||||||
|
if (!multiple.value) {
|
||||||
return {
|
return {
|
||||||
token: value,
|
token: value,
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
@ -89,40 +109,44 @@ const getLastToken = (value: string) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple enabled: extract last token for multi-flag support
|
const caret = Math.min(caretIndex.value, value.length);
|
||||||
const m = (value || '').match(/(\S+)$/);
|
const left = value.slice(0, caret);
|
||||||
const token: string = m?.[1] ?? '';
|
const right = value.slice(caret);
|
||||||
const start = m ? (m.index as number) : value.length;
|
const leftMatch = left.match(/(\S+)$/);
|
||||||
const end = m ? start + token.length : value.length;
|
const rightMatch = right.match(/^(\S+)/);
|
||||||
|
const leftToken = leftMatch?.[1] ?? '';
|
||||||
|
const rightToken = rightMatch?.[1] ?? '';
|
||||||
|
const token = leftToken + rightToken;
|
||||||
|
const start = leftMatch ? caret - leftToken.length : caret;
|
||||||
|
const end = rightMatch ? caret + rightToken.length : caret;
|
||||||
return { token, start, end };
|
return { token, start, end };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCaretFromInput = () => {
|
||||||
|
const input = getNativeInput();
|
||||||
|
caretIndex.value = input?.selectionStart ?? (model.value || '').length;
|
||||||
|
};
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
const filteredOptions = computed(() => {
|
||||||
const value = model.value || '';
|
const value = model.value || '';
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return props.options;
|
return props.options;
|
||||||
}
|
}
|
||||||
const { token } = getLastToken(value);
|
|
||||||
|
|
||||||
// If openOnFocus is enabled and token is empty/just whitespace, show all options
|
const { token } = getCurrentToken(value);
|
||||||
if (props.openOnFocus && !token) {
|
if (openOnFocus.value && !token) {
|
||||||
return props.options;
|
return props.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide suggestions when token has '='
|
|
||||||
if (!token || token.includes('=')) {
|
if (!token || token.includes('=')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is a valid flag format
|
|
||||||
const isLongFlag = token.startsWith('--');
|
const isLongFlag = token.startsWith('--');
|
||||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
const isShortFlag = allowShortFlags.value && token.startsWith('-') && !token.startsWith('--');
|
||||||
|
|
||||||
if (!isLongFlag && !isShortFlag) {
|
if (!isLongFlag && !isShortFlag) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for exact match first - if found, only show that
|
|
||||||
const exactMatch = props.options.find((opt) => opt.value === token);
|
const exactMatch = props.options.find((opt) => opt.value === token);
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
return [exactMatch];
|
return [exactMatch];
|
||||||
|
|
@ -137,7 +161,6 @@ const filteredOptions = computed(() => {
|
||||||
const desc = opt.description.toLowerCase();
|
const desc = opt.description.toLowerCase();
|
||||||
|
|
||||||
if (isShortFlag) {
|
if (isShortFlag) {
|
||||||
// Short flags: case-sensitive matching for flag, case-insensitive for description
|
|
||||||
if (flag === token) {
|
if (flag === token) {
|
||||||
startsWithFlag.push(opt);
|
startsWithFlag.push(opt);
|
||||||
} else if (flag.includes(token)) {
|
} else if (flag.includes(token)) {
|
||||||
|
|
@ -145,48 +168,78 @@ const filteredOptions = computed(() => {
|
||||||
} else if (desc.includes(token.toLowerCase())) {
|
} else if (desc.includes(token.toLowerCase())) {
|
||||||
includesDesc.push(opt);
|
includesDesc.push(opt);
|
||||||
}
|
}
|
||||||
} else {
|
continue;
|
||||||
// Long flags: case-insensitive matching
|
|
||||||
const val = token.toLowerCase();
|
|
||||||
const flagLower = flag.toLowerCase();
|
|
||||||
|
|
||||||
if (flagLower.startsWith(val)) {
|
|
||||||
startsWithFlag.push(opt);
|
|
||||||
} else if (flagLower.includes(val)) {
|
|
||||||
includesFlag.push(opt);
|
|
||||||
} else if (desc.includes(val)) {
|
|
||||||
includesDesc.push(opt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedToken = token.toLowerCase();
|
||||||
|
const flagLower = flag.toLowerCase();
|
||||||
|
if (flagLower.startsWith(normalizedToken)) {
|
||||||
|
startsWithFlag.push(opt);
|
||||||
|
} else if (flagLower.includes(normalizedToken)) {
|
||||||
|
includesFlag.push(opt);
|
||||||
|
} else if (desc.includes(normalizedToken)) {
|
||||||
|
includesDesc.push(opt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFlagTrigger = computed(() => {
|
||||||
|
const { token } = getCurrentToken(model.value || '');
|
||||||
|
if (openOnFocus.value && !token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!token || token.includes('=')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLongFlag = token.startsWith('--');
|
||||||
|
const isShortFlag = allowShortFlags.value && token.startsWith('-') && !token.startsWith('--');
|
||||||
|
return isLongFlag || isShortFlag;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
updateCaretFromInput();
|
||||||
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
|
||||||
|
highlightedIndex.value = showList.value ? 0 : -1;
|
||||||
|
};
|
||||||
|
|
||||||
const selectOption = (val: string) => {
|
const selectOption = (val: string) => {
|
||||||
const value = model.value || '';
|
const value = model.value || '';
|
||||||
const { token, start, end } = getLastToken(value);
|
const { token, start, end } = getCurrentToken(value);
|
||||||
|
|
||||||
// If multiple is disabled, replace entire value
|
if (!multiple.value) {
|
||||||
if (!props.multiple) {
|
|
||||||
// Preserve any '=value' suffix already typed
|
|
||||||
const eqPos = token.indexOf('=');
|
const eqPos = token.indexOf('=');
|
||||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||||
model.value = val + after;
|
model.value = val + after;
|
||||||
showList.value = false;
|
showList.value = false;
|
||||||
highlightedIndex.value = -1;
|
highlightedIndex.value = -1;
|
||||||
|
nextTick(() => getNativeInput()?.focus());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple enabled: replace only the last token
|
|
||||||
if (token) {
|
if (token) {
|
||||||
// Preserve any '=value' suffix already typed for this token
|
|
||||||
const eqPos = token.indexOf('=');
|
const eqPos = token.indexOf('=');
|
||||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||||
model.value = value.slice(0, start) + val + after + value.slice(end);
|
model.value = value.slice(0, start) + val + after + value.slice(end);
|
||||||
|
nextTick(() => {
|
||||||
|
const input = getNativeInput();
|
||||||
|
const nextPos = start + val.length + after.length;
|
||||||
|
input?.focus();
|
||||||
|
input?.setSelectionRange(nextPos, nextPos);
|
||||||
|
caretIndex.value = nextPos;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
model.value = val;
|
model.value = val;
|
||||||
|
nextTick(() => {
|
||||||
|
const input = getNativeInput();
|
||||||
|
input?.focus();
|
||||||
|
input?.setSelectionRange(val.length, val.length);
|
||||||
|
caretIndex.value = val.length;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showList.value = false;
|
showList.value = false;
|
||||||
highlightedIndex.value = -1;
|
highlightedIndex.value = -1;
|
||||||
};
|
};
|
||||||
|
|
@ -200,10 +253,10 @@ const hideList = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
if (!props.openOnFocus) {
|
updateCaretFromInput();
|
||||||
|
if (!openOnFocus.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// When openOnFocus is enabled, show dropdown if there are options
|
|
||||||
const hasOptions = filteredOptions.value.length > 0;
|
const hasOptions = filteredOptions.value.length > 0;
|
||||||
showList.value = hasOptions;
|
showList.value = hasOptions;
|
||||||
highlightedIndex.value = hasOptions ? 0 : -1;
|
highlightedIndex.value = hasOptions ? 0 : -1;
|
||||||
|
|
@ -217,37 +270,23 @@ watch(filteredOptions, () => {
|
||||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1;
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1;
|
||||||
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null);
|
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const dropdown = document.querySelector('.dropdown-content');
|
if (dropdownRef.value) {
|
||||||
if (dropdown) {
|
dropdownRef.value.scrollTop = 0;
|
||||||
dropdown.scrollTop = 0;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFlagTrigger = computed(() => {
|
const scrollHighlightedIntoView = () => {
|
||||||
const { token } = getLastToken(model.value || '');
|
const el = dropdownItemRefs.value[highlightedIndex.value];
|
||||||
|
if (!el) {
|
||||||
// If openOnFocus is enabled and input is empty, allow trigger
|
return;
|
||||||
if (props.openOnFocus && !token) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
el.scrollIntoView({ block: 'nearest' });
|
||||||
if (!token || token.includes('=')) return false;
|
};
|
||||||
|
|
||||||
// Check if token is a valid flag format
|
|
||||||
const isLongFlag = token.startsWith('--');
|
|
||||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
|
||||||
|
|
||||||
if (!isLongFlag && !isShortFlag) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow trigger even for exact matches so users can see descriptions
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
// Escape closes dropdown and lets arrows navigate the input
|
updateCaretFromInput();
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
showList.value = false;
|
showList.value = false;
|
||||||
highlightedIndex.value = -1;
|
highlightedIndex.value = -1;
|
||||||
|
|
@ -262,22 +301,22 @@ const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
||||||
nextTick(() => scrollHighlightedIntoView());
|
nextTick(scrollHighlightedIntoView);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
||||||
nextTick(() => scrollHighlightedIntoView());
|
nextTick(scrollHighlightedIntoView);
|
||||||
} else if (e.key === 'PageDown') {
|
} else if (e.key === 'PageDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.min(
|
highlightedIndex.value = Math.min(
|
||||||
highlightedIndex.value + pageSize,
|
highlightedIndex.value + pageSize,
|
||||||
filteredOptions.value.length - 1,
|
filteredOptions.value.length - 1,
|
||||||
);
|
);
|
||||||
nextTick(() => scrollHighlightedIntoView());
|
nextTick(scrollHighlightedIntoView);
|
||||||
} else if (e.key === 'PageUp') {
|
} else if (e.key === 'PageUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
||||||
nextTick(() => scrollHighlightedIntoView());
|
nextTick(scrollHighlightedIntoView);
|
||||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
const selected =
|
const selected =
|
||||||
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||||
|
|
@ -289,12 +328,4 @@ const handleKeydown = (e: KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function scrollHighlightedIntoView() {
|
|
||||||
const el = dropdownItemRefs.value[highlightedIndex.value];
|
|
||||||
if (!el) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,306 @@
|
||||||
<style>
|
<style>
|
||||||
.markdown-alert {
|
.docs-markdown {
|
||||||
padding: 0 1em;
|
min-width: 0;
|
||||||
margin-bottom: 16px;
|
color: var(--ui-text);
|
||||||
|
font-size: 0.975rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown > :first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :where(h1, h2, h3, h4, h5, h6) {
|
||||||
|
margin-top: 1.75em;
|
||||||
|
color: var(--ui-text-highlighted);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown h1 {
|
||||||
|
font-size: clamp(1.85rem, 1.55rem + 1vw, 2.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown h2 {
|
||||||
|
font-size: clamp(1.5rem, 1.3rem + 0.7vw, 1.95rem);
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--ui-border) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown h4 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown h5,
|
||||||
|
.docs-markdown h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :where(p, ul, ol, pre, blockquote, .markdown-alert, .docs-markdown__table-wrap, hr) {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :where(p, li, blockquote, th, td) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :where(strong) {
|
||||||
|
color: var(--ui-text-highlighted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :where(ul, ol) {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown li + li {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown li > :where(ul, ol) {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown a {
|
||||||
|
color: color-mix(in oklab, var(--ui-info) 60%, var(--ui-text-highlighted) 40%);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown a:hover {
|
||||||
|
color: var(--ui-text-highlighted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown :not(pre) > code {
|
||||||
|
display: inline;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--ui-border) 80%, transparent);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 60%, var(--ui-bg) 40%);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown pre {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--ui-border) 80%, transparent);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 40%, var(--ui-bg) 60%);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown pre code {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown blockquote {
|
||||||
|
border-left: 0.3rem solid color-mix(in oklab, var(--ui-info) 60%, var(--ui-border) 40%);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: color-mix(in oklab, var(--ui-info) 7%, var(--ui-bg) 93%);
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
color: var(--ui-text-toned);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown blockquote > :first-child,
|
||||||
|
.docs-markdown blockquote > :first-child > :first-child,
|
||||||
|
.markdown-alert > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown blockquote > :last-child,
|
||||||
|
.markdown-alert > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--ui-border) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--ui-border) 85%, transparent);
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: var(--ui-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table :where(th, td) {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-right: 1px solid color-mix(in oklab, var(--ui-border) 70%, transparent);
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--ui-border) 70%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table :where(th:last-child, td:last-child) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table thead th {
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 60%, var(--ui-bg) 40%);
|
||||||
|
color: var(--ui-text-toned);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table tbody tr:nth-child(odd) {
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 24%, var(--ui-bg) 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table tbody tr:hover {
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-elevated) 65%, var(--ui-bg-muted) 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown__table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-markdown img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--ui-border) 75%, transparent);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-alert {
|
||||||
|
--markdown-alert-accent: color-mix(in oklab, var(--ui-border-accented) 70%, transparent);
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--markdown-alert-accent) 35%, var(--ui-border) 65%);
|
||||||
|
border-left-width: 0.35rem;
|
||||||
|
border-left-color: var(--markdown-alert-accent);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: color-mix(in oklab, var(--markdown-alert-accent) 9%, var(--ui-bg) 91%);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-left: 0.25em solid #444c56;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-title {
|
.markdown-alert-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-weight: 500;
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
color: color-mix(in oklab, var(--markdown-alert-accent) 70%, var(--ui-text-highlighted) 30%);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-note {
|
.markdown-alert-note {
|
||||||
border-left-color: #539bf5;
|
--markdown-alert-accent: var(--ui-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-tip {
|
.markdown-alert-tip {
|
||||||
border-left-color: #57ab5a;
|
--markdown-alert-accent: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-important {
|
.markdown-alert-important {
|
||||||
border-left-color: #986ee2;
|
--markdown-alert-accent: color-mix(in oklab, var(--ui-primary) 60%, var(--ui-info) 40%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-warning {
|
.markdown-alert-warning {
|
||||||
border-left-color: #c69026;
|
--markdown-alert-accent: var(--ui-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-caution {
|
.markdown-alert-caution {
|
||||||
border-left-color: #e5534b;
|
--markdown-alert-accent: var(--ui-error);
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-alert-note > .markdown-alert-title {
|
|
||||||
color: #539bf5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-alert-tip > .markdown-alert-title {
|
|
||||||
color: #57ab5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-alert-important > .markdown-alert-title {
|
|
||||||
color: #986ee2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-alert-warning > .markdown-alert-title {
|
|
||||||
color: #c69026;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-alert-caution > .markdown-alert-title {
|
|
||||||
color: #e5534b;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
word-break: break-word !important;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="relative min-h-64 min-w-0">
|
||||||
<div class="modal is-active">
|
<div v-if="isLoading" class="flex min-h-64 items-center justify-center text-toned">
|
||||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin" />
|
||||||
|
|
||||||
<div class="modal-content modal-content-max">
|
|
||||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="position: relative" v-if="!isLoading">
|
<UAlert
|
||||||
<Message v-if="error" class="is-warning" title="Error" icon="fas fa-exclamation">
|
v-else-if="error"
|
||||||
{{ error }}
|
color="warning"
|
||||||
</Message>
|
variant="soft"
|
||||||
<div class="card" v-else>
|
icon="i-lucide-triangle-alert"
|
||||||
<div class="card-body p-4">
|
title="Error"
|
||||||
<div class="content" v-html="content" />
|
:description="error"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
<div v-else class="docs-markdown min-w-0 max-w-none" v-html="content" @click="handleClick" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue';
|
import { Marked } from 'marked';
|
||||||
import { marked } from 'marked';
|
|
||||||
import { baseUrl } from 'marked-base-url';
|
import { baseUrl } from 'marked-base-url';
|
||||||
import markedAlert from 'marked-alert';
|
import markedAlert from 'marked-alert';
|
||||||
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
||||||
import Message from '~/components/Message.vue';
|
import { resolveDocsImageSrc, resolveDocsLink } from '~/composables/useDocs';
|
||||||
|
import { parse_api_error, request } from '~/utils';
|
||||||
|
|
||||||
|
type MarkdownToken = {
|
||||||
|
type: string;
|
||||||
|
href?: string;
|
||||||
|
title?: string | null;
|
||||||
|
text?: string;
|
||||||
|
tokens?: Array<unknown>;
|
||||||
|
header?: Array<{ tokens: Array<unknown> }>;
|
||||||
|
rows?: Array<Array<{ tokens: Array<unknown> }>>;
|
||||||
|
_external?: boolean;
|
||||||
|
_docRoute?: string;
|
||||||
|
_isExternalImage?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps<{ file: string }>();
|
const props = defineProps<{ file: string }>();
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
|
||||||
|
|
||||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg'];
|
const content = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const isLoading = ref(true);
|
||||||
|
|
||||||
const content = ref<string>('');
|
const createMarkdownParser = () => {
|
||||||
const error = ref<string>('');
|
const parser = new Marked();
|
||||||
const isLoading = ref<boolean>(true);
|
|
||||||
|
|
||||||
const handleClick = async (e: MouseEvent) => {
|
parser.use(gfmHeadingId());
|
||||||
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
|
parser.use(baseUrl(window.origin));
|
||||||
if (!target) {
|
parser.use(markedAlert());
|
||||||
return;
|
parser.use({
|
||||||
}
|
|
||||||
const href = target.getAttribute('data-url');
|
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
await loader(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addListeners = (): void => {
|
|
||||||
removeListeners();
|
|
||||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
|
||||||
const href = l.getAttribute('data-url');
|
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(l as HTMLElement).addEventListener('click', handleClick);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeListeners = (): void => {
|
|
||||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
|
||||||
const href = l.getAttribute('data-url');
|
|
||||||
if (!href) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(l as HTMLElement).removeEventListener('click', handleClick);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loader = async (file: string) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const response = await fetch(`${file}?_=${Date.now()}`);
|
|
||||||
if (true !== response.ok) {
|
|
||||||
const err = await response.json();
|
|
||||||
error.value = err.error.message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
marked.use(gfmHeadingId());
|
|
||||||
marked.use(baseUrl(window.origin));
|
|
||||||
marked.use(markedAlert());
|
|
||||||
marked.use({
|
|
||||||
gfm: true,
|
gfm: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
postprocess: (text: string) =>
|
postprocess: (text: string) =>
|
||||||
|
|
@ -162,81 +309,143 @@ const loader = async (file: string) => {
|
||||||
(_, list) =>
|
(_, list) =>
|
||||||
`<span class="icon"><i class="fas ${list
|
`<span class="icon"><i class="fas ${list
|
||||||
.split('.')
|
.split('.')
|
||||||
.map((n: string) => n.trim())
|
.map((name: string) => name.trim())
|
||||||
.join(' ')}"></i></span>`,
|
.join(' ')}"></i></span>`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
walkTokens: (token: any) => {
|
walkTokens: (token: MarkdownToken) => {
|
||||||
if (token.type !== 'link') {
|
if (token.type === 'link' && token.href) {
|
||||||
return;
|
const resolved = resolveDocsLink(token.href);
|
||||||
}
|
token.href = resolved.href;
|
||||||
if (token.href.startsWith('#')) {
|
token._external = resolved.external;
|
||||||
|
token._docRoute = resolved.docRoute;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urls.some((l) => token.href.includes(l))) {
|
if (token.type === 'image' && token.href) {
|
||||||
const name = urls.find((l) => token.href.includes(l)) || '';
|
const resolvedHref = resolveDocsImageSrc(token.href);
|
||||||
token._external = false;
|
token._isExternalImage = /^https?:\/\//i.test(resolvedHref);
|
||||||
token.href = `/${name}`;
|
token.href = resolvedHref;
|
||||||
} else {
|
|
||||||
token._external = true;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
link(token: any) {
|
link(token: MarkdownToken) {
|
||||||
const text = this.parser.parseInline(token.tokens);
|
const inlineTokens = Array.isArray(token.tokens) ? (token.tokens as any[]) : [];
|
||||||
|
const text = this.parser.parseInline(inlineTokens as any);
|
||||||
const title = token.title ? ` title="${token.title}"` : '';
|
const title = token.title ? ` title="${token.title}"` : '';
|
||||||
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
let local = '';
|
const docRoute = token._docRoute ? ` data-doc-route="${token._docRoute}"` : '';
|
||||||
const name = urls.find((l) => token.href.includes(l)) || '';
|
return `<a href="${token.href || ''}"${docRoute}${title}${attrs}>${text}</a>`;
|
||||||
if (name) {
|
|
||||||
local = ` data-url="/api/docs/${name}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`;
|
|
||||||
},
|
},
|
||||||
table(token: any) {
|
table(token: MarkdownToken) {
|
||||||
// `token.header` and `token.rows` are available
|
const headerHtml = `<thead><tr>${(token.header || [])
|
||||||
// Use default table output from built-in renderer to get header + body markup
|
.map((cell) => {
|
||||||
// Then wrap with classed <table>
|
const inlineTokens = Array.isArray((cell as any).tokens)
|
||||||
|
? ((cell as any).tokens as any[])
|
||||||
// We need to generate the inner HTML parts first
|
: [];
|
||||||
const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>`;
|
return `<th>${this.parser.parseInline(inlineTokens as any)}</th>`;
|
||||||
|
})
|
||||||
|
.join('')}</tr></thead>`;
|
||||||
|
|
||||||
const bodyHtml = (token.rows || [])
|
const bodyHtml = (token.rows || [])
|
||||||
.map((row: any[]) => {
|
.map((row) => {
|
||||||
const tr = row
|
const columns = row
|
||||||
.map((cell: any) => {
|
.map((cell) => {
|
||||||
return `<td>${this.parser.parseInline(cell.tokens)}</td>`;
|
const inlineTokens = Array.isArray((cell as any).tokens)
|
||||||
})
|
? ((cell as any).tokens as any[])
|
||||||
.join('');
|
: [];
|
||||||
return `<tr>${tr}</tr>`;
|
return `<td>${this.parser.parseInline(inlineTokens as any)}</td>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return `<div class="table-container"><table class="table is-striped is-hoverable is-fullwidth is-bordered">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>`;
|
return `<tr>${columns}</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<div class="docs-markdown__table-wrap"><table class="docs-markdown__table">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>`;
|
||||||
},
|
},
|
||||||
image(token: any) {
|
image(token: MarkdownToken) {
|
||||||
const alt = token.text ? ` alt="${token.text}"` : ' alt=""';
|
const alt = token.text ? ` alt="${token.text}"` : ' alt=""';
|
||||||
const title = token.title ? ` title="${token.title}"` : '';
|
const title = token.title ? ` title="${token.title}"` : '';
|
||||||
const refPolicy = ' referrerpolicy="no-referrer"';
|
const refPolicy = ' referrerpolicy="no-referrer"';
|
||||||
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
|
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
|
||||||
const loading = ' loading="lazy"';
|
const loading = ' loading="lazy"';
|
||||||
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
|
return `<img src="${token.href || ''}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
content.value = String(marked.parse(text));
|
return parser;
|
||||||
} catch (e: any) {
|
};
|
||||||
|
|
||||||
|
const loader = async (file: string) => {
|
||||||
|
if (!file) {
|
||||||
|
content.value = '';
|
||||||
|
error.value = '';
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
content.value = '';
|
||||||
|
const markdownParser = createMarkdownParser();
|
||||||
|
|
||||||
|
const response = await request(`${file}?_=${Date.now()}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = response.statusText || 'Failed to load documentation';
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = await parse_api_error(response.json());
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
error.value = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
content.value = String(markdownParser.parse(text));
|
||||||
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
error.value = e.message;
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => loader(props.file));
|
const handleClick = async (event: MouseEvent) => {
|
||||||
onUpdated(() => addListeners());
|
const target = (event.target as HTMLElement | null)?.closest(
|
||||||
onBeforeUnmount(() => removeListeners());
|
'a[data-doc-route]',
|
||||||
|
) as HTMLAnchorElement | null;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = target.getAttribute('data-doc-route');
|
||||||
|
if (!href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
await navigateTo(href);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.file,
|
||||||
|
async (file) => {
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loader(file);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="message">
|
|
||||||
<div
|
|
||||||
@click="$emit('toggle')"
|
|
||||||
class="is-clickable is-pulled-right is-unselectable"
|
|
||||||
v-if="useToggle"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
|
|
||||||
</span>
|
|
||||||
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="is-unselectable message-header"
|
|
||||||
:class="{ 'is-clickable': useToggle }"
|
|
||||||
v-if="title || icon"
|
|
||||||
@click="true === useToggle ? $emit('toggle', toggle) : null"
|
|
||||||
>
|
|
||||||
<template v-if="icon">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i :class="icon"></i></span>
|
|
||||||
<span>{{ title }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ title }}</template>
|
|
||||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="content message-body is-text-break"
|
|
||||||
v-if="false === useToggle || toggle"
|
|
||||||
:class="body_class"
|
|
||||||
>
|
|
||||||
<template v-if="message">{{ message }}</template>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** Title text for the notification */
|
|
||||||
title?: string | null;
|
|
||||||
/** Icon class for the notification */
|
|
||||||
icon?: string | null;
|
|
||||||
/** Main message content */
|
|
||||||
message?: string | null;
|
|
||||||
/** If true, show toggle button */
|
|
||||||
useToggle?: boolean;
|
|
||||||
/** Current toggle state */
|
|
||||||
toggle?: boolean;
|
|
||||||
/** If true, show close button */
|
|
||||||
useClose?: boolean;
|
|
||||||
body_class?: string | null;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
title: null,
|
|
||||||
icon: null,
|
|
||||||
message: null,
|
|
||||||
useToggle: false,
|
|
||||||
toggle: false,
|
|
||||||
useClose: false,
|
|
||||||
body_class: null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
/** Emitted when the toggle button is clicked */
|
|
||||||
(e: 'toggle', value?: boolean): void;
|
|
||||||
/** Emitted when the close button is clicked */
|
|
||||||
(e: 'close'): void;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<style>
|
|
||||||
.model-content {
|
|
||||||
width: 70vw;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="modal is-active">
|
|
||||||
<div class="model-title" v-if="title" />
|
|
||||||
<div class="modal-background" @click="emitter('close')"></div>
|
|
||||||
<div class="modal-content" :class="contentClass">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
<button class="modal-close is-large" aria-label="close" @click="emitter('close')"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
|
||||||
|
|
||||||
const emitter = defineEmits(['close']);
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
contentClass: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== 'Escape') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emitter('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('keydown', handle_event);
|
|
||||||
disableOpacity();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('keydown', handle_event);
|
|
||||||
enableOpacity();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
<style scoped>
|
|
||||||
code {
|
|
||||||
color: var(--bulma-code) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="modal is-active" v-if="false === externalModel">
|
|
||||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
|
||||||
<div class="modal-content modal-content-max">
|
|
||||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="content p-0 m-0" style="position: relative">
|
|
||||||
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" />
|
|
||||||
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;">
|
|
||||||
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')">
|
|
||||||
<span class="icon"><i class="fas fa-text-width" /></span>
|
|
||||||
</button>
|
|
||||||
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))" >
|
|
||||||
<span class="icon"><i class="fas fa-copy" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="modal-close is-large"
|
|
||||||
aria-label="close"
|
|
||||||
@click="emitter('closeModel')"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-content-max" style="height: 80vh" v-else>
|
|
||||||
<div class="content p-0 m-0" style="position: relative">
|
|
||||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<pre
|
|
||||||
:class="[code_classes, custom_classes]"
|
|
||||||
><code class="p-4 is-block" v-text="data" /></pre>
|
|
||||||
<div class="m-4 is-flex" style="position: absolute; top: 0; right: 0">
|
|
||||||
<button
|
|
||||||
class="button is-small is-purple mr-3"
|
|
||||||
@click="() => toggleClass('is-pre-wrap-force')"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-text-width" /></span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="button is-info is-small"
|
|
||||||
@click="() => copyText(JSON.stringify(data, null, 2))"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-copy" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{ externalModel?: boolean; data: any; code_classes?: string; isLoading?: boolean }>(),
|
|
||||||
{
|
|
||||||
code_classes: '',
|
|
||||||
isLoading: false,
|
|
||||||
externalModel: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const custom_classes = useStorage<string>('modal_text_classes', '');
|
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent): void => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
emitter('closeModel');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async (): Promise<void> => {
|
|
||||||
disableOpacity();
|
|
||||||
document.addEventListener('keydown', handle_event);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
enableOpacity();
|
|
||||||
document.removeEventListener('keydown', handle_event);
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleClass = (className: string) => {
|
|
||||||
if (custom_classes.value.includes(className)) {
|
|
||||||
custom_classes.value = custom_classes.value.replace(className, '').trim();
|
|
||||||
} else {
|
|
||||||
custom_classes.value += ` ${className}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
||||||
<template>
|
|
||||||
<Message class="is-success">
|
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
|
||||||
<span>
|
|
||||||
A New WebUI Version installed, please
|
|
||||||
<NuxtLink class="is-bold" @click="reloadPage">click here</NuxtLink>
|
|
||||||
to reload the app.
|
|
||||||
</span>
|
|
||||||
</Message>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Message from '~/components/Message.vue';
|
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
|
||||||
</script>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,143 +1,202 @@
|
||||||
<style scoped>
|
|
||||||
.notification-item {
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
border-bottom: 1px solid #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-info {
|
|
||||||
border-color: var(--bulma-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-success {
|
|
||||||
border-color: var(--bulma-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-warning {
|
|
||||||
border-color: var(--bulma-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-error {
|
|
||||||
border-color: var(--bulma-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-message.expanded {
|
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<UPopover :content="{ align: 'end', side: 'bottom', sideOffset: 8 }">
|
||||||
<a class="navbar-link">
|
<UButton color="neutral" variant="ghost" size="sm">
|
||||||
<span class="icon"><i class="fas fa-bell" /></span>
|
<template #leading>
|
||||||
<span class="tag ml-2" :class="store.severityColor">
|
<UIcon name="i-lucide-bell" class="size-4" />
|
||||||
<span :class="{ 'is-bold': store.unreadCount }">{{ store.unreadCount }}</span>
|
|
||||||
<span> / </span>
|
|
||||||
<span class="is-underlined">{{ store.notifications.length }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span>
|
|
||||||
</a>
|
|
||||||
<div class="navbar-dropdown is-right" style="width: 400px">
|
|
||||||
<template v-if="store.notifications.length > 0">
|
|
||||||
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
|
||||||
<span class="has-text-grey"></span>
|
|
||||||
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control" v-if="store.unreadCount > 0">
|
|
||||||
<button class="button is-small is-light mr-1" @click="store.markAllRead">
|
|
||||||
<span class="icon"><i class="fas fa-check" /></span>
|
|
||||||
<span>Mark all read</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-small is-danger is-light" @click="store.clear">
|
|
||||||
<span class="icon"><i class="fas fa-trash" /></span>
|
|
||||||
<span>Clear all</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr class="navbar-divider" />
|
|
||||||
</template>
|
</template>
|
||||||
<div class="notification-list">
|
<span class="hidden sm:inline">Notifications</span>
|
||||||
<div
|
<template #trailing>
|
||||||
v-for="n in store.notifications"
|
<UBadge :color="severityTone" variant="soft" size="sm"
|
||||||
:key="n.id"
|
>{{ store.unreadCount }}/{{ store.notifications.length }}</UBadge
|
||||||
class="pr-1 pl-1 navbar-item is-flex is-align-items-start"
|
|
||||||
:class="['notification-item', 'notification-' + n.level]"
|
|
||||||
>
|
>
|
||||||
<div class="is-flex-grow-1">
|
</template>
|
||||||
<p
|
</UButton>
|
||||||
class="is-size-7 mb-1 notification-message"
|
|
||||||
:class="{ expanded: expandedId === n.id }"
|
<template #content>
|
||||||
@click="toggleExpand(n.id)"
|
<UCard
|
||||||
|
class="w-md max-w-[calc(100vw-1rem)]"
|
||||||
|
:ui="{
|
||||||
|
header: 'flex items-center justify-between gap-3',
|
||||||
|
body: 'sm:p-2 p-0',
|
||||||
|
footer: 'flex justify-end gap-2',
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
{{ n.message }}
|
<template #header>
|
||||||
</p>
|
<div>
|
||||||
<p class="is-size-7 has-text-grey">
|
<p class="text-sm font-semibold text-highlighted">Notifications</p>
|
||||||
<span
|
<p class="text-xs text-toned">Recent activity and errors.</p>
|
||||||
:date-datetime="n.created"
|
</div>
|
||||||
v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
|
|
||||||
v-rtime="n.created"
|
<UBadge :color="severityTone" variant="soft" size="sm">
|
||||||
/>
|
{{ store.unreadCount }} unread
|
||||||
-
|
</UBadge>
|
||||||
<NuxtLink @click="copy_text(n.id, n.message)">
|
</template>
|
||||||
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
|
|
||||||
|
<template #default>
|
||||||
|
<div v-if="store.sortedNotifications.length > 0" class="max-h-104 overflow-y-auto p-0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UAlert
|
||||||
|
v-for="item in store.sortedNotifications"
|
||||||
|
:key="item.id"
|
||||||
|
orientation="horizontal"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
:icon="notificationIcon(item.level)"
|
||||||
|
:title="item.message"
|
||||||
|
:description="moment(item.created).fromNow()"
|
||||||
|
:ui="{
|
||||||
|
root: 'rounded-md border border-default border-l-4 bg-default px-3 py-2 transition-colors hover:bg-muted/40',
|
||||||
|
icon: 'size-4 mt-0.5',
|
||||||
|
title:
|
||||||
|
expandedId === item.id
|
||||||
|
? 'whitespace-normal break-words text-sm'
|
||||||
|
: 'truncate text-sm',
|
||||||
|
description: 'mt-1 text-xs text-toned',
|
||||||
|
actions: 'items-center gap-1 ml-2',
|
||||||
|
}"
|
||||||
|
:class="notificationAlertClass(item.level, item.id)"
|
||||||
|
@click="handleNotificationClick(item.id)"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span class="flex items-center gap-1 text-xs text-toned">
|
||||||
|
<UTooltip :text="moment(item.created).format('YYYY-M-DD H:mm Z')">
|
||||||
|
<span :date-datetime="item.created" v-rtime="item.created" />
|
||||||
|
</UTooltip>
|
||||||
|
<span>-</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="underline underline-offset-2"
|
||||||
|
@click.stop="copy_text(item.id, item.message)"
|
||||||
|
>
|
||||||
|
<span v-if="copiedId === item.id" class="text-success">Copied!</span>
|
||||||
<span v-else>Copy</span>
|
<span v-else>Copy</span>
|
||||||
</NuxtLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 is-flex is-flex-direction-column is-justify-content-center">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control" v-if="!n.seen">
|
|
||||||
<button class="button is-small is-light" @click="store.markRead(n.id)">
|
|
||||||
<span class="icon"><i class="fas fa-check" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-danger is-small" @click="store.remove(n.id)">
|
|
||||||
<span class="icon"><i class="fas fa-trash" /></span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
v-if="!item.seen"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
square
|
||||||
|
@click.stop="store.markRead(item.id)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
square
|
||||||
|
@click.stop="store.remove(item.id)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-trash" class="size-3.5" />
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<UEmpty
|
||||||
<div v-if="store.notifications.length < 1" class="navbar-item is-flex is-align-items-start">
|
v-else
|
||||||
<div class="is-flex-grow-1 has-text-centered has-text-grey">
|
icon="i-lucide-inbox"
|
||||||
<p class="is-size-7">No notifications</p>
|
title="No notifications"
|
||||||
</div>
|
description="You do not have any stored notifications yet."
|
||||||
</div>
|
class="px-4 py-8"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
<template #footer>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-check"
|
||||||
|
:disabled="store.unreadCount === 0"
|
||||||
|
@click="store.markAllRead()"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
:disabled="store.notifications.length === 0"
|
||||||
|
@click="store.clear()"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import type { notificationType } from '~/composables/useNotification';
|
||||||
|
|
||||||
const store = useNotificationStore();
|
const store = useNotificationStore();
|
||||||
|
|
||||||
const copiedId = ref<string | null>(null);
|
const copiedId = ref<string | null>(null);
|
||||||
const expandedId = ref<string | null>(null);
|
const expandedId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const severityTone = computed(() => {
|
||||||
|
switch (store.severityLevel) {
|
||||||
|
case 'error':
|
||||||
|
return 'error' as const;
|
||||||
|
case 'warning':
|
||||||
|
return 'warning' as const;
|
||||||
|
case 'success':
|
||||||
|
return 'success' as const;
|
||||||
|
case 'info':
|
||||||
|
return 'info' as const;
|
||||||
|
default:
|
||||||
|
return 'neutral' as const;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationIcon = (level: notificationType): string => {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
return 'i-lucide-triangle-alert';
|
||||||
|
case 'warning':
|
||||||
|
return 'i-lucide-circle-alert';
|
||||||
|
case 'success':
|
||||||
|
return 'i-lucide-badge-check';
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return 'i-lucide-info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationAlertClass = (level: notificationType, id: string): string => {
|
||||||
|
const selectedClass = expandedId.value === id ? 'bg-muted/55 ring-1 ring-inset ring-default' : '';
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
return `border-l-error text-default ${selectedClass}`.trim();
|
||||||
|
case 'warning':
|
||||||
|
return `border-l-warning text-default ${selectedClass}`.trim();
|
||||||
|
case 'success':
|
||||||
|
return `border-l-success text-default ${selectedClass}`.trim();
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return `border-l-info text-default ${selectedClass}`.trim();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleExpand = (id: string) => (expandedId.value = expandedId.value === id ? null : id);
|
const toggleExpand = (id: string) => (expandedId.value = expandedId.value === id ? null : id);
|
||||||
|
|
||||||
|
const handleNotificationClick = (id: string) => {
|
||||||
|
toggleExpand(id);
|
||||||
|
store.markRead(id);
|
||||||
|
};
|
||||||
|
|
||||||
const copy_text = (id: string, text: string): void => {
|
const copy_text = (id: string, text: string): void => {
|
||||||
copiedId.value = id;
|
copiedId.value = id;
|
||||||
copyText(text, false, false);
|
copyText(text, false, false);
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,115 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="field is-grouped">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
|
||||||
rel="first"
|
|
||||||
class="button"
|
|
||||||
v-if="page !== 1"
|
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)"
|
@click="changePage(1)"
|
||||||
:disabled="isLoading"
|
/>
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
>
|
<UButton
|
||||||
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
rel="prev"
|
|
||||||
class="button"
|
|
||||||
v-if="page > 1 && page - 1 !== 1"
|
v-if="page > 1 && page - 1 !== 1"
|
||||||
@click="changePage(page - 1)"
|
rel="prev"
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-chevron-left"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
:class="{ 'is-loading': isLoading }"
|
:loading="isLoading"
|
||||||
>
|
square
|
||||||
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
@click="changePage(page - 1)"
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<USelect
|
||||||
<div class="select">
|
|
||||||
<select
|
|
||||||
id="pager_list"
|
id="pager_list"
|
||||||
v-model="currentPage"
|
v-model="currentPage"
|
||||||
@change="changePage(currentPage)"
|
:items="paginationItems"
|
||||||
|
value-key="page"
|
||||||
|
label-key="text"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="min-w-52"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
:ui="{ base: 'w-full' }"
|
||||||
<option
|
@update:model-value="changePage"
|
||||||
v-for="(item, index) in makePagination(page, last_page)"
|
/>
|
||||||
:key="`pager-${index}`"
|
|
||||||
:value="item.page"
|
<UButton
|
||||||
:disabled="0 === item.page"
|
|
||||||
>
|
|
||||||
{{ item.text }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
rel="next"
|
|
||||||
class="button"
|
|
||||||
v-if="page !== last_page && page + 1 !== last_page"
|
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)"
|
@click="changePage(page + 1)"
|
||||||
:disabled="isLoading"
|
/>
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
>
|
<UButton
|
||||||
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
rel="last"
|
|
||||||
class="button"
|
|
||||||
v-if="page !== last_page"
|
v-if="page !== last_page"
|
||||||
@click="changePage(last_page)"
|
rel="last"
|
||||||
|
aria-label="Go to last page"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-chevrons-right"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
:class="{ 'is-loading': isLoading }"
|
:loading="isLoading"
|
||||||
>
|
square
|
||||||
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
@click="changePage(last_page)"
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { makePagination } from '~/utils/index';
|
import { makePagination } from '~/utils/index';
|
||||||
|
|
||||||
const emitter = defineEmits(['navigate']);
|
const emitter = defineEmits<{
|
||||||
|
(e: 'navigate', page: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
page: {
|
page: number;
|
||||||
type: Number,
|
last_page: number;
|
||||||
required: true,
|
isLoading?: boolean;
|
||||||
},
|
}>();
|
||||||
last_page: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const changePage = (p) => {
|
|
||||||
if (p < 1 || p > props.last_page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emitter('navigate', p);
|
|
||||||
currentPage.value = p;
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentPage = ref(props.page);
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="popover-wrapper"
|
|
||||||
ref="triggerElm"
|
|
||||||
:tabindex="triggerTabIndex"
|
|
||||||
@mouseenter="handleHoverEnter"
|
|
||||||
@mouseleave="handleHoverLeave"
|
|
||||||
@focusin="handleFocusIn"
|
|
||||||
@focusout="handleFocusOut"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<div class="popover-trigger">
|
|
||||||
<slot name="trigger">
|
|
||||||
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i class="fas fa-info-circle" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="isOpen" class="popover-portal" ref="portal">
|
|
||||||
<div
|
|
||||||
ref="popover"
|
|
||||||
class="popover-card box"
|
|
||||||
:class="placementClass"
|
|
||||||
role="tooltip"
|
|
||||||
:style="portalStyle"
|
|
||||||
@mouseenter="handleHoverEnter"
|
|
||||||
@mouseleave="handleHoverLeave"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button is-bold is-fullwidth is-hidden-tablet"
|
|
||||||
@click="closePopover"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-times" /></span>
|
|
||||||
<span>Close</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<header v-if="hasTitleContent" class="popover-title mb-2">
|
|
||||||
<slot name="title">
|
|
||||||
<p class="title is-6 mb-1">{{ title }}</p>
|
|
||||||
</slot>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section v-if="hasBodyContent" class="popover-body">
|
|
||||||
<slot>
|
|
||||||
<p class="is-size-7 mb-0">{{ description }}</p>
|
|
||||||
</slot>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { PopoverProps } from '~/types/popover';
|
|
||||||
import {
|
|
||||||
computed,
|
|
||||||
nextTick,
|
|
||||||
onBeforeUnmount,
|
|
||||||
onMounted,
|
|
||||||
ref,
|
|
||||||
useSlots,
|
|
||||||
watch,
|
|
||||||
useTemplateRef,
|
|
||||||
} from 'vue';
|
|
||||||
|
|
||||||
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>();
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
|
||||||
placement: 'top',
|
|
||||||
trigger: 'hover',
|
|
||||||
offset: 10,
|
|
||||||
disabled: false,
|
|
||||||
closeOnClickOutside: true,
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
minWidth: 220,
|
|
||||||
maxWidth: 340,
|
|
||||||
maxHeight: 360,
|
|
||||||
showDelay: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = useSlots();
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const triggerRef = useTemplateRef<HTMLElement>('triggerElm');
|
|
||||||
const popover = useTemplateRef<HTMLDivElement>('popover');
|
|
||||||
const portalStyle = ref<Record<string, string>>({});
|
|
||||||
const hoverTimeout = ref<number | null>(null);
|
|
||||||
const openTimeout = ref<number | null>(null);
|
|
||||||
|
|
||||||
const placementClass = computed(() => `is-${props.placement}`);
|
|
||||||
const triggerTabIndex = computed(() => (props.trigger === 'hover' ? -1 : 0));
|
|
||||||
|
|
||||||
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title));
|
|
||||||
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default));
|
|
||||||
|
|
||||||
const clearHoverTimeout = () => {
|
|
||||||
if (null !== hoverTimeout.value) {
|
|
||||||
window.clearTimeout(hoverTimeout.value);
|
|
||||||
hoverTimeout.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearOpenTimeout = () => {
|
|
||||||
if (null !== openTimeout.value) {
|
|
||||||
window.clearTimeout(openTimeout.value);
|
|
||||||
openTimeout.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleClose = () => {
|
|
||||||
clearHoverTimeout();
|
|
||||||
clearOpenTimeout();
|
|
||||||
hoverTimeout.value = window.setTimeout(() => {
|
|
||||||
isOpen.value = false;
|
|
||||||
}, 120);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPopoverImmediate = async () => {
|
|
||||||
if (props.disabled || isOpen.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpen.value = true;
|
|
||||||
await nextTick();
|
|
||||||
updatePosition();
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPopover = () => {
|
|
||||||
clearOpenTimeout();
|
|
||||||
if (props.showDelay && 0 < props.showDelay) {
|
|
||||||
openTimeout.value = window.setTimeout(() => {
|
|
||||||
openTimeout.value = null;
|
|
||||||
void openPopoverImmediate();
|
|
||||||
}, props.showDelay);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void openPopoverImmediate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePopover = () => {
|
|
||||||
if (!isOpen.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePopover = async () => {
|
|
||||||
if (isOpen.value) {
|
|
||||||
closePopover();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openPopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHoverEnter = () => {
|
|
||||||
if ('hover' !== props.trigger || props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHoverTimeout();
|
|
||||||
openPopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHoverLeave = () => {
|
|
||||||
if ('hover' !== props.trigger) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOpenTimeout();
|
|
||||||
scheduleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusIn = () => {
|
|
||||||
if ('focus' !== props.trigger || props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
openPopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocusOut = () => {
|
|
||||||
if ('focus' !== props.trigger) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearOpenTimeout();
|
|
||||||
scheduleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if ('click' !== props.trigger || props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
void togglePopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDocumentClick = (event: MouseEvent) => {
|
|
||||||
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target as Node | null;
|
|
||||||
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target));
|
|
||||||
const isInsidePopover = Boolean(popover.value && popover.value.contains(target));
|
|
||||||
|
|
||||||
if (!isInsideTrigger && !isInsidePopover) {
|
|
||||||
clearOpenTimeout();
|
|
||||||
closePopover();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if ('Escape' === event.key && isOpen.value) {
|
|
||||||
closePopover();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
if (!triggerRef.value || !popover.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96);
|
|
||||||
|
|
||||||
popover.value.style.minWidth = `${props.minWidth}px`;
|
|
||||||
popover.value.style.maxWidth = `${constrainedMaxWidth}px`;
|
|
||||||
popover.value.style.maxHeight = `${props.maxHeight}px`;
|
|
||||||
popover.value.style.overflowY = 'auto';
|
|
||||||
|
|
||||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
|
||||||
const popoverRect = popover.value.getBoundingClientRect();
|
|
||||||
const offset = props.offset;
|
|
||||||
|
|
||||||
let effectivePlacement = props.placement;
|
|
||||||
|
|
||||||
if ('top' === props.placement) {
|
|
||||||
const spaceAbove = triggerRect.top - offset;
|
|
||||||
if (spaceAbove < popoverRect.height) {
|
|
||||||
effectivePlacement = 'bottom';
|
|
||||||
}
|
|
||||||
} else if ('bottom' === props.placement) {
|
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom - offset;
|
|
||||||
if (spaceBelow < popoverRect.height) {
|
|
||||||
effectivePlacement = 'top';
|
|
||||||
}
|
|
||||||
} else if ('left' === props.placement) {
|
|
||||||
const spaceLeft = triggerRect.left - offset;
|
|
||||||
if (spaceLeft < popoverRect.width) {
|
|
||||||
effectivePlacement = 'right';
|
|
||||||
}
|
|
||||||
} else if ('right' === props.placement) {
|
|
||||||
const spaceRight = viewportWidth - triggerRect.right - offset;
|
|
||||||
if (spaceRight < popoverRect.width) {
|
|
||||||
effectivePlacement = 'left';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let top = triggerRect.bottom + offset;
|
|
||||||
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
|
||||||
|
|
||||||
if ('top' === effectivePlacement) {
|
|
||||||
top = triggerRect.top - popoverRect.height - offset;
|
|
||||||
} else if ('left' === effectivePlacement) {
|
|
||||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
||||||
left = triggerRect.left - popoverRect.width - offset;
|
|
||||||
} else if ('right' === effectivePlacement) {
|
|
||||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
|
||||||
left = triggerRect.right + offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
||||||
const maxLeft = viewportWidth - popoverRect.width - 8;
|
|
||||||
const maxTop = viewportHeight - popoverRect.height - 8;
|
|
||||||
|
|
||||||
const clampedTop = clamp(Math.round(top), 8, maxTop);
|
|
||||||
const clampedLeft = clamp(Math.round(left), 8, maxLeft);
|
|
||||||
|
|
||||||
portalStyle.value = {
|
|
||||||
position: 'fixed',
|
|
||||||
top: `${clampedTop}px`,
|
|
||||||
left: `${clampedLeft}px`,
|
|
||||||
minWidth: `${props.minWidth}px`,
|
|
||||||
maxWidth: `${constrainedMaxWidth}px`,
|
|
||||||
maxHeight: `${props.maxHeight}px`,
|
|
||||||
overflowY: 'auto',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(isOpen, async (value) => {
|
|
||||||
if (value) {
|
|
||||||
await nextTick();
|
|
||||||
updatePosition();
|
|
||||||
await nextTick();
|
|
||||||
emit('shown');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleDocumentClick, true);
|
|
||||||
document.addEventListener('keydown', handleEscape);
|
|
||||||
window.addEventListener('resize', updatePosition);
|
|
||||||
window.addEventListener('scroll', updatePosition, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handleDocumentClick, true);
|
|
||||||
document.removeEventListener('keydown', handleEscape);
|
|
||||||
window.removeEventListener('resize', updatePosition);
|
|
||||||
window.removeEventListener('scroll', updatePosition, true);
|
|
||||||
clearHoverTimeout();
|
|
||||||
clearOpenTimeout();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.popover-wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-trigger > * {
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-portal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1050;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card {
|
|
||||||
position: fixed;
|
|
||||||
background-color: var(--bulma-scheme-main, #fff);
|
|
||||||
color: var(--bulma-text, #363636);
|
|
||||||
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
|
|
||||||
box-shadow: var(
|
|
||||||
--bulma-shadow,
|
|
||||||
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
|
|
||||||
0 0px 0 1px rgba(10, 10, 10, 0.02)
|
|
||||||
);
|
|
||||||
border-radius: var(--bulma-radius-large, 0.5rem);
|
|
||||||
pointer-events: auto;
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-title {
|
|
||||||
color: var(--bulma-text-strong, #1f1f1f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: inherit;
|
|
||||||
border-left: inherit;
|
|
||||||
border-top: inherit;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card.is-top::after {
|
|
||||||
bottom: -6px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card.is-bottom::after {
|
|
||||||
top: -6px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card.is-left::after {
|
|
||||||
right: -6px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-card.is-right::after {
|
|
||||||
left: -6px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) rotate(45deg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,354 +1,350 @@
|
||||||
<template>
|
<template>
|
||||||
<main class="columns mt-2 is-multiline">
|
<form id="presetForm" autocomplete="off" class="space-y-6" @submit.prevent="checkInfo">
|
||||||
<div class="column is-12">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<form autocomplete="off" id="presetForm" @submit.prevent="checkInfo">
|
<div v-if="reference" class="md:col-span-2 flex justify-end">
|
||||||
<div class="card">
|
<UButton
|
||||||
<div class="card-header">
|
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
|
||||||
/></span>
|
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-header-icon" v-if="reference">
|
|
||||||
<button type="button" @click="showImport = !showImport">
|
|
||||||
<span class="icon"
|
|
||||||
><i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-arrow-down': !showImport,
|
|
||||||
'fa-arrow-up': showImport,
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="columns is-multiline is-mobile">
|
|
||||||
<template v-if="showImport || !reference">
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-inline" for="import_string">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
|
||||||
Import from pre-existing preset
|
|
||||||
</label>
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<div class="select is-fullwidth">
|
|
||||||
<select
|
|
||||||
class="is-fullwidth"
|
|
||||||
v-model="selected_preset"
|
|
||||||
@change="import_existing_preset"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select a preset</option>
|
|
||||||
<optgroup
|
|
||||||
label="Custom presets"
|
|
||||||
v-if="config?.presets.filter((p) => !p?.default).length > 0"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="item in filter_presets(false)"
|
|
||||||
:key="item.name"
|
|
||||||
:value="item.name"
|
|
||||||
>
|
|
||||||
{{ prettyName(item.name) }}
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Default presets">
|
|
||||||
<option
|
|
||||||
v-for="item in filter_presets(true)"
|
|
||||||
:key="item.name"
|
|
||||||
:value="item.name"
|
|
||||||
>
|
|
||||||
{{ prettyName(item.name) }}
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span
|
|
||||||
>Select a preset to import its data.
|
|
||||||
<span class="has-text-danger"
|
|
||||||
>Warning: This will overwrite the current form data.</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-inline" for="import_string">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
|
||||||
Import string
|
|
||||||
</label>
|
|
||||||
<div class="field-body">
|
|
||||||
<div class="field has-addons">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
id="import_string"
|
|
||||||
v-model="import_string"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
:disabled="!import_string"
|
|
||||||
type="button"
|
type="button"
|
||||||
@click="importItem"
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="showImport ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
@click="showImport = !showImport"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
{{ showImport ? 'Hide' : 'Show' }} import
|
||||||
<span>Import</span>
|
</UButton>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span
|
|
||||||
>Paste shared preset string here to import it.
|
|
||||||
<span class="has-text-danger"
|
|
||||||
>Warning: This will overwrite the current form data.</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showImport || !reference">
|
||||||
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-copy" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Import from pre-existing preset</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<template #description>
|
||||||
<div class="field">
|
<span>
|
||||||
<label class="label is-inline" for="name">
|
Select a preset to import its data. Warning: This will overwrite the current form
|
||||||
<span class="icon"><i class="fa-solid fa-tag" /></span>
|
data.
|
||||||
Name
|
</span>
|
||||||
</label>
|
</template>
|
||||||
<div class="control">
|
|
||||||
<input
|
<USelect
|
||||||
|
v-model="selectedPreset"
|
||||||
|
:items="importPresetItems"
|
||||||
|
placeholder="Select a preset"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'w-full' }"
|
||||||
|
@update:model-value="() => void importExistingPreset()"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-import" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Import string</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
<span>
|
||||||
|
Paste shared preset string here to import it. Warning: This will overwrite the current
|
||||||
|
form data.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<UInput
|
||||||
|
id="import_string"
|
||||||
|
v-model="importString"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
autocomplete="off"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
icon="i-lucide-import"
|
||||||
|
size="lg"
|
||||||
|
:disabled="!importString"
|
||||||
|
class="justify-center sm:min-w-28"
|
||||||
|
@click="() => void importItem()"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-type" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Name</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
<span>Names are stored in lowercase with underscores (no spaces).</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Names are stored in lowercase with underscores (no spaces).</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<UFormField class="w-full" :ui="fieldUi">
|
||||||
<div class="field">
|
<template #label>
|
||||||
<label class="label is-inline" for="priority">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
<UIcon name="i-lucide-list-ordered" class="size-4 text-toned" />
|
||||||
Priority
|
<span class="font-semibold text-default">Priority</span>
|
||||||
</label>
|
</div>
|
||||||
<div class="control">
|
</template>
|
||||||
<input
|
|
||||||
type="number"
|
<template #description>
|
||||||
class="input"
|
<span>Higher priority presets appear first in the list.</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
id="priority"
|
id="priority"
|
||||||
v-model.number="form.priority"
|
v-model.number="form.priority"
|
||||||
:disabled="addInProgress"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
|
size="lg"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
<span class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span class="is-bold">Higher priority presets appear first in the list.</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<UFormField
|
||||||
<label class="label is-inline" for="folder">
|
class="w-full"
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
:ui="fieldUi"
|
||||||
download path
|
description="Use this defined path if none are given with the URL."
|
||||||
</label>
|
>
|
||||||
<div class="field has-addons">
|
<template #label>
|
||||||
<div class="control" v-tooltip="`Full Path: ${config.app.download_path}`">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="button is-static">
|
<UIcon name="i-lucide-folder-output" class="size-4 text-toned" />
|
||||||
<span>{{ shortPath(config.app.download_path) }}</span>
|
<span class="font-semibold text-default">Download path</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
</template>
|
||||||
<input
|
|
||||||
type="text"
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
class="input"
|
<UTooltip :text="`Full Path: ${config.app.download_path}`">
|
||||||
|
<div
|
||||||
|
class="inline-flex min-h-11 items-center rounded-md border border-default bg-muted/30 px-3 text-sm text-toned"
|
||||||
|
>
|
||||||
|
{{ shortPath(config.app.download_path) }}
|
||||||
|
</div>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<UInput
|
||||||
id="folder"
|
id="folder"
|
||||||
placeholder="Leave empty to use default download path"
|
|
||||||
v-model="form.folder"
|
v-model="form.folder"
|
||||||
:disabled="addInProgress"
|
|
||||||
list="folders"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Use this defined path if non are given with the URL.</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
|
||||||
<div class="field">
|
|
||||||
<label class="label is-inline" for="output_template">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file" /></span>
|
|
||||||
Output template
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
list="folders"
|
||||||
id="output_template"
|
placeholder="Leave empty to use default download path"
|
||||||
|
size="lg"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
placeholder="Leave empty to use default template."
|
class="w-full"
|
||||||
v-model="form.template"
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
</UFormField>
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Use this output template if non are given with URL.</span>
|
<UFormField
|
||||||
</span>
|
class="w-full"
|
||||||
|
:ui="fieldUi"
|
||||||
|
description="Use this output template if none are given with URL."
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-file-code-2" class="size-4 text-toned" />
|
||||||
|
<span class="font-semibold text-default">Output template</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UInput
|
||||||
|
id="output_template"
|
||||||
|
v-model="form.template"
|
||||||
|
type="text"
|
||||||
|
placeholder="Leave empty to use default template."
|
||||||
|
size="lg"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="space-y-5 border-t border-default pt-5">
|
||||||
<div class="field">
|
<UFormField class="w-full" :ui="editorFieldUi">
|
||||||
<label class="label is-unselectable" for="cli_options">
|
<template #label>
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span>Command options for yt-dlp</span>
|
<UIcon name="i-lucide-terminal" class="size-4 text-toned" />
|
||||||
</label>
|
<span class="font-semibold text-default">Command options for yt-dlp</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #description>
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
<button type="button" class="text-primary hover:underline" @click="showOptions = true">
|
||||||
|
View all options</button
|
||||||
|
>. Not all options are supported;
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
href="https://github.com/arabcoders/ytptube/blob/master/app/features/ytdlp/utils.py#L29"
|
||||||
|
>
|
||||||
|
some are ignored.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
<TextareaAutocomplete
|
<TextareaAutocomplete
|
||||||
id="cli_options"
|
id="cli_options"
|
||||||
v-model="form.cli"
|
v-model="form.cli"
|
||||||
:options="ytDlpOpt"
|
:options="ytDlpOpt"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
/>
|
/>
|
||||||
<span class="help is-bold">
|
</UFormField>
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>
|
|
||||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
|
|
||||||
options are supported
|
|
||||||
<NuxtLink
|
|
||||||
target="_blank"
|
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
|
|
||||||
>some are ignored</NuxtLink
|
|
||||||
>. Use with caution.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="grid gap-5 border-t border-default pt-5 md:grid-cols-2">
|
||||||
<div class="field">
|
<div class="space-y-3">
|
||||||
<label
|
<UFormField class="h-full w-full" :ui="editorFieldUi">
|
||||||
class="label is-inline"
|
<template #label>
|
||||||
for="cookies"
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
v-tooltip="'Netscape HTTP Cookie format.'"
|
<UIcon name="i-lucide-cookie" class="size-4 text-toned" />
|
||||||
|
<span>Cookies</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
Use this cookies if none are given with the URL.
|
||||||
|
<NuxtLink
|
||||||
|
target="_blank"
|
||||||
|
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
|
||||||
|
class="text-sm text-primary hover:underline"
|
||||||
|
>Recommended addon</NuxtLink
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
</p>
|
||||||
Cookies -
|
</template>
|
||||||
<NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()"
|
<template #hint>
|
||||||
>Upload file</NuxtLink
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm font-medium text-primary hover:underline"
|
||||||
|
@click="triggerCookieUpload"
|
||||||
>
|
>
|
||||||
</label>
|
Upload file
|
||||||
<div class="control">
|
</button>
|
||||||
|
</template>
|
||||||
<TextDropzone
|
<TextDropzone
|
||||||
ref="cookiesDropzoneRef"
|
ref="cookiesDropzoneRef"
|
||||||
id="cookies"
|
id="cookies"
|
||||||
|
:rows="7"
|
||||||
v-model="form.cookies"
|
v-model="form.cookies"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
@error="(msg: string) => toast.error(msg)"
|
@error="(msg: string) => toast.error(msg)"
|
||||||
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here."
|
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here."
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span
|
|
||||||
>Use this cookies if non are given with the URL. Use the
|
|
||||||
<NuxtLink
|
|
||||||
target="_blank"
|
|
||||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
|
|
||||||
>
|
|
||||||
Recommended addon</NuxtLink
|
|
||||||
>
|
|
||||||
by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP Cookie
|
|
||||||
format.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="space-y-3">
|
||||||
<div class="field">
|
<UFormField class="h-full w-full" :ui="editorFieldUi">
|
||||||
<label class="label is-inline" for="description">
|
<template #label>
|
||||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
Description
|
<UIcon name="i-lucide-message-square-text" class="size-4 text-toned" />
|
||||||
</label>
|
<span class="font-semibold text-default">Description</span>
|
||||||
<div class="control">
|
</div>
|
||||||
<textarea
|
</template>
|
||||||
class="textarea"
|
|
||||||
|
<template #description>
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
Use this field to help users to understand how to use this preset.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UTextarea
|
||||||
id="description"
|
id="description"
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:disabled="addInProgress"
|
:disabled="addInProgress"
|
||||||
placeholder="Extras instructions for users to follow"
|
placeholder="Extras instructions for users to follow"
|
||||||
|
:rows="7"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
class="w-full"
|
||||||
|
:ui="textareaUi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Use this field to help users to understand how to use this preset.</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div
|
||||||
<div class="card-footer-item">
|
class="flex flex-col-reverse gap-2 border-t border-default pt-5 sm:flex-row sm:justify-end"
|
||||||
<button
|
|
||||||
class="button is-fullwidth is-primary"
|
|
||||||
:disabled="addInProgress"
|
|
||||||
type="submit"
|
|
||||||
:class="{ 'is-loading': addInProgress }"
|
|
||||||
form="presetForm"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
<UButton
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button
|
|
||||||
class="button is-fullwidth is-danger"
|
|
||||||
@click="emitter('cancel')"
|
|
||||||
:disabled="addInProgress"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="justify-center"
|
||||||
|
@click="emitter('cancel')"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
Cancel
|
||||||
<span>Cancel</span>
|
</UButton>
|
||||||
</button>
|
|
||||||
</div>
|
<UButton
|
||||||
</div>
|
type="submit"
|
||||||
</div>
|
color="primary"
|
||||||
</form>
|
size="lg"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
:loading="addInProgress"
|
||||||
|
class="justify-center"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<datalist id="folders" v-if="config?.folders">
|
<datalist v-if="config?.folders" id="folders">
|
||||||
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
|
<UModal
|
||||||
|
v-if="showOptions"
|
||||||
|
v-model:open="showOptions"
|
||||||
|
title="yt-dlp options"
|
||||||
|
:dismissible="true"
|
||||||
|
:ui="{ content: 'sm:max-w-6xl', body: 'p-0' }"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span class="sr-only">Browse available yt-dlp flags and descriptions.</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
<YTDLPOptions />
|
<YTDLPOptions />
|
||||||
</Modal>
|
</template>
|
||||||
</main>
|
</UModal>
|
||||||
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -358,7 +354,7 @@ import TextDropzone from '~/components/TextDropzone.vue';
|
||||||
import type { ImportedItem } from '~/types';
|
import type { ImportedItem } from '~/types';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import type { Preset } from '~/types/presets';
|
import type { Preset } from '~/types/presets';
|
||||||
import { normalizePresetName, prettyName } from '~/utils';
|
import { normalizePresetName } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(event: 'cancel'): void;
|
(event: 'cancel'): void;
|
||||||
|
|
@ -374,17 +370,72 @@ const props = defineProps<{
|
||||||
|
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)));
|
const dialog = useDialog();
|
||||||
const import_string = ref<string>('');
|
const { presets, findPreset, selectItems } = usePresetOptions(() => props.presets);
|
||||||
|
|
||||||
|
const form = reactive<Preset>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
folder: '',
|
||||||
|
template: '',
|
||||||
|
cookies: '',
|
||||||
|
cli: '',
|
||||||
|
default: false,
|
||||||
|
priority: 0,
|
||||||
|
...JSON.parse(JSON.stringify(props.preset || {})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importString = ref('');
|
||||||
const showImport = useStorage<boolean>('showImport', false);
|
const showImport = useStorage<boolean>('showImport', false);
|
||||||
const selected_preset = ref<string>('');
|
const selectedPreset = ref<string>('');
|
||||||
const showOptions = ref<boolean>(false);
|
const showOptions = ref(false);
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||||
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
|
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
|
||||||
|
|
||||||
if (form.priority === undefined) {
|
const fieldUi = {
|
||||||
form.priority = 0;
|
label: 'font-semibold text-default',
|
||||||
}
|
container: 'space-y-2',
|
||||||
|
description: 'text-sm text-toned',
|
||||||
|
hint: 'text-sm text-toned',
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorFieldUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
label: 'font-semibold text-default',
|
||||||
|
container: 'flex flex-col space-y-2',
|
||||||
|
description: 'text-sm text-toned',
|
||||||
|
hint: 'text-sm text-toned',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textareaUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'min-h-[10rem] w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPresetItems = computed(() => selectItems.value);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.preset,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(form, {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
folder: '',
|
||||||
|
template: '',
|
||||||
|
cookies: '',
|
||||||
|
cli: '',
|
||||||
|
default: false,
|
||||||
|
priority: 0,
|
||||||
|
...JSON.parse(JSON.stringify(value || {})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.ytdlp_options,
|
() => config.ytdlp_options,
|
||||||
|
|
@ -399,6 +450,57 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const triggerCookieUpload = (): void => {
|
||||||
|
cookiesDropzoneRef.value?.triggerFileSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFormContent = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
form.name ||
|
||||||
|
form.cli ||
|
||||||
|
form.template ||
|
||||||
|
form.folder ||
|
||||||
|
form.cookies ||
|
||||||
|
form.description ||
|
||||||
|
(form.priority ?? 0) > 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmImportOverwrite = async (): Promise<boolean> => {
|
||||||
|
if (!hasFormContent.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status } = await dialog.confirmDialog({
|
||||||
|
title: 'Overwrite current form?',
|
||||||
|
message: 'Importing will overwrite the current preset form fields.',
|
||||||
|
confirmText: 'Overwrite',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
confirmColor: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
return status === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
||||||
|
try {
|
||||||
|
const response = await convertCliOptions(args);
|
||||||
|
|
||||||
|
if (response.output_template) {
|
||||||
|
form.template = response.output_template;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.download_path) {
|
||||||
|
form.folder = response.download_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.opts as Record<string, any>;
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const checkInfo = async (): Promise<void> => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
for (const key of ['name']) {
|
for (const key of ['name']) {
|
||||||
if (!form[key as keyof Preset]) {
|
if (!form[key as keyof Preset]) {
|
||||||
|
|
@ -412,6 +514,7 @@ const checkInfo = async (): Promise<void> => {
|
||||||
toast.error('The name field is required.');
|
toast.error('The name field is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.name = normalizedName;
|
form.name = normalizedName;
|
||||||
|
|
||||||
if (form.folder) {
|
if (form.folder) {
|
||||||
|
|
@ -428,17 +531,9 @@ const checkInfo = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: Preset = JSON.parse(JSON.stringify(form));
|
const copy: Preset = JSON.parse(JSON.stringify(form));
|
||||||
let usedName = false;
|
const usedName = presets.value.some(
|
||||||
const name = normalizedName;
|
(item) => item.id !== props.reference && item.name === normalizedName,
|
||||||
|
);
|
||||||
props.presets?.forEach((p) => {
|
|
||||||
if (p.id === props.reference) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (p.name === name) {
|
|
||||||
usedName = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (usedName) {
|
if (usedName) {
|
||||||
toast.error('The preset name is already in use.');
|
toast.error('The preset name is already in use.');
|
||||||
|
|
@ -446,43 +541,28 @@ const checkInfo = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in copy) {
|
for (const key in copy) {
|
||||||
const val = copy[key as keyof Preset];
|
const value = copy[key as keyof Preset];
|
||||||
if ('string' === typeof val) {
|
if (typeof value === 'string') {
|
||||||
(copy as any)[key] = val.trim();
|
(copy as any)[key] = value.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) });
|
emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
|
||||||
try {
|
|
||||||
const response = await convertCliOptions(args);
|
|
||||||
|
|
||||||
if (response.output_template) {
|
|
||||||
form.template = response.output_template;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.download_path) {
|
|
||||||
form.folder = response.download_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.opts as Record<string, any>;
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(e.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
const val = import_string.value.trim();
|
const value = importString.value.trim();
|
||||||
if (!val) {
|
if (!value) {
|
||||||
toast.error('The import string is required.');
|
toast.error('The import string is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await confirmImportOverwrite())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as Preset & ImportedItem;
|
const item = decode(value) as Preset & ImportedItem;
|
||||||
|
|
||||||
if (!item?._type || 'preset' !== item._type) {
|
if (!item?._type || 'preset' !== item._type) {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|
@ -494,44 +574,41 @@ const importItem = async (): Promise<void> => {
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
form.name = item.name;
|
form.name = item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.cli) {
|
if (item.cli) {
|
||||||
form.cli = item.cli;
|
form.cli = item.cli;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.template) {
|
if (item.template) {
|
||||||
form.template = item.template;
|
form.template = item.template;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.folder) {
|
if (item.folder) {
|
||||||
form.folder = item.folder;
|
form.folder = item.folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.description) {
|
if (item.description) {
|
||||||
form.description = item.description;
|
form.description = item.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.priority !== undefined) {
|
if (item.priority !== undefined) {
|
||||||
form.priority = item.priority;
|
form.priority = item.priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = '';
|
importString.value = '';
|
||||||
showImport.value = false;
|
showImport.value = false;
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
toast.error(`Failed to parse. ${e.message}`);
|
toast.error(`Failed to parse. ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter_presets = (flag = true): Preset[] =>
|
const importExistingPreset = async (): Promise<void> => {
|
||||||
config.presets.filter((item) => item.default === flag);
|
if (!selectedPreset.value) {
|
||||||
|
|
||||||
const import_existing_preset = async (): Promise<void> => {
|
|
||||||
if (!selected_preset.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preset = config.presets.find((p) => p.name === selected_preset.value);
|
if (!(await confirmImportOverwrite())) {
|
||||||
|
selectedPreset.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = findPreset(selectedPreset.value);
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
toast.error('Preset not found.');
|
toast.error('Preset not found.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -545,6 +622,6 @@ const import_existing_preset = async (): Promise<void> => {
|
||||||
form.priority = preset.priority ?? 0;
|
form.priority = preset.priority ?? 0;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
selected_preset.value = '';
|
selectedPreset.value = '';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,286 +1,228 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="modal" :class="{ 'is-active': isOpen }">
|
<USlideover
|
||||||
<div class="modal-background" @click="emitter('close')" />
|
:open="isOpen"
|
||||||
<div
|
:side="direction"
|
||||||
class="modal-card"
|
:dismissible="true"
|
||||||
:class="{
|
:overlay="true"
|
||||||
'slide-from-right': direction === 'right',
|
:ui="{ content: 'w-full sm:max-w-xl' }"
|
||||||
'slide-from-left': direction === 'left',
|
@update:open="(open) => !open && emitter('close')"
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<header class="modal-card-head is-rounded-less">
|
<template #header>
|
||||||
<p class="modal-card-title">WebUI Settings</p>
|
<div class="flex items-center justify-between gap-3">
|
||||||
<button class="delete" @click="emitter('close')" aria-label="close" />
|
<div>
|
||||||
</header>
|
<p class="text-base font-semibold text-highlighted">WebUI Settings</p>
|
||||||
<section class="modal-card-body">
|
<p class="text-sm text-toned">Adjust interface behavior and download defaults.</p>
|
||||||
<div class="box">
|
</div>
|
||||||
<div class="field">
|
</div>
|
||||||
<label class="label">Page View</label>
|
</template>
|
||||||
<div class="control">
|
|
||||||
<input
|
<template #body>
|
||||||
id="view_mode"
|
<div class="w-full space-y-6">
|
||||||
type="checkbox"
|
<UPageCard variant="subtle" class="w-full" :ui="settingsCardUi">
|
||||||
class="switch is-success"
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-layout-dashboard" class="size-4 text-toned" />
|
||||||
|
<span class="text-sm font-semibold text-highlighted">Page View</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<USwitch
|
||||||
v-model="simpleMode"
|
v-model="simpleMode"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:ui="settingsSwitchUi"
|
||||||
|
:label="simpleMode ? 'Simple View' : 'Regular View'"
|
||||||
|
description="The simple view is ideal for non-technical users and mobile devices."
|
||||||
/>
|
/>
|
||||||
<label for="view_mode" class="is-unselectable">
|
</template>
|
||||||
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
</UPageCard>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
|
||||||
The simple view is ideal for non-technical users and mobile devices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
<UPageCard variant="subtle" class="w-full" :ui="settingsCardUi">
|
||||||
<p class="title is-5 mb-4">
|
<template #header>
|
||||||
<span class="icon-text">
|
<div class="flex items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-palette" /></span>
|
<UIcon name="i-lucide-image" class="size-4 text-toned" />
|
||||||
<span>Theming</span>
|
<span class="text-sm font-semibold text-highlighted">Background</span>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<div class="field">
|
<template #body>
|
||||||
<label class="label">Color scheme</label>
|
<USwitch
|
||||||
<div class="control">
|
v-model="bg_enable"
|
||||||
<label for="auto" class="radio">
|
class="w-full"
|
||||||
<input id="auto" type="radio" v-model="selectedTheme" value="auto" />
|
size="lg"
|
||||||
<span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span>
|
:ui="settingsSwitchUi"
|
||||||
<span>Auto</span>
|
:label="bg_enable ? 'Shown' : 'Hidden'"
|
||||||
</label>
|
/>
|
||||||
<label for="light" class="radio">
|
|
||||||
<input id="light" type="radio" v-model="selectedTheme" value="light" />
|
|
||||||
<span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span>
|
|
||||||
<span>Light</span>
|
|
||||||
</label>
|
|
||||||
<label for="dark" class="radio">
|
|
||||||
<input id="dark" type="radio" v-model="selectedTheme" value="dark" />
|
|
||||||
<span class="icon"><i class="fa-solid fa-moon" /></span>
|
|
||||||
<span>Dark</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
<UButton
|
||||||
<label class="label">Show Background</label>
|
v-if="bg_enable"
|
||||||
<div class="control">
|
color="info"
|
||||||
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable" />
|
variant="outline"
|
||||||
<label for="random_bg" class="is-unselectable">
|
icon="i-lucide-image-up"
|
||||||
{{ bg_enable ? 'Yes' : 'No' }}
|
class="w-full justify-center"
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="bg_enable">
|
|
||||||
<div class="control">
|
|
||||||
<button
|
|
||||||
@click="$emit('reload_bg')"
|
|
||||||
class="button is-link is-light is-fullwidth"
|
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="$emit('reload_bg')"
|
||||||
>
|
>
|
||||||
<span class="icon"
|
Reload Background
|
||||||
><i
|
</UButton>
|
||||||
class="fa"
|
|
||||||
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }"
|
|
||||||
/></span>
|
|
||||||
<span>Reload Background</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="bg_enable">
|
<UFormField
|
||||||
<label class="label">Background visibility</label>
|
class="w-full"
|
||||||
<div class="field has-addons">
|
v-if="bg_enable"
|
||||||
<div class="control">
|
label="Background visibility"
|
||||||
<a class="button is-static">
|
:hint="String(parseFloat(String(1.0 - bg_opacity)).toFixed(2))"
|
||||||
<code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
|
>
|
||||||
</a>
|
<USlider
|
||||||
</div>
|
v-model="bgOpacityModel"
|
||||||
<div class="control is-expanded">
|
:min="0.5"
|
||||||
<input
|
:max="1"
|
||||||
class="input"
|
:step="0.05"
|
||||||
type="range"
|
size="lg"
|
||||||
v-model="bg_opacity"
|
class="w-full"
|
||||||
min="0.50"
|
|
||||||
max="1.00"
|
|
||||||
step="0.05"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</UPageCard>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
<UPageCard variant="subtle" class="w-full" :ui="settingsCardUi">
|
||||||
<p class="title is-5 mb-4">
|
<template #header>
|
||||||
<span class="icon-text">
|
<div class="flex items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-home" /></span>
|
<UIcon name="i-lucide-monitor" class="size-4 text-toned" />
|
||||||
<span>Dashboard</span>
|
<span class="text-sm font-semibold text-highlighted">Downloads</span>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<div class="field" v-if="!simpleMode">
|
<template #body>
|
||||||
<label class="label">URL Separator</label>
|
<UFormField
|
||||||
<div class="control">
|
v-if="!simpleMode"
|
||||||
<div class="select is-fullwidth">
|
label="URL Separator"
|
||||||
<select v-model="separator">
|
class="w-full"
|
||||||
<option
|
:ui="settingsFieldUi"
|
||||||
v-for="(sep, index) in separators"
|
|
||||||
:key="`sep-${index}`"
|
|
||||||
:value="sep.value"
|
|
||||||
>
|
>
|
||||||
{{ sep.name }} ({{ sep.value }})
|
<USelect
|
||||||
</option>
|
v-model="separator"
|
||||||
</select>
|
:items="separatorItems"
|
||||||
</div>
|
value-key="value"
|
||||||
</div>
|
label-key="label"
|
||||||
</div>
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'w-full' }"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<div class="field">
|
<USwitch
|
||||||
<label class="label">Show Thumbnails</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="show_thumbnail"
|
|
||||||
type="checkbox"
|
|
||||||
class="switch is-success"
|
|
||||||
v-model="show_thumbnail"
|
v-model="show_thumbnail"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:ui="settingsSwitchUi"
|
||||||
|
:label="show_thumbnail ? 'Show Thumbnails' : 'Hide Thumbnails'"
|
||||||
|
description="Show videos thumbnail if available"
|
||||||
/>
|
/>
|
||||||
<label for="show_thumbnail" class="is-unselectable">
|
|
||||||
{{ show_thumbnail ? 'Yes' : 'No' }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
|
||||||
Show videos thumbnail if available
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="show_thumbnail">
|
<UFormField
|
||||||
<label class="label">Aspect Ratio</label>
|
v-if="show_thumbnail"
|
||||||
<div class="control">
|
label="Aspect Ratio"
|
||||||
<label for="ratio_16by9" class="radio">
|
class="w-full"
|
||||||
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9" />
|
:ui="settingsFieldUi"
|
||||||
<span> 16:9</span>
|
>
|
||||||
</label>
|
<USelect
|
||||||
<label for="ratio_3by1" class="radio">
|
v-model="thumbnail_ratio"
|
||||||
<input id="ratio_3by1" type="radio" v-model="thumbnail_ratio" value="is-3by1" />
|
:items="thumbnailRatioItems"
|
||||||
<span> 3:1</span>
|
value-key="value"
|
||||||
</label>
|
label-key="label"
|
||||||
</div>
|
size="lg"
|
||||||
<p class="help">
|
class="w-full"
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
:ui="{ base: 'w-full' }"
|
||||||
Choose the aspect ratio for thumbnail display.
|
/>
|
||||||
</p>
|
</UFormField>
|
||||||
</div>
|
|
||||||
<div class="field">
|
<USwitch
|
||||||
<label class="label">Popover</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="show_popover"
|
|
||||||
type="checkbox"
|
|
||||||
class="switch is-success"
|
|
||||||
v-model="show_popover"
|
v-model="show_popover"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:ui="settingsSwitchUi"
|
||||||
|
:label="show_popover ? 'Popover On' : 'Popover Off'"
|
||||||
|
description="Show additional information over certain elements."
|
||||||
/>
|
/>
|
||||||
<label for="show_popover" class="is-unselectable">
|
</template>
|
||||||
{{ show_popover ? 'Yes' : 'No' }}
|
</UPageCard>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
|
||||||
Show additional information over certain elements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
<UPageCard variant="subtle" class="w-full" :ui="settingsCardUi">
|
||||||
<p class="title is-5 mb-4">
|
<template #header>
|
||||||
<span class="icon-text">
|
<div class="flex items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-download" /></span>
|
<UIcon name="i-lucide-download" class="size-4 text-toned" />
|
||||||
<span>Queue</span>
|
<span class="text-sm font-semibold text-highlighted">Queue</span>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<div class="field">
|
<template #body>
|
||||||
<label class="label">Auto-refresh queue when disconnected</label>
|
<USwitch
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="queue_auto_refresh"
|
|
||||||
type="checkbox"
|
|
||||||
class="switch is-success"
|
|
||||||
v-model="queue_auto_refresh"
|
v-model="queue_auto_refresh"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:ui="settingsSwitchUi"
|
||||||
|
:label="queue_auto_refresh ? 'Auto-refresh Enabled' : 'Auto-refresh Disabled'"
|
||||||
|
description="Automatically refresh queue data when WebSocket connection is unavailable."
|
||||||
/>
|
/>
|
||||||
<label for="queue_auto_refresh" class="is-unselectable">
|
|
||||||
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
|
||||||
Automatically refresh queue data when WebSocket connection is unavailable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="queue_auto_refresh">
|
<UFormField
|
||||||
<label class="label">Auto-refresh interval (seconds)</label>
|
class="w-full"
|
||||||
<div class="field has-addons">
|
v-if="queue_auto_refresh"
|
||||||
<div class="control">
|
label="Auto-refresh interval"
|
||||||
<a class="button is-static">
|
:hint="`${queue_auto_refresh_delay / 1000}s`"
|
||||||
<code>{{ queue_auto_refresh_delay / 1000 }}s</code>
|
:ui="settingsFieldUi"
|
||||||
</a>
|
>
|
||||||
</div>
|
<USlider
|
||||||
<div class="control is-expanded">
|
v-model="queueRefreshDelayModel"
|
||||||
<input
|
:min="5000"
|
||||||
class="input"
|
:max="60000"
|
||||||
type="range"
|
:step="5000"
|
||||||
v-model.number="queue_auto_refresh_delay"
|
size="lg"
|
||||||
min="5000"
|
class="w-full"
|
||||||
max="60000"
|
|
||||||
step="5000"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<p class="mt-2 text-sm text-toned">
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
|
||||||
How often to refresh the queue (5-60 seconds). Lower values increase server load.
|
How often to refresh the queue (5-60 seconds). Lower values increase server load.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</UFormField>
|
||||||
</div>
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
|
||||||
<div class="box">
|
<UPageCard variant="subtle" class="w-full" :ui="settingsCardUi">
|
||||||
<p class="title is-5 mb-4">
|
<template #header>
|
||||||
<span class="icon-text">
|
<div class="flex items-center gap-2">
|
||||||
<span class="icon"><i class="fas fa-bell" /></span>
|
<UIcon name="i-lucide-bell" class="size-4 text-toned" />
|
||||||
<span>Notifications</span>
|
<span class="text-sm font-semibold text-highlighted">Notifications</span>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<div class="field">
|
<template #body>
|
||||||
<label class="label">Show notifications</label>
|
<USwitch
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="allow_toasts"
|
|
||||||
type="checkbox"
|
|
||||||
class="switch is-success"
|
|
||||||
v-model="allow_toasts"
|
v-model="allow_toasts"
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
:ui="settingsSwitchUi"
|
||||||
|
:label="allow_toasts ? 'Shown' : 'Hidden'"
|
||||||
/>
|
/>
|
||||||
<label for="allow_toasts" class="is-unselectable">
|
|
||||||
{{ allow_toasts ? 'Yes' : 'No' }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="allow_toasts">
|
<UFormField
|
||||||
<label class="label">Notification target</label>
|
v-if="allow_toasts"
|
||||||
<div class="control">
|
label="Notification target"
|
||||||
<div class="select is-fullwidth">
|
class="w-full"
|
||||||
<select v-model="toast_target" @change="onNotificationTargetChange">
|
:ui="settingsFieldUi"
|
||||||
<option value="toast">Toast</option>
|
>
|
||||||
<option value="browser" :disabled="!isSecureContext">Browser</option>
|
<USelect
|
||||||
</select>
|
v-model="toast_target"
|
||||||
</div>
|
:items="notificationTargetItems"
|
||||||
</div>
|
value-key="value"
|
||||||
<p class="help">
|
label-key="label"
|
||||||
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'w-full' }"
|
||||||
|
@update:model-value="() => void onNotificationTargetChange()"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-sm text-toned">
|
||||||
<template v-if="!isSecureContext">
|
<template v-if="!isSecureContext">
|
||||||
Browser notifications require HTTPS connection.
|
Browser notifications require HTTPS connection.
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -288,52 +230,44 @@
|
||||||
Choose where to display notifications. Browser requires permission.
|
Choose where to display notifications. Browser requires permission.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</UFormField>
|
||||||
|
|
||||||
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
<UFormField
|
||||||
<label class="label">Notifications position</label>
|
v-if="allow_toasts && toast_target === 'toast'"
|
||||||
<div class="control">
|
label="Notifications position"
|
||||||
<div class="select is-fullwidth">
|
class="w-full"
|
||||||
<select v-model="toast_position">
|
:ui="settingsFieldUi"
|
||||||
<option :value="POSITION.TOP_RIGHT">{{ POSITION.TOP_RIGHT }}</option>
|
>
|
||||||
<option :value="POSITION.TOP_CENTER">{{ POSITION.TOP_CENTER }}</option>
|
<USelect
|
||||||
<option :value="POSITION.TOP_LEFT">{{ POSITION.TOP_LEFT }}</option>
|
v-model="toast_position"
|
||||||
<option :value="POSITION.BOTTOM_RIGHT">{{ POSITION.BOTTOM_RIGHT }}</option>
|
:items="toastPositionItems"
|
||||||
<option :value="POSITION.BOTTOM_CENTER">{{ POSITION.BOTTOM_CENTER }}</option>
|
size="lg"
|
||||||
<option :value="POSITION.BOTTOM_LEFT">{{ POSITION.BOTTOM_LEFT }}</option>
|
class="w-full"
|
||||||
</select>
|
:ui="{ base: 'w-full' }"
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
|
||||||
<label class="label">Dismiss notification on click</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="dismiss_on_click"
|
|
||||||
type="checkbox"
|
|
||||||
class="switch is-success"
|
|
||||||
v-model="toast_dismiss_on_click"
|
|
||||||
/>
|
/>
|
||||||
<label for="dismiss_on_click" class="is-unselectable">
|
</UFormField>
|
||||||
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
|
||||||
</label>
|
<USwitch
|
||||||
</div>
|
v-if="allow_toasts && toast_target === 'toast'"
|
||||||
</div>
|
v-model="toast_dismiss_on_click"
|
||||||
</div>
|
class="w-full"
|
||||||
</section>
|
size="lg"
|
||||||
</div>
|
:ui="settingsSwitchUi"
|
||||||
|
:label="toast_dismiss_on_click ? 'Dismiss on click' : 'Keep on click'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'assets/css/bulma-switch.css';
|
import { watch, onMounted, onBeforeUnmount, ref, computed } from 'vue';
|
||||||
import { watch, onMounted, onBeforeUnmount, ref } from 'vue';
|
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { POSITION } from 'vue-toastification';
|
|
||||||
import { useConfigStore } from '~/stores/ConfigStore';
|
import { useConfigStore } from '~/stores/ConfigStore';
|
||||||
import { useNotification } from '~/composables/useNotification';
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import type { notificationTarget } from '~/composables/useNotification';
|
import type { notificationTarget, toastPosition } from '~/composables/useNotification';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -355,9 +289,8 @@ const notification = useNotification();
|
||||||
|
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||||
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto');
|
|
||||||
const allow_toasts = useStorage<boolean>('allow_toasts', true);
|
const allow_toasts = useStorage<boolean>('allow_toasts', true);
|
||||||
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
|
const toast_position = useStorage<toastPosition>('toast_position', 'top-right');
|
||||||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true);
|
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true);
|
||||||
const toast_target = useStorage<notificationTarget>('toast_target', 'toast');
|
const toast_target = useStorage<notificationTarget>('toast_target', 'toast');
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||||
|
|
@ -369,6 +302,60 @@ const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
|
||||||
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
||||||
const isSecureContext = ref<boolean>(false);
|
const isSecureContext = ref<boolean>(false);
|
||||||
|
|
||||||
|
const settingsCardUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
container: 'w-full p-4 sm:p-5',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full space-y-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsFieldUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
container: 'mt-2 w-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsSwitchUi = {
|
||||||
|
root: 'w-full items-start justify-between gap-4',
|
||||||
|
wrapper: 'ms-0 flex-1 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bgOpacityModel = computed<number>({
|
||||||
|
get: () => Number(bg_opacity.value),
|
||||||
|
set: (value) => {
|
||||||
|
bg_opacity.value = Number(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueRefreshDelayModel = computed<number>({
|
||||||
|
get: () => Number(queue_auto_refresh_delay.value),
|
||||||
|
set: (value) => {
|
||||||
|
queue_auto_refresh_delay.value = Number(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const separatorItems = computed(() =>
|
||||||
|
separators.map((sep) => ({ label: `${sep.name} (${sep.value})`, value: sep.value })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbnailRatioItems = [
|
||||||
|
{ label: '16:9', value: 'is-16by9' },
|
||||||
|
{ label: '3:1', value: 'is-3by1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const notificationTargetItems = computed(() => [
|
||||||
|
{ label: 'Toast', value: 'toast' },
|
||||||
|
{ label: 'Browser', value: 'browser', disabled: !isSecureContext.value },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toastPositionItems: Array<{ label: string; value: toastPosition }> = [
|
||||||
|
{ label: 'top-left', value: 'top-left' },
|
||||||
|
{ label: 'top-center', value: 'top-center' },
|
||||||
|
{ label: 'top-right', value: 'top-right' },
|
||||||
|
{ label: 'bottom-left', value: 'bottom-left' },
|
||||||
|
{ label: 'bottom-center', value: 'bottom-center' },
|
||||||
|
{ label: 'bottom-right', value: 'bottom-right' },
|
||||||
|
];
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if ('Escape' === e.key && props.isOpen) {
|
if ('Escape' === e.key && props.isOpen) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -415,63 +402,7 @@ watch(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal-card.slide-from-right {
|
|
||||||
position: fixed;
|
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
max-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
width: 600px;
|
|
||||||
max-width: 90vw;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.is-active .modal-card.slide-from-right {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-card.slide-from-left {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
max-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
width: 600px;
|
|
||||||
max-width: 90vw;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.is-active .modal-card.slide-from-left {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-card-body {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.modal-card.slide-from-right,
|
|
||||||
.modal-card.slide-from-left {
|
|
||||||
width: 100vw;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body.settings-panel-open) {
|
:global(body.settings-panel-open) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main_container {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
|
||||||
:global(.settings-open #main_container) {
|
|
||||||
transform: translateX(-300px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,142 +1,131 @@
|
||||||
<style scoped>
|
|
||||||
code {
|
|
||||||
color: var(--bulma-code) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="box">
|
<div class="space-y-5">
|
||||||
<h2 class="title is-5">Inspect Task Handler</h2>
|
<form class="space-y-5" @submit.prevent="onSubmit">
|
||||||
<form @submit.prevent="onSubmit">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<div class="field">
|
<UFormField
|
||||||
<label class="label" for="url">
|
label="URL"
|
||||||
<span class="icon-text">
|
:ui="fieldUi"
|
||||||
<span class="icon"><i class="fas fa-link" /></span>
|
:error="urlError || undefined"
|
||||||
<span>URL</span>
|
description="Enter the URL of the resource you want to inspect."
|
||||||
</span>
|
class="lg:col-span-2"
|
||||||
</label>
|
>
|
||||||
<div class="control has-icons-left">
|
<UInput
|
||||||
<input
|
|
||||||
id="url"
|
id="url"
|
||||||
v-model="url"
|
v-model="url"
|
||||||
type="url"
|
type="url"
|
||||||
class="input"
|
|
||||||
:class="{ 'is-danger': urlError }"
|
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
required
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-link" class="size-4 text-toned" />
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Preset"
|
||||||
|
:ui="fieldUi"
|
||||||
|
description="Select a preset to apply its settings during inspection. In real scenario, the preset will be based on what is selected when creating the task."
|
||||||
|
>
|
||||||
|
<USelect
|
||||||
|
id="preset"
|
||||||
|
v-model="preset"
|
||||||
|
:items="presetItems"
|
||||||
|
placeholder="Select a preset"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
</UFormField>
|
||||||
</div>
|
|
||||||
<p v-if="urlError" class="help is-danger">{{ urlError }}</p>
|
<UFormField
|
||||||
<p v-else class="help is-bold">Enter the URL of the resource you want to inspect.</p>
|
label="Handler (For testing)"
|
||||||
</div>
|
:ui="fieldUi"
|
||||||
<div class="field">
|
description="In real scenario, the system auto-detects the appropriate handler based on the URL. This field is for testing purposes only."
|
||||||
<label class="label" for="preset">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"> <i class="fa-solid fa-list" /> </span>
|
|
||||||
<span>Preset</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
|
||||||
<div class="select is-fullwidth">
|
|
||||||
<select id="preset" class="is-fullwidth" v-model="preset">
|
|
||||||
<optgroup
|
|
||||||
label="Custom presets"
|
|
||||||
v-if="config?.presets.filter((p) => !p?.default).length > 0"
|
|
||||||
>
|
>
|
||||||
<option
|
<UInput
|
||||||
v-for="cPreset in filter_presets(false)"
|
|
||||||
:key="cPreset.name"
|
|
||||||
:value="cPreset.name"
|
|
||||||
>
|
|
||||||
{{ cPreset.name }}
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Default presets">
|
|
||||||
<option
|
|
||||||
v-for="dPreset in filter_presets(true)"
|
|
||||||
:key="dPreset.name"
|
|
||||||
:value="dPreset.name"
|
|
||||||
>
|
|
||||||
{{ dPreset.name }}
|
|
||||||
</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
|
|
||||||
</div>
|
|
||||||
<p class="help is-bold">
|
|
||||||
Select a preset to apply its settings during inspection. In real scenario, the preset will
|
|
||||||
be based on what is selected when creating the task.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="handler">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-cogs" /></span>
|
|
||||||
<span>Handler (For testing)</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
|
||||||
<input
|
|
||||||
id="handler"
|
id="handler"
|
||||||
v-model="handler"
|
v-model="handler"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
|
||||||
placeholder="Handler class name"
|
placeholder="Handler class name"
|
||||||
/>
|
class="w-full"
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span>
|
:ui="inputUi"
|
||||||
</div>
|
:disabled="loading"
|
||||||
<p class="help is-bold">
|
>
|
||||||
In real scenario, the system auto-detects the appropriate handler based on the URL. This
|
<template #leading>
|
||||||
field is for testing purposes only.
|
<UIcon name="i-lucide-rss" class="size-4 text-toned" />
|
||||||
</p>
|
</template>
|
||||||
</div>
|
</UInput>
|
||||||
<div class="field is-grouped is-grouped-right">
|
</UFormField>
|
||||||
<div class="control">
|
|
||||||
<button class="button is-primary" type="submit" :disabled="loading">
|
|
||||||
<span class="icon"> <i class="fas fa-search" /></span>
|
|
||||||
<span>Inspect</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button class="button is-warning" type="button" @click="onReset" :disabled="loading">
|
|
||||||
<span class="icon"> <i class="fas fa-undo" /> </span>
|
|
||||||
<span>Reset</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-rotate-ccw"
|
||||||
|
:disabled="loading"
|
||||||
|
class="justify-center"
|
||||||
|
@click="onReset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
class="justify-center"
|
||||||
|
>
|
||||||
|
Inspect
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Message v-if="loading" class="is-info">
|
<UAlert
|
||||||
<p>
|
v-if="loading"
|
||||||
<span class="icon-text">
|
color="info"
|
||||||
<span class="icon"><i class="fas fa-spinner fa-spin" /></span>
|
variant="soft"
|
||||||
<span>Inspecting.. please wait.</span>
|
icon="i-lucide-loader-circle"
|
||||||
</span>
|
title="Inspecting"
|
||||||
</p>
|
description="Inspecting.. please wait."
|
||||||
</Message>
|
/>
|
||||||
|
|
||||||
<div v-if="response" class="mt-4">
|
<UAlert
|
||||||
<Message
|
v-else-if="response && 'error' in response"
|
||||||
v-if="response.error"
|
color="error"
|
||||||
class="is-danger"
|
variant="soft"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
title="Error"
|
title="Error"
|
||||||
icon="fas fa-exclamation-triangle"
|
:description="errorDescription"
|
||||||
>
|
/>
|
||||||
<p>{{ response.error }}</p>
|
|
||||||
<p v-if="response.message">{{ response.message }}</p>
|
<div v-else-if="response" class="space-y-3">
|
||||||
</Message>
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
<div class="content" v-else>
|
<UIcon name="i-lucide-braces" class="size-4 text-toned" />
|
||||||
<h4>Result:</h4>
|
<span>Result:</span>
|
||||||
<pre><code>{{ response }}</code></pre>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-default bg-default">
|
||||||
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
|
<pre
|
||||||
|
class="min-w-0 max-w-full overflow-x-auto p-4 text-xs leading-6 text-default"
|
||||||
|
><code>{{ formattedResponse }}</code></pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { request } from '~/utils';
|
import { request } from '~/utils';
|
||||||
import { useConfigStore } from '~/stores/ConfigStore';
|
import { useConfigStore } from '~/stores/ConfigStore';
|
||||||
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
|
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
|
||||||
|
|
@ -147,38 +136,84 @@ const props = defineProps<{
|
||||||
handler?: string;
|
handler?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = useConfigStore();
|
const { selectItems } = usePresetOptions();
|
||||||
const url = ref<string>(props.url ?? '');
|
|
||||||
const preset = ref<string>(props.preset || config.app.default_preset || '');
|
const config = useConfigStore();
|
||||||
const handler = ref<string>(props.handler ?? '');
|
const url = ref(props.url ?? '');
|
||||||
const loading = ref<boolean>(false);
|
const preset = ref(props.preset || config.app.default_preset || '');
|
||||||
const response = ref<TaskInspectResponse | null>(null);
|
const handler = ref(props.handler ?? '');
|
||||||
const urlError = ref<string>('');
|
const loading = ref(false);
|
||||||
|
const response = ref<TaskInspectResponse | null>(null);
|
||||||
|
const urlError = ref('');
|
||||||
|
|
||||||
|
const fieldUi = {
|
||||||
|
label: 'font-semibold text-default',
|
||||||
|
container: 'space-y-2',
|
||||||
|
description: 'text-sm text-toned',
|
||||||
|
error: 'text-sm text-error',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetItems = computed(
|
||||||
|
() => selectItems.value as Array<{ type?: 'label' | 'item'; label: string; value?: string }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedResponse = computed(() => {
|
||||||
|
return response.value ? JSON.stringify(response.value, null, 2) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorDescription = computed(() => {
|
||||||
|
if (!response.value || !('error' in response.value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error =
|
||||||
|
typeof response.value.error === 'string'
|
||||||
|
? response.value.error
|
||||||
|
: String(response.value.error ?? '');
|
||||||
|
const message =
|
||||||
|
typeof response.value.message === 'string'
|
||||||
|
? response.value.message
|
||||||
|
: String(response.value.message ?? '');
|
||||||
|
|
||||||
|
return message ? `${error} ${message}` : error;
|
||||||
|
});
|
||||||
|
|
||||||
// Watch for prop changes and update fields
|
|
||||||
watch(
|
watch(
|
||||||
() => props.url,
|
() => props.url,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val !== undefined) url.value = val;
|
if (val !== undefined) {
|
||||||
|
url.value = val;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.preset,
|
() => props.preset,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val !== undefined) preset.value = val;
|
if (val !== undefined) {
|
||||||
|
preset.value = val;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.handler,
|
() => props.handler,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val !== undefined) handler.value = val;
|
if (val !== undefined) {
|
||||||
|
handler.value = val;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const validateUrl = (val: string): boolean => {
|
const validateUrl = (val: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const u = new URL(val);
|
const parsed = new URL(val);
|
||||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +257,4 @@ const onReset = () => {
|
||||||
response.value = null;
|
response.value = null;
|
||||||
urlError.value = '';
|
urlError.value = '';
|
||||||
};
|
};
|
||||||
const filter_presets = (flag: boolean = true) =>
|
|
||||||
config.presets.filter((item) => item.default === flag);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="file-dropzone is-relative"
|
class="relative flex h-full w-full cursor-text flex-col"
|
||||||
:class="{ 'is-dragging': isDragging }"
|
:class="{ 'is-dragging': isDragging }"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
@dragover.prevent="handleDragOver"
|
@dragover.prevent="handleDragOver"
|
||||||
|
|
@ -8,24 +8,34 @@
|
||||||
@dragleave.prevent="handleDragLeave"
|
@dragleave.prevent="handleDragLeave"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<textarea
|
<UTextarea
|
||||||
|
:id="id"
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
class="control textarea is-fullwidth"
|
v-model="model"
|
||||||
:class="{ 'is-focused': isDragging }"
|
|
||||||
:value="model"
|
|
||||||
@input="handleInput"
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:id="id"
|
:rows="rows"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
class="h-full w-full"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'min-h-[10rem] w-full bg-elevated/60 font-mono text-sm ring-default focus-visible:ring-primary',
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isDragging"
|
v-if="isDragging"
|
||||||
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center"
|
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border border-dashed border-success bg-success/15"
|
||||||
>
|
>
|
||||||
<div class="has-text-centered has-text-dark">
|
<div class="text-center text-success">
|
||||||
<span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span>
|
<span
|
||||||
<p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p>
|
class="mb-3 inline-flex size-12 items-center justify-center rounded-full bg-success/15"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-file-down" class="size-5" />
|
||||||
|
</span>
|
||||||
|
<p class="text-sm font-semibold">Drop file here</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -33,8 +43,8 @@
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="accept"
|
:accept="accept"
|
||||||
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
style="display: none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -42,36 +52,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
}
|
rows?: number;
|
||||||
|
}>(),
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
{
|
||||||
disabled: false,
|
disabled: false,
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
id: '',
|
id: '',
|
||||||
accept: '',
|
accept: '',
|
||||||
maxSize: 2 * 1024 * 1024,
|
maxSize: 2 * 1024 * 1024,
|
||||||
});
|
rows: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const model = defineModel<string>({ default: '' });
|
const model = defineModel<string>({ default: '' });
|
||||||
|
|
||||||
const emit = defineEmits<{ error: [message: string] }>();
|
const emit = defineEmits<{ error: [message: string] }>();
|
||||||
|
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
const textareaRef = ref<{ textareaRef?: HTMLTextAreaElement | null } | null>(null);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const isDragging = ref<boolean>(false);
|
const isDragging = ref<boolean>(false);
|
||||||
const dragCounter = ref<number>(0);
|
const dragCounter = ref<number>(0);
|
||||||
|
|
||||||
const handleInput = (event: Event): void => {
|
|
||||||
const target = event.target as HTMLTextAreaElement;
|
|
||||||
model.value = target.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent): void => {
|
const handleDragEnter = (event: DragEvent): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -83,14 +91,15 @@ const handleDragEnter = (event: DragEvent): void => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (_event: DragEvent): void => {
|
const handleDragLeave = (): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dragCounter.value--;
|
dragCounter.value--;
|
||||||
if (0 === dragCounter.value) {
|
if (dragCounter.value <= 0) {
|
||||||
isDragging.value = false;
|
isDragging.value = false;
|
||||||
|
dragCounter.value = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -133,7 +142,7 @@ const handleClick = (event: MouseEvent): void => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event && event.target === textareaRef.value) {
|
if (event.target === textareaRef.value?.textareaRef) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -161,26 +170,19 @@ const processFile = async (file: File): Promise<void> => {
|
||||||
if (file.size > props.maxSize) {
|
if (file.size > props.maxSize) {
|
||||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||||
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2);
|
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2);
|
||||||
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`;
|
emit('error', `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`);
|
||||||
emit('error', errorMsg);
|
|
||||||
console.error(errorMsg);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBinary = await checkIfBinary(file);
|
const isBinary = await checkIfBinary(file);
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
const errorMsg = 'File appears to be binary. Please provide a text file.';
|
emit('error', 'File appears to be binary. Please provide a text file.');
|
||||||
emit('error', errorMsg);
|
|
||||||
console.error(errorMsg);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await readFileAsText(file);
|
model.value = await readFileAsText(file);
|
||||||
model.value = text;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to read file';
|
emit('error', error instanceof Error ? error.message : 'Failed to read file');
|
||||||
emit('error', errorMsg);
|
|
||||||
console.error('Failed to read file:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -198,21 +200,13 @@ const checkIfBinary = async (file: File): Promise<boolean> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = new Uint8Array(e.target.result as ArrayBuffer);
|
const bytes = new Uint8Array(e.target.result as ArrayBuffer);
|
||||||
|
|
||||||
let nullBytes = 0;
|
let nullBytes = 0;
|
||||||
let nonPrintable = 0;
|
let nonPrintable = 0;
|
||||||
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (const byte of bytes) {
|
||||||
const byte = bytes[i];
|
|
||||||
|
|
||||||
if (undefined === byte) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (0 === byte) {
|
if (0 === byte) {
|
||||||
nullBytes++;
|
nullBytes++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (9 !== byte && 10 !== byte && 13 !== byte && (byte < 32 || byte > 126)) {
|
if (9 !== byte && 10 !== byte && 13 !== byte && (byte < 32 || byte > 126)) {
|
||||||
nonPrintable++;
|
nonPrintable++;
|
||||||
}
|
}
|
||||||
|
|
@ -226,8 +220,8 @@ const checkIfBinary = async (file: File): Promise<boolean> => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const readFileAsText = (file: File): Promise<string> => {
|
const readFileAsText = (file: File): Promise<string> =>
|
||||||
return new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||||
|
|
@ -241,7 +235,6 @@ const readFileAsText = (file: File): Promise<string> => {
|
||||||
reader.onerror = () => reject(new Error('File reading error'));
|
reader.onerror = () => reject(new Error('File reading error'));
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const triggerFileSelect = (): void => {
|
const triggerFileSelect = (): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
|
|
@ -250,27 +243,5 @@ const triggerFileSelect = (): void => {
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ triggerFileSelect });
|
defineExpose({ triggerFileSelect, textareaRef });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.file-dropzone {
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-dropzone.is-dragging textarea {
|
|
||||||
box-shadow: --var(--bulma-card-shadow) !important;
|
|
||||||
border-color: --var(--bulma-success);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative flex h-full w-full flex-col">
|
||||||
class="dropdown"
|
<UTextarea
|
||||||
:class="{ 'is-active': showList && filteredOptions.length }"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<div class="control" style="width: 100%">
|
|
||||||
<textarea
|
|
||||||
:id="id"
|
:id="id"
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="localValue"
|
v-model="localValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
:rows="rows ?? 4"
|
||||||
|
:disabled="disabled"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
class="h-full w-full"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'min-h-[10rem] w-full bg-elevated/60 font-mono text-sm ring-default focus-visible:ring-primary',
|
||||||
|
}"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
@focus="onFocus"
|
@focus="onFocus"
|
||||||
@blur="hideList"
|
@blur="hideList"
|
||||||
|
|
@ -16,30 +23,30 @@
|
||||||
@keyup="updateCaret"
|
@keyup="updateCaret"
|
||||||
@click="updateCaret"
|
@click="updateCaret"
|
||||||
@mouseup="updateCaret"
|
@mouseup="updateCaret"
|
||||||
class="control is-fullwidth textarea"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
autocomplete="off"
|
|
||||||
style="width: 100%; position: relative; z-index: 2"
|
|
||||||
rows="4"
|
|
||||||
:disabled="disabled"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
|
<div
|
||||||
<div class="dropdown-content" style="width: 100%; max-height: 11em; overflow-y: auto">
|
v-if="showList && filteredOptions.length"
|
||||||
<a
|
class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-default bg-default shadow-lg"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
v-for="(option, idx) in filteredOptions"
|
v-for="(option, idx) in filteredOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@mousedown.prevent="appendFlag(option.value)"
|
|
||||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
|
||||||
style="display: flex; justify-content: space-between"
|
|
||||||
ref="dropdownItems"
|
ref="dropdownItems"
|
||||||
|
data-autocomplete-item
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start justify-between gap-4 px-3 py-2 text-left text-sm transition-colors"
|
||||||
|
:class="
|
||||||
|
idx === highlightedIndex
|
||||||
|
? 'bg-elevated text-highlighted'
|
||||||
|
: 'text-default hover:bg-elevated/60'
|
||||||
|
"
|
||||||
|
@mousedown.prevent="appendFlag(option.value)"
|
||||||
>
|
>
|
||||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
<span class="shrink-0 font-semibold text-highlighted">{{ option.value }}</span>
|
||||||
<span class="has-text-grey-light is-text-overflow" style="margin-left: 1em">{{
|
<span class="min-w-0 flex-1 truncate text-xs text-toned">{{ option.description }}</span>
|
||||||
option.description
|
</button>
|
||||||
}}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -53,11 +60,12 @@ const props = defineProps<{
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
rows?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<string>();
|
const model = defineModel<string>();
|
||||||
const localValue = ref(model.value || '');
|
const localValue = ref(model.value || '');
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
const textareaRef = ref<{ textareaRef?: HTMLTextAreaElement | null } | null>(null);
|
||||||
const caretIndex = ref(0);
|
const caretIndex = ref(0);
|
||||||
const showList = ref(false);
|
const showList = ref(false);
|
||||||
const highlightedIndex = ref(-1);
|
const highlightedIndex = ref(-1);
|
||||||
|
|
@ -66,7 +74,6 @@ const dropdownItems = ref<HTMLElement[]>([]);
|
||||||
watch(model, (val) => (localValue.value = val || ''));
|
watch(model, (val) => (localValue.value = val || ''));
|
||||||
watch(localValue, (val) => (model.value = val));
|
watch(localValue, (val) => (model.value = val));
|
||||||
|
|
||||||
// Compute token at caret position
|
|
||||||
const getCurrentToken = (value: string, caret: number) => {
|
const getCurrentToken = (value: string, caret: number) => {
|
||||||
const left = value.slice(0, caret);
|
const left = value.slice(0, caret);
|
||||||
const right = value.slice(caret);
|
const right = value.slice(caret);
|
||||||
|
|
@ -82,15 +89,12 @@ const getCurrentToken = (value: string, caret: number) => {
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
const filteredOptions = computed(() => {
|
||||||
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||||
// Hide suggestions once '=' is present in the current token
|
|
||||||
if (!token || token.includes('=')) {
|
if (!token || token.includes('=')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Only suggest when typing a long flag starting with `--`
|
|
||||||
if (!token.startsWith('--')) {
|
if (!token.startsWith('--')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Hide suggestions if token exactly matches an option value
|
|
||||||
if (props.options.some((opt) => opt.value === token)) {
|
if (props.options.some((opt) => opt.value === token)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -117,25 +121,25 @@ const appendFlag = (flag: string) => {
|
||||||
const caret = caretIndex.value;
|
const caret = caretIndex.value;
|
||||||
const { token, start, end } = getCurrentToken(value, caret);
|
const { token, start, end } = getCurrentToken(value, caret);
|
||||||
if (token) {
|
if (token) {
|
||||||
// Replace only the flag part of the token, preserving any '=value' suffix in the same token
|
|
||||||
const eqPos = token.indexOf('=');
|
const eqPos = token.indexOf('=');
|
||||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||||
localValue.value = value.slice(0, start) + flag + after + value.slice(end);
|
localValue.value = value.slice(0, start) + flag + after + value.slice(end);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const pos = start + flag.length + after.length;
|
const pos = start + flag.length + after.length;
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value?.textareaRef) {
|
||||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
textareaRef.value.textareaRef.selectionStart = textareaRef.value.textareaRef.selectionEnd =
|
||||||
|
pos;
|
||||||
caretIndex.value = pos;
|
caretIndex.value = pos;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// No token at caret: append at caret position
|
|
||||||
const needsSpace = caret > 0 && value[caret - 1] !== ' ';
|
const needsSpace = caret > 0 && value[caret - 1] !== ' ';
|
||||||
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret);
|
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const pos = caret + (needsSpace ? 1 : 0) + flag.length;
|
const pos = caret + (needsSpace ? 1 : 0) + flag.length;
|
||||||
if (textareaRef.value) {
|
if (textareaRef.value?.textareaRef) {
|
||||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
textareaRef.value.textareaRef.selectionStart = textareaRef.value.textareaRef.selectionEnd =
|
||||||
|
pos;
|
||||||
caretIndex.value = pos;
|
caretIndex.value = pos;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -145,8 +149,8 @@ const appendFlag = (flag: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCaret = () => {
|
const updateCaret = () => {
|
||||||
caretIndex.value = textareaRef.value
|
caretIndex.value = textareaRef.value?.textareaRef
|
||||||
? (textareaRef.value.selectionStart ?? localValue.value.length)
|
? (textareaRef.value.textareaRef.selectionStart ?? localValue.value.length)
|
||||||
: localValue.value.length;
|
: localValue.value.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -164,16 +168,12 @@ const onInput = () => {
|
||||||
highlightedIndex.value = showList.value ? 0 : -1;
|
highlightedIndex.value = showList.value ? 0 : -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset scroll position when filtered options change
|
|
||||||
watch(filteredOptions, () => {
|
watch(filteredOptions, () => {
|
||||||
highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1;
|
highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const dropdown = document.querySelector('.dropdown-content');
|
dropdownItems.value = Array.from(
|
||||||
if (dropdown) {
|
document.querySelectorAll('[data-autocomplete-item]'),
|
||||||
dropdown.scrollTop = 0;
|
) as HTMLElement[];
|
||||||
}
|
|
||||||
const items = document.querySelectorAll('.dropdown-item');
|
|
||||||
dropdownItems.value = Array.from(items) as HTMLElement[];
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,7 +184,6 @@ const hideList = () =>
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const onKeydown = (e: KeyboardEvent) => {
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
// Track caret and allow ESC to immediately close suggestions and restore normal keys
|
|
||||||
updateCaret();
|
updateCaret();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
showList.value = false;
|
showList.value = false;
|
||||||
|
|
@ -200,34 +199,30 @@ const onKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
||||||
nextTick(() => {
|
nextTick(() =>
|
||||||
const el = dropdownItems.value[highlightedIndex.value];
|
dropdownItems.value[highlightedIndex.value]?.scrollIntoView({ block: 'nearest' }),
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
);
|
||||||
});
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
||||||
nextTick(() => {
|
nextTick(() =>
|
||||||
const el = dropdownItems.value[highlightedIndex.value];
|
dropdownItems.value[highlightedIndex.value]?.scrollIntoView({ block: 'nearest' }),
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
);
|
||||||
});
|
|
||||||
} else if (e.key === 'PageDown') {
|
} else if (e.key === 'PageDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.min(
|
highlightedIndex.value = Math.min(
|
||||||
highlightedIndex.value + pageSize,
|
highlightedIndex.value + pageSize,
|
||||||
filteredOptions.value.length - 1,
|
filteredOptions.value.length - 1,
|
||||||
);
|
);
|
||||||
nextTick(() => {
|
nextTick(() =>
|
||||||
const el = dropdownItems.value[highlightedIndex.value];
|
dropdownItems.value[highlightedIndex.value]?.scrollIntoView({ block: 'nearest' }),
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
);
|
||||||
});
|
|
||||||
} else if (e.key === 'PageUp') {
|
} else if (e.key === 'PageUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
||||||
nextTick(() => {
|
nextTick(() =>
|
||||||
const el = dropdownItems.value[highlightedIndex.value];
|
dropdownItems.value[highlightedIndex.value]?.scrollIntoView({ block: 'nearest' }),
|
||||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
);
|
||||||
});
|
|
||||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||||
const hasEqual = token.includes('=');
|
const hasEqual = token.includes('=');
|
||||||
|
|
@ -236,13 +231,10 @@ const onKeydown = (e: KeyboardEvent) => {
|
||||||
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||||
? filteredOptions.value[highlightedIndex.value]
|
? filteredOptions.value[highlightedIndex.value]
|
||||||
: undefined;
|
: undefined;
|
||||||
// Only autocomplete if there's a partial word being typed
|
|
||||||
if (selected && isFlagTrigger) {
|
if (selected && isFlagTrigger) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
appendFlag(selected.value);
|
appendFlag(selected.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dropdownItems is updated via a single top-level watch(filteredOptions)
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
max-width: 80vw;
|
max-width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,25 +112,27 @@
|
||||||
/>
|
/>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<span
|
<button
|
||||||
v-if="infoLoaded && !usingHls && hasVideoStream"
|
v-if="infoLoaded && !usingHls && hasVideoStream"
|
||||||
class="is-hidden-mobile has-text-info is-pointer"
|
type="button"
|
||||||
|
class="hidden items-center gap-2 text-info transition-colors hover:text-info/80 md:inline-flex"
|
||||||
@click.prevent="forceSwitchToHls"
|
@click.prevent="forceSwitchToHls"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-arrows-rotate" /></span>
|
<UIcon name="i-lucide-refresh-cw" class="size-4" />
|
||||||
<span>Trouble playing? switch to HLS stream.</span>
|
<span>Trouble playing? switch to HLS stream.</span>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<button
|
||||||
class="is-hidden-mobile has-text-grey-lighter is-pointer"
|
type="button"
|
||||||
|
class="hidden items-center gap-2 text-toned transition-colors hover:text-default md:inline-flex"
|
||||||
@click="showHelp = !showHelp"
|
@click="showHelp = !showHelp"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-question" /></span>
|
<UIcon name="i-lucide-circle-help" class="size-4" />
|
||||||
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
|
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -270,10 +272,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: center" v-else>
|
<div v-else class="flex justify-center py-10">
|
||||||
<div class="icon">
|
<UIcon name="i-lucide-loader-circle" class="size-16 animate-spin text-toned" />
|
||||||
<i class="fa-solid fa-spinner fa-spin fa-4x" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,189 +1,259 @@
|
||||||
<!-- ui/app/pages/options.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-1 container" style="border-radius: 0; padding: 0">
|
<div class="w-full min-w-0 max-w-full space-y-4 p-1 sm:p-2">
|
||||||
<div class="box m-2">
|
<div class="grid gap-4 rounded-lg border border-default bg-muted/10 p-4 lg:grid-cols-12">
|
||||||
<div class="columns is-multiline is-vcentered">
|
<UFormField label="Search" class="lg:col-span-4" :ui="fieldUi">
|
||||||
<div class="column is-12 is-6-desktop">
|
<UInput
|
||||||
<label class="label is-small">Search</label>
|
|
||||||
<div class="control has-icons-left">
|
|
||||||
<input
|
|
||||||
v-model.trim="filters.query"
|
v-model.trim="filters.query"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
|
||||||
placeholder="Filter by flag or description..."
|
placeholder="Filter by flag or description..."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-search" class="size-4 text-toned" />
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Group Filter" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
|
||||||
|
<USelect
|
||||||
|
v-model="filters.group"
|
||||||
|
:items="groupItems"
|
||||||
|
value-key="value"
|
||||||
|
label-key="label"
|
||||||
|
class="w-full"
|
||||||
|
:ui="inputUi"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-left"><i class="fa-solid fa-magnifying-glass" /></span>
|
</UFormField>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-3-desktop">
|
<UFormField label="Display" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
|
||||||
<label class="label is-small">Group Filter</label>
|
<USelect
|
||||||
<div class="select is-fullwidth">
|
v-model="displayMode"
|
||||||
<select v-model="filters.group">
|
:items="displayItems"
|
||||||
<option value="">All</option>
|
value-key="value"
|
||||||
<option v-for="g in groupNames" :key="g" :value="g">{{ g }}</option>
|
label-key="label"
|
||||||
</select>
|
class="w-full"
|
||||||
</div>
|
:ui="inputUi"
|
||||||
</div>
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-3-desktop">
|
<UFormField label="Sort By" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
|
||||||
<label class="label is-small">Display</label>
|
<USelect
|
||||||
<div class="control">
|
v-model="sortBy"
|
||||||
<div class="select is-fullwidth">
|
:items="sortItems"
|
||||||
<select v-model="displayMode">
|
value-key="value"
|
||||||
<option value="grouped">Grouped</option>
|
label-key="label"
|
||||||
<option value="list">List</option>
|
class="w-full"
|
||||||
</select>
|
:ui="inputUi"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</UFormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6-tablet is-3-desktop">
|
<UFormField label="Order" class="sm:col-span-6 lg:col-span-2" :ui="fieldUi">
|
||||||
<label class="label is-small">Sort By</label>
|
<USelect
|
||||||
<div class="select is-fullwidth">
|
v-model="sortDir"
|
||||||
<select v-model="sortBy">
|
:items="orderItems"
|
||||||
<option value="flag">Flag</option>
|
value-key="value"
|
||||||
<option value="group">Group</option>
|
label-key="label"
|
||||||
</select>
|
class="w-full"
|
||||||
</div>
|
:ui="inputUi"
|
||||||
</div>
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-3-desktop">
|
<UFormField label="Flags" class="lg:col-span-12" :ui="fieldUi">
|
||||||
<label class="label is-small">Order</label>
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="select is-fullwidth">
|
<UButton
|
||||||
<select v-model="sortDir">
|
v-for="item in flagFilterItems"
|
||||||
<option value="asc">Asc</option>
|
:key="item.value"
|
||||||
<option value="desc">Desc</option>
|
type="button"
|
||||||
</select>
|
size="xs"
|
||||||
</div>
|
:color="filters.flagKind === item.value ? 'primary' : 'neutral'"
|
||||||
</div>
|
:variant="filters.flagKind === item.value ? 'solid' : 'outline'"
|
||||||
|
@click="filters.flagKind = item.value"
|
||||||
<div class="column is-12 is-6-desktop">
|
|
||||||
<label class="label is-small">Flags</label>
|
|
||||||
<div class="buttons are-small">
|
|
||||||
<button
|
|
||||||
class="button"
|
|
||||||
:class="{ 'is-link': filters.flagKind === 'any' }"
|
|
||||||
@click="filters.flagKind = 'any'"
|
|
||||||
>
|
>
|
||||||
Any
|
{{ item.label }}
|
||||||
</button>
|
</UButton>
|
||||||
<button
|
|
||||||
class="button"
|
<UButton
|
||||||
:class="{ 'is-link': filters.flagKind === 'short' }"
|
type="button"
|
||||||
@click="filters.flagKind = 'short'"
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="() => void reload()"
|
||||||
>
|
>
|
||||||
Short Only (-x)
|
Reload
|
||||||
</button>
|
</UButton>
|
||||||
<button
|
|
||||||
class="button"
|
|
||||||
:class="{ 'is-link': filters.flagKind === 'long' }"
|
|
||||||
@click="filters.flagKind = 'long'"
|
|
||||||
>
|
|
||||||
Long Only (--xyz)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="0 === visible.length" class="has-text-centered has-text-grey">
|
<UAlert
|
||||||
<p>No options match your criteria.</p>
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading yt-dlp options. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="visible.length === 0"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search-x"
|
||||||
|
title="No options match your criteria."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else-if="displayMode === 'grouped' && grouped.length !== 0">
|
||||||
|
<section v-for="group in grouped" :key="group.name" class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon name="i-lucide-folder-open" class="size-4 text-toned" />
|
||||||
|
<span>{{ group.name }}</span>
|
||||||
|
<UBadge color="neutral" variant="soft" size="sm">{{ group.items.length }}</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="'grouped' === displayMode && 0 !== grouped.length">
|
<div
|
||||||
<section v-for="group in grouped" :key="group.name" class="m-2 mb-5">
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
<h2 class="title is-6 mb-3">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-regular fa-folder-open" /></span>
|
|
||||||
<span
|
|
||||||
>{{ group.name }}
|
|
||||||
<small class="has-text-grey">({{ group.items.length }})</small></span
|
|
||||||
>
|
>
|
||||||
</span>
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
</h2>
|
<table class="min-w-180 w-full table-auto text-sm">
|
||||||
<div class="table-container">
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||||
<table class="table is-fullwidth is-striped is-hoverable is-bordered">
|
<tr class="text-left [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||||
<thead>
|
<th class="w-[1%] whitespace-nowrap">Flags</th>
|
||||||
<tr>
|
|
||||||
<th style="width: 30%">Flags</th>
|
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-default">
|
||||||
<template v-for="opt in group.items" :key="opt.flags.join('|')">
|
<tr
|
||||||
<tr v-if="!opt.ignored">
|
v-for="opt in group.items"
|
||||||
<td>
|
:key="opt.flags.join('|')"
|
||||||
<i
|
class="align-top hover:bg-muted/20"
|
||||||
class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
>
|
||||||
@click="copyFlag(opt.flags)"
|
<td class="w-[1%] px-3 py-3 align-top whitespace-nowrap">
|
||||||
/>
|
<div class="flex items-start gap-2">
|
||||||
<div class="is-flex is-align-items-center">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<div class="tags">
|
<UBadge
|
||||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
v-for="flag in opt.flags"
|
||||||
|
:key="flag"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
class="max-w-full whitespace-nowrap font-mono"
|
||||||
|
>
|
||||||
|
{{ flag }}
|
||||||
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UTooltip text="Copy long flag">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
square
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="!hasLongFlag(opt.flags)"
|
||||||
|
@click="() => void copyFlag(opt.flags)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-3 py-3 align-top text-default">
|
||||||
<span v-if="opt.description && 0 !== opt.description.length">{{
|
<div class="min-w-0 wrap-break-word whitespace-normal">
|
||||||
|
<span v-if="opt.description && opt.description.length !== 0">{{
|
||||||
opt.description
|
opt.description
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else class="has-text-grey">—</span>
|
<span v-else class="text-toned">-</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<div
|
||||||
<div class="table-container">
|
v-else
|
||||||
<table class="table is-fullwidth is-striped is-hoverable is-bordered m-2">
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
<thead>
|
>
|
||||||
<tr>
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
<th style="width: 30%">Flags</th>
|
<table class="min-w-215 w-full table-auto text-sm">
|
||||||
<th style="width: 20%">Group</th>
|
<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">
|
||||||
|
<th class="w-[1%] whitespace-nowrap">Flags</th>
|
||||||
|
<th class="w-[1%] whitespace-nowrap">Group</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-default">
|
||||||
<template v-for="opt in visible" :key="opt.flags.join('|')">
|
<tr
|
||||||
<tr v-if="!opt.ignored">
|
v-for="opt in visible"
|
||||||
<td>
|
:key="opt.flags.join('|')"
|
||||||
<i
|
class="align-top hover:bg-muted/20"
|
||||||
class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
>
|
||||||
@click="copyFlag(opt.flags)"
|
<td class="w-[1%] px-3 py-3 align-top">
|
||||||
/>
|
<div class="flex items-start gap-2">
|
||||||
<div class="is-flex is-align-items-center">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<div class="tags">
|
<UBadge
|
||||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
v-for="flag in opt.flags"
|
||||||
|
:key="flag"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
class="max-w-full whitespace-nowrap font-mono"
|
||||||
|
>
|
||||||
|
{{ flag }}
|
||||||
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UTooltip text="Copy long flag">
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-copy"
|
||||||
|
square
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="!hasLongFlag(opt.flags)"
|
||||||
|
@click="() => void copyFlag(opt.flags)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-bold">
|
<td class="w-[1%] px-3 py-3 align-top font-medium text-default whitespace-nowrap">
|
||||||
{{ opt.group || 'root' }}
|
{{ opt.group || 'root' }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-3 py-3 align-top text-default">
|
||||||
<span v-if="opt.description && 0 !== opt.description.length">{{
|
<div class="min-w-0 wrap-break-word whitespace-normal">
|
||||||
|
<span v-if="opt.description && opt.description.length !== 0">{{
|
||||||
opt.description
|
opt.description
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else class="has-text-grey">—</span>
|
<span v-else class="text-toned">-</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { YTDLPOption } from '~/types/ytdlp';
|
import type { YTDLPOption } from '~/types/ytdlp';
|
||||||
|
import {
|
||||||
|
buildYtdlpGroupItems,
|
||||||
|
normalizeYtdlpGroupFilter,
|
||||||
|
YTDLP_ALL_GROUPS,
|
||||||
|
} from '~/utils/ytdlpOptions';
|
||||||
|
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const options = ref<YTDLPOption[]>([]);
|
const options = ref<YTDLPOption[]>([]);
|
||||||
|
|
@ -193,10 +263,42 @@ const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc');
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
query: '',
|
query: '',
|
||||||
group: '',
|
group: YTDLP_ALL_GROUPS,
|
||||||
flagKind: 'any' as 'any' | 'short' | 'long',
|
flagKind: 'any' as 'any' | 'short' | 'long',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fieldUi = {
|
||||||
|
label: 'font-semibold text-default',
|
||||||
|
container: 'space-y-2',
|
||||||
|
description: 'text-sm text-toned',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputUi = {
|
||||||
|
root: 'w-full',
|
||||||
|
base: 'w-full bg-elevated/60 ring-default focus-visible:ring-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayItems = [
|
||||||
|
{ label: 'Grouped', value: 'grouped' },
|
||||||
|
{ label: 'List', value: 'list' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortItems = [
|
||||||
|
{ label: 'Flag', value: 'flag' },
|
||||||
|
{ label: 'Group', value: 'group' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderItems = [
|
||||||
|
{ label: 'Asc', value: 'asc' },
|
||||||
|
{ label: 'Desc', value: 'desc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const flagFilterItems = [
|
||||||
|
{ label: 'Any', value: 'any' as const },
|
||||||
|
{ label: 'Short Only (-x)', value: 'short' as const },
|
||||||
|
{ label: 'Long Only (--xyz)', value: 'long' as const },
|
||||||
|
];
|
||||||
|
|
||||||
const reload = async (): Promise<void> => {
|
const reload = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
@ -213,8 +315,12 @@ const reload = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasLongFlag = (flags: string[]): boolean => {
|
||||||
|
return flags.some((flag) => flag.startsWith('--'));
|
||||||
|
};
|
||||||
|
|
||||||
const copyFlag = async (flags: string[]): Promise<void> => {
|
const copyFlag = async (flags: string[]): Promise<void> => {
|
||||||
const longFlag = flags.find((f) => f.startsWith('--'));
|
const longFlag = flags.find((flag) => flag.startsWith('--'));
|
||||||
if (!longFlag) {
|
if (!longFlag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -224,35 +330,49 @@ const copyFlag = async (flags: string[]): Promise<void> => {
|
||||||
onMounted(async () => await reload());
|
onMounted(async () => await reload());
|
||||||
|
|
||||||
const groupNames = computed<string[]>(() => {
|
const groupNames = computed<string[]>(() => {
|
||||||
const s = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const o of options.value) {
|
for (const option of options.value) {
|
||||||
s.add(o.group || 'root');
|
names.add(option.group || 'root');
|
||||||
}
|
}
|
||||||
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupItems = computed(() => {
|
||||||
|
return buildYtdlpGroupItems(groupNames.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filtered = computed<YTDLPOption[]>(() => {
|
const filtered = computed<YTDLPOption[]>(() => {
|
||||||
const q = filters.query.toLowerCase();
|
const q = filters.query.toLowerCase();
|
||||||
const g = filters.group;
|
const g = normalizeYtdlpGroupFilter(filters.group);
|
||||||
|
|
||||||
return options.value.filter((o) => {
|
return options.value.filter((option) => {
|
||||||
if (g && (o.group || 'root') !== g) {
|
if (option.ignored) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('short' === filters.flagKind && !o.flags.some((f) => /^-\w(,|$)|^-\w$/.test(f))) {
|
if (g && (option.group || 'root') !== g) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('long' === filters.flagKind && !o.flags.some((f) => /^--[a-zA-Z0-9][\w-]*/.test(f))) {
|
if (
|
||||||
|
filters.flagKind === 'short' &&
|
||||||
|
!option.flags.some((flag) => /^-\w(,|$)|^-\w$/.test(flag))
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (0 !== q.length) {
|
if (
|
||||||
const hay = [o.flags.join(' '), o.description || '', o.group || 'root']
|
filters.flagKind === 'long' &&
|
||||||
|
!option.flags.some((flag) => /^--[a-zA-Z0-9][\w-]*/.test(flag))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.length !== 0) {
|
||||||
|
const haystack = [option.flags.join(' '), option.description || '', option.group || 'root']
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (-1 === hay.indexOf(q)) {
|
if (!haystack.includes(q)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,54 +382,45 @@ const filtered = computed<YTDLPOption[]>(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const sorted = computed<YTDLPOption[]>(() => {
|
const sorted = computed<YTDLPOption[]>(() => {
|
||||||
const dir = 'asc' === sortDir.value ? 1 : -1;
|
const dir = sortDir.value === 'asc' ? 1 : -1;
|
||||||
const arr = [...filtered.value];
|
const list = [...filtered.value];
|
||||||
|
|
||||||
arr.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
if ('group' === sortBy.value) {
|
if (sortBy.value === 'group') {
|
||||||
const ga = (a.group || 'root').localeCompare(b.group || 'root');
|
const groupCompare = (a.group || 'root').localeCompare(b.group || 'root');
|
||||||
if (0 !== ga) {
|
if (groupCompare !== 0) {
|
||||||
return ga * dir;
|
return groupCompare * dir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '');
|
return (a.flags[0] || '').localeCompare(b.flags[0] || '') * dir;
|
||||||
return fa * dir;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return arr;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
const visible = computed<YTDLPOption[]>(() => sorted.value);
|
const visible = computed(() => sorted.value);
|
||||||
|
|
||||||
const grouped = computed<{ name: string; items: YTDLPOption[] }[]>(() => {
|
const grouped = computed<{ name: string; items: YTDLPOption[] }[]>(() => {
|
||||||
const map = new Map<string, YTDLPOption[]>();
|
const map = new Map<string, YTDLPOption[]>();
|
||||||
|
|
||||||
for (const o of visible.value) {
|
for (const option of visible.value) {
|
||||||
const key = o.group || 'root';
|
const key = option.group || 'root';
|
||||||
if (!map.has(key)) {
|
if (!map.has(key)) {
|
||||||
map.set(key, []);
|
map.set(key, []);
|
||||||
}
|
}
|
||||||
map.get(key)!.push(o);
|
map.get(key)?.push(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = 'asc' === sortDir.value ? 1 : -1;
|
const dir = sortDir.value === 'asc' ? 1 : -1;
|
||||||
const out = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
const list = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||||
|
|
||||||
if ('group' === sortBy.value) {
|
if (sortBy.value === 'group') {
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name) * dir);
|
list.sort((a, b) => a.name.localeCompare(b.name) * dir);
|
||||||
} else {
|
} else {
|
||||||
// When sorting by flag, groups should still be in alphabetical order
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return list;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.table td,
|
|
||||||
.table th {
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { reactive, readonly } from 'vue';
|
||||||
|
|
||||||
export type DialogResult<T = string | null> = { status: boolean; value: T };
|
export type DialogResult<T = string | null> = { status: boolean; value: T };
|
||||||
|
|
||||||
|
export type DialogOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
checked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type BaseOptions = {
|
type BaseOptions = {
|
||||||
/**
|
/**
|
||||||
* Title of the dialog
|
* Title of the dialog
|
||||||
|
|
@ -16,9 +22,9 @@ type BaseOptions = {
|
||||||
*/
|
*/
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
/**
|
/**
|
||||||
* Color class for the confirm button (e.g., 'is-primary', 'is-danger')
|
* Color for the confirm button.
|
||||||
*/
|
*/
|
||||||
confirmColor?: string;
|
confirmColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PromptOptions = BaseOptions & {
|
export type PromptOptions = BaseOptions & {
|
||||||
|
|
@ -50,6 +56,10 @@ export type ConfirmOptions = BaseOptions & {
|
||||||
* Raw HTML content to include in the dialog message.
|
* Raw HTML content to include in the dialog message.
|
||||||
*/
|
*/
|
||||||
rawHTML?: string;
|
rawHTML?: string;
|
||||||
|
/**
|
||||||
|
* Optional checkbox-style options to return with the confirmation result.
|
||||||
|
*/
|
||||||
|
options?: DialogOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlertOptions = BaseOptions & {};
|
export type AlertOptions = BaseOptions & {};
|
||||||
|
|
@ -97,7 +107,7 @@ export const useDialog = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmDialog = (opts: ConfirmOptions) =>
|
const confirmDialog = (opts: ConfirmOptions) =>
|
||||||
new Promise<DialogResult<null>>((resolve) => {
|
new Promise<DialogResult<Record<string, boolean> | null>>((resolve) => {
|
||||||
state.queue.push({ type: 'confirm', opts, resolve });
|
state.queue.push({ type: 'confirm', opts, resolve });
|
||||||
_dequeue();
|
_dequeue();
|
||||||
});
|
});
|
||||||
|
|
@ -108,16 +118,21 @@ export const useDialog = () => {
|
||||||
_dequeue();
|
_dequeue();
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirm = (value?: string) => {
|
const confirm = (value?: string | Record<string, boolean>) => {
|
||||||
if (!state.current) return;
|
if (!state.current) return;
|
||||||
if (state.current.type === 'prompt') {
|
if (state.current.type === 'prompt') {
|
||||||
const val = value ?? state.input;
|
const val = 'string' === typeof value ? value : state.input;
|
||||||
const v = (state.current.opts as PromptOptions).validate?.(val);
|
const v = (state.current.opts as PromptOptions).validate?.(val);
|
||||||
if (v && v !== true) {
|
if (v && v !== true) {
|
||||||
state.errorMsg = v;
|
state.errorMsg = v;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.current.resolve({ status: true, value: val });
|
state.current.resolve({ status: true, value: val });
|
||||||
|
} else if (state.current.type === 'confirm') {
|
||||||
|
state.current.resolve({
|
||||||
|
status: true,
|
||||||
|
value: 'object' === typeof value && null !== value ? value : null,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
state.current.resolve({ status: true, value: null });
|
state.current.resolve({ status: true, value: null });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
ui/app/composables/useDocs.ts
Normal file
170
ui/app/composables/useDocs.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
export type DocsFile = 'README.md' | 'FAQ.md' | 'API.md';
|
||||||
|
|
||||||
|
export type DocsEntry = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file: DocsFile;
|
||||||
|
route: string;
|
||||||
|
slug: string[];
|
||||||
|
icon: string;
|
||||||
|
navLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCS_ASSETS = ['sc_short.jpg', 'sc_simple.jpg'] as const;
|
||||||
|
|
||||||
|
const DOCS_ENTRIES: DocsEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'readme',
|
||||||
|
title: 'README',
|
||||||
|
description: 'Project overview',
|
||||||
|
file: 'README.md',
|
||||||
|
route: '/docs/readme',
|
||||||
|
slug: ['readme'],
|
||||||
|
icon: 'i-lucide-book-open',
|
||||||
|
navLabel: 'README',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'faq',
|
||||||
|
title: 'FAQ',
|
||||||
|
description: 'Answers for setup details, task handlers, and common issues.',
|
||||||
|
file: 'FAQ.md',
|
||||||
|
route: '/docs/faq',
|
||||||
|
slug: ['faq'],
|
||||||
|
icon: 'i-lucide-circle-help',
|
||||||
|
navLabel: 'FAQ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docs-api',
|
||||||
|
title: 'API',
|
||||||
|
description: 'HTTP API reference and endpoint behavior.',
|
||||||
|
file: 'API.md',
|
||||||
|
route: '/docs/api',
|
||||||
|
slug: ['api'],
|
||||||
|
icon: 'i-lucide-code-xml',
|
||||||
|
navLabel: 'API',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOCS_ROUTE_BY_FILE = new Map(DOCS_ENTRIES.map((entry) => [entry.file, entry.route]));
|
||||||
|
const normalizeSlugParts = (slug?: string | string[]): string[] => {
|
||||||
|
if (!slug) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Array.isArray(slug) ? slug : [slug])
|
||||||
|
.map((part) => part.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocsEntryBySlug = (slug?: string | string[]): DocsEntry | undefined => {
|
||||||
|
const parts = normalizeSlugParts(slug);
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return DOCS_ENTRIES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = parts.join('/');
|
||||||
|
|
||||||
|
if (key === 'readme') {
|
||||||
|
return DOCS_ENTRIES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOCS_ENTRIES.find((entry) => entry.slug.join('/') === key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocsNavigationEntries = () =>
|
||||||
|
DOCS_ENTRIES.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.navLabel,
|
||||||
|
icon: entry.icon,
|
||||||
|
to: entry.route,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const extractKnownDocsTarget = (href: string): { name: string; hash: string } | undefined => {
|
||||||
|
if (!href || href.startsWith('#')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(href, window?.origin || 'http://localhost');
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
const name = segments.at(-1) || '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
DOCS_ROUTE_BY_FILE.has(name as DocsFile) ||
|
||||||
|
DOCS_ASSETS.includes(name as (typeof DOCS_ASSETS)[number])
|
||||||
|
) {
|
||||||
|
return { name, hash: parsed.hash || '' };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDocsLink = (href: string): { href: string; external: boolean; docRoute?: string } => {
|
||||||
|
if (!href) {
|
||||||
|
return { href, external: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
const route = window?.location?.pathname || '';
|
||||||
|
const currentRoute = `${route}${href}`;
|
||||||
|
return { href: currentRoute, external: false, docRoute: currentRoute };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(href, window?.origin || 'http://localhost');
|
||||||
|
const currentPath = window?.location?.pathname || '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.origin === (window?.origin || parsed.origin) &&
|
||||||
|
parsed.hash &&
|
||||||
|
parsed.pathname === currentPath
|
||||||
|
) {
|
||||||
|
const currentRoute = `${parsed.pathname}${parsed.hash}`;
|
||||||
|
return { href: currentRoute, external: false, docRoute: currentRoute };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const match = extractKnownDocsTarget(href);
|
||||||
|
if (!match) {
|
||||||
|
return { href, external: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docsRoute = DOCS_ROUTE_BY_FILE.get(match.name as DocsFile);
|
||||||
|
if (docsRoute) {
|
||||||
|
const route = `${docsRoute}${match.hash}`;
|
||||||
|
return {
|
||||||
|
href: route,
|
||||||
|
external: false,
|
||||||
|
docRoute: route,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: `/api/docs/${match.name}`,
|
||||||
|
external: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDocsImageSrc = (href: string): string => {
|
||||||
|
const match = extractKnownDocsTarget(href);
|
||||||
|
if (!match) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCS_ASSETS.includes(match.name as (typeof DOCS_ASSETS)[number])) {
|
||||||
|
return `/api/docs/${match.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return href;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
DOCS_ENTRIES,
|
||||||
|
getDocsEntryBySlug,
|
||||||
|
getDocsNavigationEntries,
|
||||||
|
resolveDocsImageSrc,
|
||||||
|
resolveDocsLink,
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import { POSITION, useToast } from 'vue-toastification';
|
import { getNuxtToastManager } from '~/utils/nuxtToastManager';
|
||||||
|
|
||||||
export type notificationType = 'info' | 'success' | 'warning' | 'error';
|
export type notificationType = 'info' | 'success' | 'warning' | 'error';
|
||||||
export type notificationTarget = 'toast' | 'browser';
|
export type notificationTarget = 'toast' | 'browser';
|
||||||
|
export type toastPosition =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right';
|
||||||
|
|
||||||
export interface notification {
|
export interface notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -16,18 +23,30 @@ export interface notificationOptions {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
closeOnClick?: boolean;
|
closeOnClick?: boolean;
|
||||||
position?: POSITION;
|
position?: toastPosition;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
onClick?: (toast: { id: string | number }) => void;
|
||||||
onClick?: (closeToast: Function) => void;
|
|
||||||
store?: boolean;
|
store?: boolean;
|
||||||
lowPriority?: boolean;
|
lowPriority?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowToast = useStorage<boolean>('allow_toasts', true);
|
const allowToast = useStorage<boolean>('allow_toasts', true);
|
||||||
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
|
const toastPosition = useStorage<toastPosition>('toast_position', 'top-right');
|
||||||
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true);
|
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true);
|
||||||
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast');
|
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast');
|
||||||
const toast = useToast();
|
|
||||||
|
const toastMeta = (type: notificationType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return { color: 'success' as const, icon: 'i-lucide-badge-check' };
|
||||||
|
case 'warning':
|
||||||
|
return { color: 'warning' as const, icon: 'i-lucide-circle-alert' };
|
||||||
|
case 'error':
|
||||||
|
return { color: 'error' as const, icon: 'i-lucide-triangle-alert' };
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return { color: 'info' as const, icon: 'i-lucide-info' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const requestBrowserPermission = async (): Promise<NotificationPermission> => {
|
const requestBrowserPermission = async (): Promise<NotificationPermission> => {
|
||||||
if (!('Notification' in window)) {
|
if (!('Notification' in window)) {
|
||||||
|
|
@ -60,23 +79,21 @@ const sendMessage = (
|
||||||
'granted' !== Notification.permission;
|
'granted' !== Notification.permission;
|
||||||
|
|
||||||
if (useToastNotification) {
|
if (useToastNotification) {
|
||||||
switch (type) {
|
const toast = getNuxtToastManager();
|
||||||
case 'info':
|
if (!toast) {
|
||||||
toast.info(message, opts);
|
console.warn('Nuxt toast manager is not ready yet; skipping toast notification.');
|
||||||
break;
|
return;
|
||||||
case 'success':
|
|
||||||
toast.success(message, opts);
|
|
||||||
break;
|
|
||||||
case 'warning':
|
|
||||||
toast.warning(message, opts);
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
toast.error(message, opts);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
toast.error(`Unknown notification type: ${type}. ${message}`, opts);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const meta = toastMeta(type);
|
||||||
|
toast.add({
|
||||||
|
title: message,
|
||||||
|
color: meta.color,
|
||||||
|
icon: meta.icon,
|
||||||
|
duration: opts?.timeout,
|
||||||
|
close: true,
|
||||||
|
onClick: opts?.onClick,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,15 +139,18 @@ const notify = (type: notificationType, message: string, opts?: notificationOpti
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.closeOnClick = toastDismissOnClick.value;
|
opts.closeOnClick = toastDismissOnClick.value;
|
||||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT;
|
opts.position = toastPosition.value ?? 'top-right';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
opts.onClick = (toastInstance: { id: string | number }) => {
|
||||||
opts.onClick = (closeToast: Function) => {
|
|
||||||
if (opts?.closeOnClick !== false) {
|
|
||||||
closeToast();
|
|
||||||
}
|
|
||||||
if (notificationStore) {
|
if (notificationStore) {
|
||||||
notificationStore.markRead(id);
|
notificationStore.markRead(id);
|
||||||
}
|
}
|
||||||
|
if (opts?.closeOnClick !== false) {
|
||||||
|
const toast = getNuxtToastManager();
|
||||||
|
if (!toast) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.remove(toastInstance.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
sendMessage(type, id, message, opts);
|
sendMessage(type, id, message, opts);
|
||||||
|
|
|
||||||
155
ui/app/composables/usePresetEditor.ts
Normal file
155
ui/app/composables/usePresetEditor.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import type { Preset } from '~/types/presets';
|
||||||
|
import { cleanObject, prettyName } from '~/utils';
|
||||||
|
|
||||||
|
type EditablePreset = Partial<Preset> & {
|
||||||
|
raw?: boolean;
|
||||||
|
toggle_description?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMOVE_KEYS = ['raw', 'toggle_description'];
|
||||||
|
|
||||||
|
const makeEmptyPreset = (): Partial<Preset> => ({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
folder: '',
|
||||||
|
template: '',
|
||||||
|
cookies: '',
|
||||||
|
cli: '',
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
|
||||||
|
const cleaned = cleanObject(JSON.parse(JSON.stringify(item)), REMOVE_KEYS) as Partial<Preset> & {
|
||||||
|
default?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('default' in cleaned) {
|
||||||
|
delete cleaned.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 sessionId = ref(0);
|
||||||
|
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalDescription = computed(() => {
|
||||||
|
return reference.value ? 'Update an existing preset.' : 'Create a preset.';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
reference.value = null;
|
||||||
|
preset.value = makeEmptyPreset();
|
||||||
|
initialSnapshot.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
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();
|
||||||
|
sessionId.value += 1;
|
||||||
|
initialSnapshot.value = snapshot(preset.value);
|
||||||
|
isOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: Preset | EditablePreset): void => {
|
||||||
|
reference.value = item.id ?? null;
|
||||||
|
preset.value = sanitizePreset(item);
|
||||||
|
sessionId.value += 1;
|
||||||
|
initialSnapshot.value = snapshot(preset.value);
|
||||||
|
isOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestClose = async (): Promise<void> => {
|
||||||
|
if (presetsStore.addInProgress.value) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async ({
|
||||||
|
reference: currentReference,
|
||||||
|
preset: currentPreset,
|
||||||
|
}: {
|
||||||
|
reference: number | null;
|
||||||
|
preset: Preset;
|
||||||
|
}) => {
|
||||||
|
const cleaned = sanitizePreset(currentPreset) as Preset;
|
||||||
|
|
||||||
|
if (currentReference) {
|
||||||
|
const updated = await presetsStore.updatePreset(currentReference, cleaned);
|
||||||
|
if (updated) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await presetsStore.createPreset(cleaned);
|
||||||
|
if (created) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
reference,
|
||||||
|
preset,
|
||||||
|
modalKey,
|
||||||
|
modalTitle,
|
||||||
|
modalDescription,
|
||||||
|
isDirty,
|
||||||
|
addInProgress: presetsStore.addInProgress,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
close,
|
||||||
|
requestClose,
|
||||||
|
reset,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
};
|
||||||
96
ui/app/composables/usePresetOptions.ts
Normal file
96
ui/app/composables/usePresetOptions.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
|
||||||
|
|
||||||
|
import type { Preset } from '~/types/presets';
|
||||||
|
import { prettyName } from '~/utils';
|
||||||
|
|
||||||
|
type PresetSelectItem = {
|
||||||
|
type?: 'label' | 'item';
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresetDefaultField = 'cookies' | 'cli' | 'template' | 'folder';
|
||||||
|
|
||||||
|
type UsePresetOptionsOptions = {
|
||||||
|
order?: 'custom-first' | 'default-first';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePresetOptions = (
|
||||||
|
source?: MaybeRefOrGetter<Preset[] | readonly Preset[] | undefined>,
|
||||||
|
options: UsePresetOptionsOptions = {},
|
||||||
|
) => {
|
||||||
|
const config = useConfigStore();
|
||||||
|
|
||||||
|
const presets = computed<Preset[]>(() => {
|
||||||
|
const items = source ? toValue(source) : config.presets;
|
||||||
|
return Array.isArray(items) ? [...items] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPresets = computed(() => presets.value.filter((item) => item.default));
|
||||||
|
const customPresets = computed(() => presets.value.filter((item) => !item.default));
|
||||||
|
|
||||||
|
const filterPresets = (flag: boolean = true): Preset[] =>
|
||||||
|
presets.value.filter((item) => item.default === flag);
|
||||||
|
|
||||||
|
const findPreset = (name?: string | null): Preset | undefined =>
|
||||||
|
presets.value.find((item) => item.name === name);
|
||||||
|
|
||||||
|
const hasPreset = (name?: string | null): boolean => Boolean(findPreset(name));
|
||||||
|
|
||||||
|
const selectItems = computed<PresetSelectItem[]>(() => {
|
||||||
|
const items: PresetSelectItem[] = [];
|
||||||
|
|
||||||
|
const groups =
|
||||||
|
options.order === 'default-first'
|
||||||
|
? [
|
||||||
|
{ label: 'Default presets', list: defaultPresets.value },
|
||||||
|
{ label: 'Custom presets', list: customPresets.value },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ label: 'Custom presets', list: customPresets.value },
|
||||||
|
{ label: 'Default presets', list: defaultPresets.value },
|
||||||
|
];
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
if (group.list.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ type: 'label', label: group.label });
|
||||||
|
group.list.forEach((preset) => {
|
||||||
|
items.push({ label: prettyName(preset.name), value: preset.name });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPresetDefault = (
|
||||||
|
presetName: string | undefined,
|
||||||
|
type: PresetDefaultField,
|
||||||
|
fallback: string = '',
|
||||||
|
downloadPath: string = config.app.download_path || '',
|
||||||
|
): string => {
|
||||||
|
const preset = findPreset(presetName);
|
||||||
|
if (!preset) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'folder' && preset.folder) {
|
||||||
|
return preset.folder.replace(downloadPath, '') || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset[type] || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets,
|
||||||
|
defaultPresets,
|
||||||
|
customPresets,
|
||||||
|
filterPresets,
|
||||||
|
findPreset,
|
||||||
|
hasPreset,
|
||||||
|
selectItems,
|
||||||
|
getPresetDefault,
|
||||||
|
};
|
||||||
|
};
|
||||||
113
ui/app/error.vue
113
ui/app/error.vue
|
|
@ -1,56 +1,95 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="error-layout">
|
<NuxtLayout name="error-layout">
|
||||||
<div>
|
<div class="flex min-h-full flex-1 items-start justify-center py-4 sm:py-8">
|
||||||
<div class="columns is-multiline">
|
<UPageCard variant="outline" :ui="pageCardUi" class="w-full">
|
||||||
<div class="column is-12">
|
<template #body>
|
||||||
<h1 class="title is-4">
|
<div class="space-y-6 px-4 py-5 sm:px-6 sm:py-6 lg:px-7">
|
||||||
{{ error.statusCode
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
}}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span>
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-toned">
|
||||||
|
<UIcon name="i-lucide-triangle-alert" class="size-4 text-warning" />
|
||||||
|
<span>Application error</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-semibold text-highlighted sm:text-3xl">
|
||||||
|
{{ error.status || 'Unknown Error' }}
|
||||||
|
<span v-if="error.statusText"> - {{ error.statusText }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<p class="max-w-3xl text-sm leading-6 text-toned">
|
||||||
|
An unexpected error interrupted this view. You can go back home or retry the
|
||||||
|
current route.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="column is-12" v-if="error.message">
|
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||||
<div class="notification has-background-warning-90 has-text-dark">
|
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-house" to="/">
|
||||||
<p>{{ error.message }}</p>
|
Back to Home
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton color="primary" size="sm" icon="i-lucide-rotate-cw" @click="handleRetry">
|
||||||
|
Retry
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="error.stack">
|
<UAlert
|
||||||
<h2 class="title is-5 is-clickable" @click="showStacks = !showStacks">
|
v-if="error.message"
|
||||||
<span class="icon-text">
|
color="warning"
|
||||||
<span class="icon">
|
variant="soft"
|
||||||
<i v-if="showStacks" class="fas fa-arrow-up"></i>
|
icon="i-lucide-circle-alert"
|
||||||
<i v-else class="fas fa-arrow-down"></i>
|
title="Details"
|
||||||
</span>
|
:description="error.message"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-if="error.stack" class="space-y-3 border-t border-default pt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 text-sm font-semibold text-highlighted transition hover:text-primary"
|
||||||
|
@click="showStacks = !showStacks"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="showStacks ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
class="size-4"
|
||||||
|
/>
|
||||||
<span>Stack trace</span>
|
<span>Stack trace</span>
|
||||||
</span>
|
</button>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<pre v-if="showStacks"><code>{{ error.stack }}</code></pre>
|
<div
|
||||||
|
v-if="showStacks"
|
||||||
|
class="overflow-x-auto rounded-lg border border-default bg-elevated/60"
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
class="min-w-0 p-4 text-xs leading-6 whitespace-pre-wrap text-default"
|
||||||
|
><code>{{ error.stack }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div class="column is-12">
|
|
||||||
<h2 class="title is-5">
|
|
||||||
<NuxtLink to="/">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fas fa-home"></i></span>
|
|
||||||
<span>Back to Home</span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
const props = defineProps({
|
import type { NuxtError } from '#app';
|
||||||
error: {
|
|
||||||
type: Object,
|
const props = defineProps<{
|
||||||
required: true,
|
error: NuxtError;
|
||||||
},
|
}>();
|
||||||
});
|
|
||||||
const showStacks = ref(false);
|
const showStacks = ref(false);
|
||||||
|
|
||||||
|
const pageCardUi = {
|
||||||
|
root: 'w-full bg-default',
|
||||||
|
container: 'w-full p-0',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full p-0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (): Promise<void> => {
|
||||||
|
await clearError({ redirect: useRoute().fullPath });
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => console.error(props.error));
|
onMounted(() => console.error(props.error));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<UApp>
|
||||||
<nav class="navbar is-dark mb-5">
|
<div class="min-h-screen bg-default text-default">
|
||||||
<div class="navbar-brand pl-5">
|
<div
|
||||||
<NuxtLink class="navbar-item" to="/">
|
class="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-4 py-4 sm:px-6 sm:py-6 lg:px-8"
|
||||||
<span class="icon-text">
|
>
|
||||||
<span class="icon"><i class="fas fa-home"></i></span>
|
<header class="mb-6 flex items-center justify-between border-b border-default pb-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-flex items-center gap-2 text-sm font-semibold text-highlighted"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-house" class="size-4" />
|
||||||
<span>Home</span>
|
<span>Home</span>
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<span class="text-xs tracking-[0.22em] text-toned uppercase">Error</span>
|
||||||
import 'assets/css/bulma.css';
|
</header>
|
||||||
import 'assets/css/style.css';
|
|
||||||
import 'assets/css/all.css';
|
<main class="flex min-w-0 flex-1 flex-col">
|
||||||
</script>
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,128 +1,162 @@
|
||||||
<style scoped>
|
|
||||||
.logs-container {
|
|
||||||
padding: 1rem;
|
|
||||||
min-width: 100%;
|
|
||||||
max-height: 73vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
background-color: unset;
|
|
||||||
border-bottom: 1px solid var(--bulma-grey-light) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon"><i class="fas fa-cogs" /></span>
|
<UIcon name="i-lucide-git-commit-horizontal" class="size-5 text-toned" />
|
||||||
CHANGELOG
|
<span>CHANGELOG</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-toned">
|
||||||
|
Latest project changes, loaded remotely when available and falling back to the bundled
|
||||||
|
changelog file.
|
||||||
|
</p>
|
||||||
|
</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>
|
</span>
|
||||||
|
|
||||||
<div class="is-pulled-right">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0">
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
id="filter"
|
||||||
|
v-model.lazy="query"
|
||||||
|
type="search"
|
||||||
placeholder="Filter changelog entries"
|
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"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="logs && logs.length > 0">
|
<UButton
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
v-if="logs.length > 0"
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
color="neutral"
|
||||||
|
:variant="toggleFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilter = !toggleFilter"
|
||||||
|
>
|
||||||
<span v-if="!isMobile">Filter</span>
|
<span v-if="!isMobile">Filter</span>
|
||||||
</button>
|
</UButton>
|
||||||
|
|
||||||
|
<USwitch
|
||||||
|
v-model="latestOnly"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
:label="latestOnly ? 'Latest Only' : 'All Loaded'"
|
||||||
|
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="filteredLogs.length > 0" class="space-y-4">
|
||||||
|
<UPageCard v-for="log in filteredLogs" :key="log.tag" variant="outline" :ui="pageCardUi">
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 text-base font-semibold text-highlighted"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-git-branch" class="size-4 text-toned" />
|
||||||
|
<span>{{ log.tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UBadge v-if="isInstalled(log)" color="success" variant="soft" size="sm">
|
||||||
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UBadge color="neutral" variant="soft" size="sm">
|
||||||
|
{{ log.commits?.length || 0 }} commits
|
||||||
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="is-hidden-mobile">
|
<div class="space-y-3 border-t border-default pt-4">
|
||||||
<span class="subtitle"
|
<article
|
||||||
>This page display the latest changes and updates from the project.</span
|
v-for="commit in log.commits"
|
||||||
|
:key="commit.sha"
|
||||||
|
class="flex flex-col gap-2 rounded-md border border-default bg-muted/20 px-3 py-3"
|
||||||
>
|
>
|
||||||
</div>
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
</div>
|
<p class="min-w-0 flex-1 text-sm text-default">
|
||||||
</div>
|
<span class="font-semibold text-highlighted">
|
||||||
|
{{ ucFirst(commit.message).replace(/\.$/, '') }}.
|
||||||
<div class="columns is-multiline" v-if="isLoading">
|
|
||||||
<div class="column is-12">
|
|
||||||
<Message class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
|
||||||
Loading data. Please wait...
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="!isLoading">
|
|
||||||
<div class="logs-container" v-if="filteredLogs && filteredLogs.length > 0">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column p-0 m-0 is-12" v-for="(log, index) in filteredLogs" :key="log.tag">
|
|
||||||
<div class="content p-0 m-0">
|
|
||||||
<h1 class="is-4">
|
|
||||||
<span class="icon"><i class="fas fa-code-branch" /></span>
|
|
||||||
{{ log.tag }}
|
|
||||||
<span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
|
||||||
<template v-if="log.date">
|
|
||||||
<span style="font-size: 0.5em">
|
|
||||||
-
|
|
||||||
<span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
|
||||||
{{ moment(log.date).fromNow() }}
|
|
||||||
</span></span
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</h1>
|
|
||||||
<hr />
|
|
||||||
<ul>
|
|
||||||
<li v-for="commit in log.commits" :key="commit.sha">
|
|
||||||
<strong> {{ ucFirst(commit.message).replace(/\.$/, '') }}. </strong> -
|
|
||||||
<small>
|
|
||||||
<NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank">
|
|
||||||
<span
|
|
||||||
class="has-tooltip"
|
|
||||||
v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`"
|
|
||||||
>
|
|
||||||
{{ moment(commit.date).fromNow() }}
|
|
||||||
</span>
|
</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>
|
</NuxtLink>
|
||||||
<span
|
|
||||||
v-tooltip="'Code is at this commit.'"
|
<UIcon
|
||||||
v-if="commit.full_sha === app_sha"
|
v-if="commit.full_sha === app_sha"
|
||||||
class="icon has-text-success"
|
name="i-lucide-check"
|
||||||
><i class="fas fa-check"
|
class="size-4 text-success"
|
||||||
/></span>
|
/>
|
||||||
</small>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<hr v-if="index < logs.length - 1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-toned">
|
||||||
<div class="column is-12">
|
<span>{{ commit.author }}</span>
|
||||||
<Message
|
<UTooltip :text="`SHA: ${commit.full_sha} - Date: ${commit.date}`">
|
||||||
title="No Results"
|
<span>{{ moment(commit.date).fromNow() }}</span>
|
||||||
class="is-warning"
|
</UTooltip>
|
||||||
icon="fas fa-search"
|
|
||||||
v-if="query"
|
|
||||||
:useClose="true"
|
|
||||||
@close="query = ''"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
No changelog entries found for the query: <strong>{{ query }}</strong
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p>Please try a different search term.</p>
|
|
||||||
</Message>
|
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
v-if="query"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
title="No Results"
|
||||||
|
: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"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-alert"
|
||||||
|
title="No changelog entries"
|
||||||
|
description="No changelog data is available right now."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -130,7 +164,9 @@ hr {
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { changelogs, changeset } from '~/types/changelogs';
|
import type { changelogs, changeset } from '~/types/changelogs';
|
||||||
|
import { request, ucFirst, uri } from '~/utils';
|
||||||
|
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
|
|
@ -141,14 +177,23 @@ useHead({ title: 'CHANGELOG' });
|
||||||
const PROJECT = 'ytptube';
|
const PROJECT = 'ytptube';
|
||||||
const REPO = `https://github.com/arabcoders/${PROJECT}`;
|
const REPO = `https://github.com/arabcoders/${PROJECT}`;
|
||||||
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;
|
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
|
||||||
const logs = ref<changelogs>([]);
|
const logs = ref<changelogs>([]);
|
||||||
const app_version = ref('');
|
const app_version = ref('');
|
||||||
const app_branch = ref('');
|
const app_branch = ref('');
|
||||||
const app_sha = ref('');
|
const app_sha = ref('');
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const query = ref<string>('');
|
const query = ref('');
|
||||||
const toggleFilter = ref<boolean>(false);
|
const toggleFilter = ref(false);
|
||||||
|
const latestOnly = useStorage<boolean>('changelog_latest_only', true);
|
||||||
|
|
||||||
|
const pageCardUi = {
|
||||||
|
root: 'w-full bg-default',
|
||||||
|
container: 'w-full p-4 sm:p-5',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full',
|
||||||
|
};
|
||||||
|
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
|
|
@ -156,14 +201,19 @@ watch(toggleFilter, () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const visibleLogs = computed<changelogs>(() =>
|
||||||
|
latestOnly.value ? logs.value.slice(0, DEFAULT_LIMIT) : logs.value,
|
||||||
|
);
|
||||||
|
|
||||||
const filteredLogs = computed<changelogs>(() => {
|
const filteredLogs = computed<changelogs>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const q = query.value?.toLowerCase();
|
||||||
if (!q) return logs.value;
|
if (!q) {
|
||||||
|
return visibleLogs.value;
|
||||||
|
}
|
||||||
|
|
||||||
return logs.value
|
return visibleLogs.value
|
||||||
.map((log) => {
|
.map((log) => {
|
||||||
const tagMatches = log.tag.toLowerCase().includes(q);
|
const tagMatches = log.tag.toLowerCase().includes(q);
|
||||||
|
|
||||||
const filteredCommits =
|
const filteredCommits =
|
||||||
log.commits?.filter(
|
log.commits?.filter(
|
||||||
(commit) =>
|
(commit) =>
|
||||||
|
|
@ -181,8 +231,8 @@ const filteredLogs = computed<changelogs>(() => {
|
||||||
.filter((log): log is changeset => log !== null);
|
.filter((log): log is changeset => log !== null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadContent = async () => {
|
const loadContent = async (): Promise<void> => {
|
||||||
if ('' === app_version.value || logs.value.length > 0) {
|
if (app_version.value === '' || logs.value.length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,22 +242,19 @@ const loadContent = async () => {
|
||||||
REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
|
REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
|
||||||
);
|
);
|
||||||
logs.value = await changes.json();
|
logs.value = await changes.json();
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error(e);
|
console.error(error);
|
||||||
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
|
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
await nextTick();
|
console.error(error);
|
||||||
logs.value = logs.value.slice(0, 10);
|
toast.error(`Failed to fetch changelog. ${error.message}`);
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error(`Failed to fetch changelog. ${e.message}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isInstalled = (log: changeset) => {
|
const isInstalled = (log: changeset): boolean => {
|
||||||
const installed = String(app_sha.value);
|
const installed = String(app_sha.value);
|
||||||
|
|
||||||
if (log.full_sha.startsWith(installed)) {
|
if (log.full_sha.startsWith(installed)) {
|
||||||
|
|
@ -228,6 +275,6 @@ onMounted(async () => {
|
||||||
app_branch.value = config.app.app_branch;
|
app_branch.value = config.app.app_branch;
|
||||||
app_version.value = config.app.app_version;
|
app_version.value = config.app.app_version;
|
||||||
app_sha.value = config.app.app_commit_sha;
|
app_sha.value = config.app.app_commit_sha;
|
||||||
loadContent();
|
await loadContent();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,233 +1,182 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon-text">
|
<UIcon name="i-lucide-filter" class="size-5 text-toned" />
|
||||||
<template v-if="toggleForm">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
|
|
||||||
/></span>
|
|
||||||
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
|
||||||
<span>Conditions</span>
|
<span>Conditions</span>
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
|
||||||
placeholder="Filter displayed content"
|
|
||||||
/>
|
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="items && items.length > 0">
|
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
|
||||||
<span v-if="!isMobile">Filter</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
@click="
|
|
||||||
resetForm(false);
|
|
||||||
toggleForm = !toggleForm;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
<span v-if="!isMobile">New Condition</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
v-tooltip.bottom="'Change display style'"
|
|
||||||
class="button has-tooltip-bottom"
|
|
||||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-table': display_style !== 'list',
|
|
||||||
'fa-table-list': display_style === 'list',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-info"
|
|
||||||
@click="async () => await loadContent(page)"
|
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
:disabled="isLoading"
|
|
||||||
v-if="items && items.length > 0"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
<span v-if="!isMobile">Reload</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
<p class="text-sm text-toned">
|
||||||
<span class="subtitle">
|
|
||||||
Run yt-dlp custom match filter on returned info. and apply options.
|
Run yt-dlp custom match filter on returned info. and apply options.
|
||||||
|
</p>
|
||||||
|
</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>
|
</span>
|
||||||
|
<input
|
||||||
|
id="filter"
|
||||||
|
ref="filterInput"
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter displayed content"
|
||||||
|
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilterPanel"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Filter</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">New Condition</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="toggleDisplayStyle"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="() => void reloadContent()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
|
||||||
<Pager
|
<Pager
|
||||||
|
v-if="paging?.total_pages > 1"
|
||||||
:page="paging.page"
|
:page="paging.page"
|
||||||
:last_page="paging.total_pages"
|
:last_page="paging.total_pages"
|
||||||
:isLoading="isLoading"
|
:isLoading="isLoading"
|
||||||
@navigate="
|
@navigate="navigatePage"
|
||||||
async (newPage) => {
|
|
||||||
page = newPage;
|
|
||||||
await loadContent(newPage);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="displayStyle === '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">
|
||||||
|
<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 class="w-full text-left">Condition</th>
|
||||||
|
<th class="w-[1%]">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="divide-y divide-default">
|
||||||
|
<tr v-for="cond in filteredItems" :key="cond.id" class="hover:bg-muted/20">
|
||||||
|
<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>
|
||||||
|
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
<span v-if="cond.priority > 0" class="inline-flex items-center gap-1">
|
||||||
<ConditionForm
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||||
:addInProgress="conditions.addInProgress.value"
|
<span>Priority: {{ cond.priority }}</span>
|
||||||
:reference="itemRef"
|
</span>
|
||||||
:item="item as Condition"
|
|
||||||
@cancel="resetForm(true)"
|
|
||||||
@submit="updateItem"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1 text-xs text-toned">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-lucide-filter" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<code class="wrap-break-word">{{ cond.filter }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cond.cli" class="flex items-start gap-2">
|
||||||
|
<UIcon name="i-lucide-terminal" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<code class="wrap-break-word">{{ cond.cli }}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-for="([key, value], index) in extrasEntries(cond.extras)"
|
||||||
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
|
:key="`${cond.id}-${key}-${index}`"
|
||||||
|
class="flex items-start gap-2"
|
||||||
>
|
>
|
||||||
<div class="column is-12" v-if="'list' === display_style">
|
<UIcon name="i-lucide-list" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
<div class="table-container">
|
<code class="wrap-break-word">{{ key }}: {{ value }}</code>
|
||||||
<table
|
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
|
||||||
style="min-width: 850px; table-layout: fixed"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr class="has-text-centered is-unselectable">
|
|
||||||
<th width="80%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
|
||||||
<span>Condition</span>
|
|
||||||
</th>
|
|
||||||
<th width="20%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="cond in filteredItems" :key="cond.id">
|
|
||||||
<td class="is-vcentered">
|
|
||||||
<div class="is-text-overflow is-bold">
|
|
||||||
{{ cond.name }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="is-unselectable">
|
|
||||||
<span
|
|
||||||
class="icon-text is-clickable"
|
|
||||||
@click="toggleEnabled(cond)"
|
|
||||||
v-tooltip="
|
|
||||||
'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid fa-power-off"
|
|
||||||
:class="{
|
|
||||||
'has-text-success': cond.enabled !== false,
|
|
||||||
'has-text-danger': cond.enabled === false,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Popover :maxWidth="450">
|
|
||||||
<template #trigger>
|
|
||||||
<span class="is-clickable">
|
|
||||||
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
|
|
||||||
<span>Show Details</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title><strong>Condition Details</strong></template>
|
|
||||||
|
|
||||||
<div v-if="cond.filter">
|
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
|
||||||
<code>{{ cond.filter }}</code>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="cond.cli">
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
|
||||||
<code>{{ cond.cli }}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
|
||||||
<template v-for="(value, key) in cond.extras" :key="key">
|
|
||||||
<div>
|
|
||||||
<span class="icon"><i class="fa-solid fa-list" /></span>
|
|
||||||
<code>{{ key }}: {{ value }}</code>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
<template v-if="cond.priority > 0">
|
|
||||||
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
|
||||||
<span>Priority: {{ cond.priority }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<td class="w-[1%] px-3 py-3 align-middle whitespace-nowrap">
|
||||||
<div class="control">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<UButton
|
||||||
class="button is-info is-small is-fullwidth"
|
color="info"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
@click="exportItem(cond)"
|
@click="exportItem(cond)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="warning"
|
||||||
class="button is-warning is-small is-fullwidth"
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
@click="editItem(cond)"
|
@click="editItem(cond)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="error"
|
||||||
class="button is-danger is-small is-fullwidth"
|
variant="outline"
|
||||||
@click="deleteItem(cond)"
|
size="xs"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="() => void deleteItem(cond)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -235,149 +184,191 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
|
||||||
<div class="column is-6" v-for="cond in filteredItems" :key="cond.id">
|
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
<UCard
|
||||||
<header class="card-header">
|
v-for="cond in filteredItems"
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
:key="cond.id"
|
||||||
<div class="card-header-icon">
|
class="flex h-full flex-col border bg-default"
|
||||||
<div class="field is-grouped">
|
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||||
<div class="control" v-if="cond.priority > 0">
|
|
||||||
<span class="tag is-dark">
|
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
|
||||||
<span v-text="cond.priority" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="control" @click="toggleEnabled(cond)">
|
|
||||||
<span
|
|
||||||
class="icon"
|
|
||||||
:class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
||||||
v-tooltip="
|
|
||||||
`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-power-off" />
|
<template #header>
|
||||||
</span>
|
<div class="flex items-start justify-between gap-3">
|
||||||
</div>
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
<div class="control">
|
<button
|
||||||
<a
|
type="button"
|
||||||
class="has-text-info"
|
class="min-w-0 w-full text-left text-sm font-semibold text-highlighted"
|
||||||
v-tooltip="'Export item'"
|
@click="toggleExpand(cond.id, 'name')"
|
||||||
@click.prevent="exportItem(cond)"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span :class="expandClass(cond.id, 'name')">{{ cond.name }}</span>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="card-content is-flex-grow-1">
|
|
||||||
<div class="content">
|
|
||||||
<p class="is-text-overflow">
|
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
|
||||||
<span v-text="cond.filter" />
|
|
||||||
</p>
|
|
||||||
<p class="is-text-overflow" v-if="cond.cli">
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
|
||||||
<span>{{ cond.cli }}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="is-text-overflow"
|
|
||||||
v-if="cond.extras && Object.keys(cond.extras).length > 0"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-list" /></span>
|
|
||||||
<span
|
|
||||||
>Extras:
|
|
||||||
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
|
||||||
<b>{{ key }}</b
|
|
||||||
>: {{ value }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="is-clickable"
|
|
||||||
:class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
|
||||||
v-if="cond.description"
|
|
||||||
@click="toggleExpand(cond.id, 'description')"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
|
||||||
<span>{{ cond.description }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)">
|
<button
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
type="button"
|
||||||
<span>Delete</span>
|
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>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="cond.priority > 0"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||||
|
<span>Priority {{ cond.priority }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<UButton
|
||||||
|
color="info"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
square
|
||||||
|
@click="exportItem(cond)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
|
<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
|
<div
|
||||||
class="columns is-multiline"
|
v-if="extrasEntries(cond.extras).length > 0"
|
||||||
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
|
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
<div class="mb-2 flex items-center gap-2 text-toned">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<UIcon name="i-lucide-list" class="size-4" />
|
||||||
Loading data. Please wait...
|
<span class="text-sm font-medium">Extras</span>
|
||||||
</Message>
|
</div>
|
||||||
<Message
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="mt-auto grid gap-2 pt-2 sm:grid-cols-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="query && filteredItems.length < 1" class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
title="No Results"
|
title="No Results"
|
||||||
class="is-warning"
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||||
icon="fas fa-search"
|
/>
|
||||||
v-else-if="query"
|
|
||||||
:useClose="true"
|
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||||
@close="query = ''"
|
Clear filter
|
||||||
>
|
</UButton>
|
||||||
<p>
|
|
||||||
No results found for the query: <code>{{ query }}</code
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p>Please try a different search term.</p>
|
|
||||||
</Message>
|
|
||||||
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
|
||||||
There are no custom defined conditions yet. Click the
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span> <strong>New Condition</strong> button
|
|
||||||
to add your first condition.
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="!filteredItems.length"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-alert"
|
||||||
|
title="No items"
|
||||||
|
description="There are no custom defined conditions yet. Click the New Condition button to add your first condition."
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="filteredItems.length > 0 && !query"
|
||||||
v-if="filteredItems && filteredItems.length > 0 && !toggleForm"
|
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
|
||||||
<Message class="is-info" :body_class="'pl-0'">
|
|
||||||
<ul>
|
|
||||||
<li>
|
<li>
|
||||||
Filtering is based on yt-dlp’s <code>--match-filter</code> logic. Any expression that
|
Filtering is based on yt-dlp's <code>--match-filter</code> logic. Any expression that
|
||||||
works with yt-dlp will also work here, including the same boolean operators. We added
|
works with yt-dlp will also work here, including the same boolean operators. We added
|
||||||
extended support for the <code>OR</code> ( <code>||</code> ) operator, which yt-dlp
|
extended support for the <code>OR</code> ( <code>||</code> ) operator, which yt-dlp does
|
||||||
does not natively support. This allows you to combine multiple conditions more
|
not natively support. This allows you to combine multiple conditions more flexibly.
|
||||||
flexibly.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The primary use case for this feature is to apply custom cli arguments to specific
|
The primary use case for this feature is to apply custom cli arguments to specific
|
||||||
returned info.
|
returned info.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
For example, i follow specific channel that sometimes region lock some videos, by
|
For example, i follow specific channel that sometimes region lock some videos, by using
|
||||||
using the following filter i am able to bypass it
|
the following filter i am able to bypass it
|
||||||
<code>availability = 'needs_auth' & channel_id = 'channel_id'</code>. and set proxy
|
<code>availability = 'needs_auth' & channel_id = 'channel_id'</code>. and set proxy
|
||||||
for that specific video, while leaving the rest of the videos to be downloaded
|
for that specific video, while leaving the rest of the videos to be downloaded normally.
|
||||||
normally.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The data which the filter is applied on is the same data that yt-dlp returns, simply,
|
The data which the filter is applied on is the same data that yt-dlp returns, simply,
|
||||||
|
|
@ -385,26 +376,47 @@
|
||||||
instant feedback if the filter matches or not.
|
instant feedback if the filter matches or not.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
v-if="editorOpen"
|
||||||
|
:open="editorOpen"
|
||||||
|
:title="modalTitle"
|
||||||
|
:description="modalDescription"
|
||||||
|
: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()"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<ConditionForm
|
||||||
|
:key="modalKey"
|
||||||
|
:addInProgress="conditions.addInProgress.value"
|
||||||
|
:reference="itemRef"
|
||||||
|
:item="item as Condition"
|
||||||
|
@cancel="closeEditor()"
|
||||||
|
@submit="updateItem"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { Condition } from '~/types/conditions';
|
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { useConditions } from '~/composables/useConditions';
|
import { useConditions } from '~/composables/useConditions';
|
||||||
|
import type { Condition } from '~/types/conditions';
|
||||||
import type { APIResponse } from '~/types/responses';
|
import type { APIResponse } from '~/types/responses';
|
||||||
|
import { cleanObject, copyText, encode } from '~/utils';
|
||||||
|
|
||||||
type ConditionItemWithUI = Condition & { raw?: boolean };
|
type ConditionItemWithUI = Condition & { raw?: boolean };
|
||||||
|
|
||||||
const box = useConfirm();
|
const box = useConfirm();
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
|
const displayStyle = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
|
||||||
const conditions = useConditions();
|
const conditions = useConditions();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
|
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
|
||||||
const paging = conditions.pagination;
|
const paging = conditions.pagination;
|
||||||
|
|
@ -412,29 +424,101 @@ const isLoading = conditions.isLoading;
|
||||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
||||||
const item = ref<Partial<Condition>>({});
|
const item = ref<Partial<Condition>>({});
|
||||||
const itemRef = ref<number | null | undefined>(null);
|
const itemRef = ref<number | null | undefined>(null);
|
||||||
const toggleForm = ref(false);
|
const editorOpen = ref(false);
|
||||||
const query = ref<string>('');
|
const query = ref('');
|
||||||
const toggleFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const remove_keys = ['raw', 'toggle_description'];
|
const removeKeys = ['raw', 'toggle_description'];
|
||||||
const expandedItems = reactive<Record<number, Set<string>>>({});
|
const expandedItems = reactive<Record<number, Set<string>>>({});
|
||||||
|
|
||||||
const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
if (!q) return items.value;
|
if (!normalizedQuery) {
|
||||||
return items.value.filter((item: ConditionItemWithUI) => deepIncludes(item, q, new WeakSet()));
|
return items.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadContent = async (page: number = 1): Promise<void> => {
|
const modalTitle = computed(() =>
|
||||||
await conditions.loadConditions(page);
|
itemRef.value ? `Edit - ${item.value.name}` : 'Add new condition',
|
||||||
await nextTick();
|
);
|
||||||
if (conditions.pagination.value.total_pages > 1) {
|
const modalDescription = computed(
|
||||||
useRouter().replace({ query: { ...route.query, page: page.toString() } });
|
() => 'Run yt-dlp custom match filter on returned info. and apply options.',
|
||||||
|
);
|
||||||
|
const modalKey = computed(
|
||||||
|
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showFilter, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
query.value = '';
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncPageQuery = async (pageNumber: number): Promise<void> => {
|
||||||
|
const totalPages = conditions.pagination.value.total_pages;
|
||||||
|
const nextQuery = { ...route.query };
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
nextQuery.page = String(pageNumber);
|
||||||
|
} else {
|
||||||
|
delete nextQuery.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace({ query: nextQuery });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpand = (itemId: number | undefined, field: string) => {
|
const toggleFilterPanel = async (): Promise<void> => {
|
||||||
if (!itemId) return;
|
showFilter.value = !showFilter.value;
|
||||||
|
if (!showFilter.value) {
|
||||||
|
query.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
filterInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadContent = async (pageNumber = 1): Promise<void> => {
|
||||||
|
page.value = pageNumber;
|
||||||
|
await conditions.loadConditions(pageNumber);
|
||||||
|
await nextTick();
|
||||||
|
await syncPageQuery(pageNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadContent = async (): Promise<void> => {
|
||||||
|
await loadContent(page.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
|
await loadContent(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetEditor = (): void => {
|
||||||
|
item.value = {};
|
||||||
|
itemRef.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = (): void => {
|
||||||
|
editorOpen.value = false;
|
||||||
|
resetEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
resetEditor();
|
||||||
|
editorOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDisplayStyle = (): void => {
|
||||||
|
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (itemId: number | undefined, field: string): void => {
|
||||||
|
if (!itemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!expandedItems[itemId]) {
|
if (!expandedItems[itemId]) {
|
||||||
expandedItems[itemId] = new Set();
|
expandedItems[itemId] = new Set();
|
||||||
|
|
@ -448,28 +532,30 @@ const toggleExpand = (itemId: number | undefined, field: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isExpanded = (itemId: number | undefined, field: string): boolean => {
|
const isExpanded = (itemId: number | undefined, field: string): boolean => {
|
||||||
if (!itemId) return false;
|
if (!itemId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return expandedItems[itemId]?.has(field) ?? false;
|
return expandedItems[itemId]?.has(field) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(toggleFilter, (val) => {
|
const expandClass = (itemId: number | undefined, field: string): string => {
|
||||||
if (!val) {
|
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
|
||||||
query.value = '';
|
};
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetForm = (closeForm = false): void => {
|
const extrasEntries = (extras?: Record<string, unknown>): Array<[string, unknown]> => {
|
||||||
item.value = {};
|
if (!extras) {
|
||||||
itemRef.value = null;
|
return [];
|
||||||
if (closeForm) {
|
|
||||||
toggleForm.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Object.entries(extras).filter(([, value]) => value !== undefined && value !== '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (cond: Condition): Promise<void> => {
|
const deleteItem = async (cond: Condition): Promise<void> => {
|
||||||
if (true !== (await box.confirm(`Delete '${cond.name}'?`))) {
|
if (true !== (await box.confirm(`Delete '${cond.name}'?`))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await conditions.deleteCondition(cond.id!);
|
await conditions.deleteCondition(cond.id!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -480,41 +566,43 @@ const updateItem = async ({
|
||||||
reference: number | null | undefined;
|
reference: number | null | undefined;
|
||||||
item: Condition;
|
item: Condition;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
updatedItem = cleanObject(updatedItem, remove_keys) as Condition;
|
updatedItem = cleanObject(updatedItem, removeKeys) as Condition;
|
||||||
const cb = (resp: APIResponse) => {
|
const callback = (response: APIResponse) => {
|
||||||
if (resp.success) {
|
if (response.success) {
|
||||||
resetForm(true);
|
closeEditor();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await conditions.patchCondition(reference, updatedItem, cb);
|
await conditions.patchCondition(reference, updatedItem, callback);
|
||||||
} else {
|
} else {
|
||||||
await conditions.createCondition(updatedItem, cb);
|
await conditions.createCondition(updatedItem, callback);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (_item: Condition): void => {
|
const editItem = (value: Condition): void => {
|
||||||
item.value = JSON.parse(JSON.stringify(_item)) as Condition;
|
item.value = JSON.parse(JSON.stringify(value)) as Condition;
|
||||||
itemRef.value = _item.id;
|
itemRef.value = value.id;
|
||||||
toggleForm.value = true;
|
editorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnabled = async (cond: Condition): Promise<void> => {
|
const toggleEnabled = async (cond: Condition): Promise<void> => {
|
||||||
const new_state = !cond.enabled;
|
await conditions.patchCondition(cond.id!, { enabled: !cond.enabled });
|
||||||
await conditions.patchCondition(cond.id!, { enabled: new_state });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportItem = (cond: Condition): void =>
|
const exportItem = (cond: Condition): void => {
|
||||||
copyText(
|
copyText(
|
||||||
encode({
|
encode({
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k)),
|
Object.entries(cond).filter(
|
||||||
|
([key, value]) => !!value && !['id', ...removeKeys].includes(key),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_type: 'condition',
|
_type: 'condition',
|
||||||
_version: '1.2',
|
_version: '1.2',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => await loadContent(page.value));
|
onMounted(async () => await loadContent(page.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,67 @@
|
||||||
<style>
|
|
||||||
.terminal {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-item:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-1 columns is-multiline">
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="column is-12 is-clearfix">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<h1 class="title is-4">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="icon-text">
|
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<UIcon name="i-lucide-terminal" class="size-5 text-toned" />
|
||||||
<span>Console</span>
|
<span>Console</span>
|
||||||
</span>
|
|
||||||
</h1>
|
<UBadge :color="isLoading ? 'info' : 'neutral'" variant="soft" size="sm">
|
||||||
<div class="subtitle is-6 is-unselectable">
|
{{ isLoading ? 'Streaming output' : 'Idle' }}
|
||||||
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing
|
</UBadge>
|
||||||
the web interface and it's settings.
|
|
||||||
|
<UBadge v-if="commandHistory.length > 0" color="neutral" variant="outline" size="sm">
|
||||||
|
{{ commandHistory.length }} saved
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-toned">Run yt-dlp commands directly in a non-interactive session.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-eraser"
|
||||||
|
@click="() => void clearOutput()"
|
||||||
|
>
|
||||||
|
Clear output
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12">
|
|
||||||
<div class="card">
|
<UPageCard variant="naked" :ui="pageCardUi">
|
||||||
<header class="card-header">
|
<template #body>
|
||||||
<p class="card-header-title">
|
<div class="space-y-4">
|
||||||
<span class="icon">
|
<div>
|
||||||
<i class="fa-solid fa-desktop" />
|
<div>
|
||||||
</span>
|
<div
|
||||||
<span class="ml-2">Output</span>
|
ref="terminal_window"
|
||||||
|
class="terminal-host min-h-[55vh] max-h-[55vh] overflow-hidden rounded-xl scroll-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-default bg-default">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 border-b border-default bg-muted/10 px-4 py-3 lg:flex-row lg:items-start lg:justify-between"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 space-y-1">
|
||||||
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
|
<UIcon name="i-lucide-send" class="size-4 text-toned" />
|
||||||
|
<span>Command</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-toned">
|
||||||
|
Press <code>Enter</code> to run single-line input, <code>Shift+Enter</code> to
|
||||||
|
switch to multi-line, and <code>Ctrl+Enter</code> to run multi-line input.
|
||||||
</p>
|
</p>
|
||||||
<p class="card-header-icon">
|
</div>
|
||||||
<span v-tooltip.top="'Clear console window'" class="icon" @click="clearOutput()">
|
</div>
|
||||||
<i class="fa-solid fa-broom" />
|
|
||||||
</span>
|
<div class="grid gap-3 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
||||||
</p>
|
<div class="space-y-3">
|
||||||
</header>
|
|
||||||
<section class="card-content p-0 m-0">
|
|
||||||
<div ref="terminal_window" style="min-height: 60vh; max-height: 70vh" />
|
|
||||||
</section>
|
|
||||||
<section class="card-content p-1 m-1">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control is-expanded">
|
|
||||||
<TextareaAutocomplete
|
<TextareaAutocomplete
|
||||||
v-if="isMultiLineInput"
|
v-if="isMultiLineInput"
|
||||||
ref="commandTextarea"
|
ref="commandTextarea"
|
||||||
|
|
@ -55,111 +69,140 @@
|
||||||
:options="ytDlpOptions"
|
:options="ytDlpOptions"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
placeholder="--help"
|
placeholder="--help"
|
||||||
|
:rows="5"
|
||||||
@keydown="handleKeyDown"
|
@keydown="handleKeyDown"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputAutocomplete
|
<InputAutocomplete
|
||||||
v-else
|
v-else
|
||||||
v-model="command"
|
|
||||||
ref="commandInput"
|
ref="commandInput"
|
||||||
|
v-model="command"
|
||||||
:options="ytDlpOptions"
|
:options="ytDlpOptions"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
placeholder="--help"
|
placeholder="--help"
|
||||||
@keydown="handleKeyDown"
|
|
||||||
@paste="handlePaste"
|
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
:allowShortFlags="true"
|
:allowShortFlags="true"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
@paste="handlePaste"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
type="button"
|
|
||||||
:disabled="isLoading || !hasValidCommand"
|
|
||||||
@click="runCommand"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="flex flex-wrap items-center justify-end gap-2 xl:self-end">
|
||||||
<div class="card">
|
<UPopover :content="{ side: 'top', align: 'end', sideOffset: 8 }">
|
||||||
<header class="card-header">
|
<UButton
|
||||||
<p
|
color="neutral"
|
||||||
class="card-header-title is-pointer is-unselectable"
|
variant="outline"
|
||||||
@click="isHistoryCollapsed = !isHistoryCollapsed"
|
size="lg"
|
||||||
|
icon="i-lucide-history"
|
||||||
|
trailing-icon="i-lucide-chevron-up"
|
||||||
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
History
|
||||||
<span class="ml-2">Commands History</span>
|
</UButton>
|
||||||
</p>
|
|
||||||
<p class="card-header-icon">
|
<template #content>
|
||||||
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
|
<UCard class="w-[min(92vw,42rem)]" :ui="{ body: 'space-y-3 p-4' }">
|
||||||
<i class="fa-solid fa-trash" />
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
</span>
|
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
|
||||||
<span
|
<UIcon name="i-lucide-history" class="size-4 text-toned" />
|
||||||
v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'"
|
<span>Command history</span>
|
||||||
class="icon ml-2"
|
</div>
|
||||||
@click="isHistoryCollapsed = !isHistoryCollapsed"
|
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
:disabled="commandHistory.length < 1"
|
||||||
|
@click="() => void clearHistory()"
|
||||||
>
|
>
|
||||||
<i
|
Clear history
|
||||||
class="fa-solid"
|
</UButton>
|
||||||
:class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'"
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="commandHistory.length < 1"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-clock-3"
|
||||||
|
title="Commands history is empty"
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
</p>
|
<div
|
||||||
</header>
|
v-else
|
||||||
<div v-show="!isHistoryCollapsed" class="card-content p-2">
|
class="max-h-96 w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
<Message class="is-info" v-if="commandHistory.length < 1">
|
|
||||||
Commands history is empty.
|
|
||||||
</Message>
|
|
||||||
<div class="table-container" v-if="commandHistory.length > 0">
|
|
||||||
<table
|
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
|
||||||
style="min-width: 850px; table-layout: fixed"
|
|
||||||
>
|
>
|
||||||
<tbody>
|
<div class="w-full max-w-full overflow-auto overscroll-contain">
|
||||||
<tr v-for="(cmd, index) in commandHistory" :key="index">
|
<table class="min-w-155 w-full text-sm">
|
||||||
<td>
|
<tbody class="divide-y divide-default">
|
||||||
<code
|
<tr
|
||||||
class="is-family-monospace is-text-overflow is-pointer is-block"
|
v-for="(cmd, index) in commandHistory"
|
||||||
@click="loadCommand(cmd)"
|
:key="`${index}-${cmd}`"
|
||||||
|
class="hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<td class="px-3 py-3 align-middle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full text-left font-mono text-xs text-default hover:text-highlighted"
|
||||||
|
@click="() => void loadCommand(cmd)"
|
||||||
>
|
>
|
||||||
{{ cmd.replace(/\n/g, ' ') }}
|
{{ cmd.replace(/\n/g, ' ') }}
|
||||||
</code>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 40px; text-align: center">
|
|
||||||
<button class="delete" @click="removeFromHistory(index)" />
|
<td
|
||||||
|
class="w-[2%] px-3 py-3 text-center align-middle whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
square
|
||||||
|
@click="removeFromHistory(index)"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
:icon="isLoading ? 'i-lucide-loader-circle' : 'i-lucide-send'"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading || !hasValidCommand"
|
||||||
|
class="flex-1 justify-center sm:flex-none sm:min-w-36"
|
||||||
|
@click="() => void runCommand()"
|
||||||
|
>
|
||||||
|
Run command
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
|
||||||
import { useStorage } from '@vueuse/core';
|
|
||||||
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils';
|
|
||||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||||
import { useDialog } from '~/composables/useDialog';
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils';
|
||||||
|
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
|
|
@ -167,25 +210,36 @@ const dialog = useDialog();
|
||||||
|
|
||||||
const terminal = ref<Terminal>();
|
const terminal = ref<Terminal>();
|
||||||
const terminalFit = ref<FitAddon>();
|
const terminalFit = ref<FitAddon>();
|
||||||
const command = ref<string>('');
|
const command = ref('');
|
||||||
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
|
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
|
||||||
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
|
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
|
||||||
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
||||||
const isLoading = ref<boolean>(false);
|
const isLoading = ref(false);
|
||||||
const storedCommand = useStorage<string>('console_command', '');
|
const storedCommand = useStorage<string>('console_command', '');
|
||||||
const commandHistory = useStorage<string[]>('console_command_history', []);
|
const commandHistory = useStorage<string[]>('console_command_history', []);
|
||||||
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false);
|
|
||||||
const sseController = ref<AbortController | null>(null);
|
const sseController = ref<AbortController | null>(null);
|
||||||
|
|
||||||
const MAX_HISTORY_ITEMS = 50;
|
const MAX_HISTORY_ITEMS = 50;
|
||||||
|
|
||||||
|
const pageCardUi = {
|
||||||
|
root: 'w-full bg-transparent',
|
||||||
|
container: 'w-full p-0 sm:p-0',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full',
|
||||||
|
};
|
||||||
|
|
||||||
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
||||||
config.ytdlp_options.flatMap((opt) =>
|
config.ytdlp_options.flatMap((opt) =>
|
||||||
opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
|
opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0);
|
const hasValidCommand = computed(() => Boolean(command.value && command.value.trim().length > 0));
|
||||||
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'));
|
const isMultiLineInput = computed(() => Boolean(command.value && command.value.includes('\n')));
|
||||||
|
|
||||||
|
watch(command, (value) => {
|
||||||
|
storedCommand.value = value;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => isLoading.value,
|
() => isLoading.value,
|
||||||
|
|
@ -193,12 +247,14 @@ watch(
|
||||||
if (value) {
|
if (value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.value.trim()) {
|
if (command.value.trim()) {
|
||||||
addToHistory(command.value.trim());
|
addToHistory(command.value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
command.value = '';
|
command.value = '';
|
||||||
await nextTick();
|
await nextTick();
|
||||||
focusInput();
|
await focusInput();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
@ -209,6 +265,7 @@ watch(
|
||||||
if (config.app.console_enabled) {
|
if (config.app.console_enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
||||||
await navigateTo('/');
|
await navigateTo('/');
|
||||||
},
|
},
|
||||||
|
|
@ -216,7 +273,7 @@ watch(
|
||||||
|
|
||||||
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||||
const isTextarea = 'TEXTAREA' === target.tagName;
|
const isTextarea = target.tagName === 'TEXTAREA';
|
||||||
|
|
||||||
if (event.key !== 'Enter') {
|
if (event.key !== 'Enter') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -224,7 +281,7 @@ const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||||
|
|
||||||
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
runCommand();
|
await runCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,7 +292,9 @@ const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||||
command.value.substring(0, cursorPos) +
|
command.value.substring(0, cursorPos) +
|
||||||
'\n' +
|
'\n' +
|
||||||
command.value.substring(target.selectionEnd || cursorPos);
|
command.value.substring(target.selectionEnd || cursorPos);
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
if (commandTextarea.value) {
|
if (commandTextarea.value) {
|
||||||
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
|
|
@ -272,14 +331,11 @@ const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle_event = () => {
|
const handle_event = (): void => {
|
||||||
if (!terminal.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
terminalFit.value?.fit();
|
terminalFit.value?.fit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStreamMessage = (event: EventSourceMessage) => {
|
const handleStreamMessage = (event: EventSourceMessage): void => {
|
||||||
if (!terminal.value) {
|
if (!terminal.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -293,18 +349,18 @@ const handleStreamMessage = (event: EventSourceMessage) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('output' === event.event) {
|
if (event.event === 'output') {
|
||||||
terminal.value.writeln(payload?.line ?? '');
|
terminal.value.writeln(payload?.line ?? '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('close' === event.event) {
|
if (event.event === 'close') {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
sseController.value?.abort();
|
sseController.value?.abort();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startStream = async (cmd: string) => {
|
const startStream = async (cmd: string): Promise<void> => {
|
||||||
sseController.value?.abort();
|
sseController.value?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
sseController.value = controller;
|
sseController.value = controller;
|
||||||
|
|
@ -324,7 +380,9 @@ const startStream = async (cmd: string) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = response.statusText || 'Failed to start command stream.';
|
let message = response.statusText || 'Failed to start command stream.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
message = await parse_api_error(response.clone().json());
|
message = await parse_api_error(response.clone().json());
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -337,6 +395,7 @@ const startStream = async (cmd: string) => {
|
||||||
message = response.statusText || 'Failed to start command stream.';
|
message = response.statusText || 'Failed to start command stream.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
},
|
},
|
||||||
onmessage: handleStreamMessage,
|
onmessage: handleStreamMessage,
|
||||||
|
|
@ -344,6 +403,7 @@ const startStream = async (cmd: string) => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.value?.writeln(`Error: ${error}`);
|
terminal.value?.writeln(`Error: ${error}`);
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
},
|
},
|
||||||
|
|
@ -360,7 +420,7 @@ const startStream = async (cmd: string) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureTerminal = async () => {
|
const ensureTerminal = async (): Promise<void> => {
|
||||||
if (terminal.value) {
|
if (terminal.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +434,10 @@ const ensureTerminal = async () => {
|
||||||
rows: 10,
|
rows: 10,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
scrollback: 1000,
|
scrollback: 1000,
|
||||||
|
theme: {
|
||||||
|
background: '#09090b',
|
||||||
|
foreground: '#f4f4f5',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
terminalFit.value = new FitAddon();
|
terminalFit.value = new FitAddon();
|
||||||
|
|
@ -388,12 +452,12 @@ const ensureTerminal = async () => {
|
||||||
terminalFit.value.fit();
|
terminalFit.value.fit();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runCommand = async () => {
|
const runCommand = async (): Promise<void> => {
|
||||||
if (!hasValidCommand.value) {
|
if (!hasValidCommand.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true !== config.app.console_enabled) {
|
if (config.app.console_enabled !== true) {
|
||||||
await navigateTo('/');
|
await navigateTo('/');
|
||||||
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -404,39 +468,38 @@ const runCommand = async () => {
|
||||||
if (cmd.startsWith('yt-dlp')) {
|
if (cmd.startsWith('yt-dlp')) {
|
||||||
cmd = cmd.replace(/^yt-dlp/, '').trim();
|
cmd = cmd.replace(/^yt-dlp/, '').trim();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if ('' === cmd) {
|
if (cmd === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureTerminal();
|
await ensureTerminal();
|
||||||
|
|
||||||
if ('clear' === cmd) {
|
if (cmd === 'clear') {
|
||||||
clearOutput(true);
|
await clearOutput(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await startStream(cmd);
|
await startStream(cmd);
|
||||||
terminal.value?.writeln(`user@YTPTube ~`);
|
terminal.value?.writeln('user@YTPTube ~');
|
||||||
terminal.value?.writeln(`$ yt-dlp ${command.value}`);
|
terminal.value?.writeln(`$ yt-dlp ${command.value}`);
|
||||||
storedCommand.value = '';
|
storedCommand.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearOutput = async (withCommand: boolean = false) => {
|
const clearOutput = async (withCommand: boolean = false): Promise<void> => {
|
||||||
if (terminal.value) {
|
terminal.value?.clear();
|
||||||
terminal.value.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (true === withCommand) {
|
if (withCommand === true) {
|
||||||
command.value = '';
|
command.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
focusInput();
|
await focusInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusInput = async () => {
|
const focusInput = async (): Promise<void> => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
let elm;
|
|
||||||
|
let elm: HTMLInputElement | HTMLTextAreaElement | undefined;
|
||||||
if (isMultiLineInput.value) {
|
if (isMultiLineInput.value) {
|
||||||
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -446,43 +509,53 @@ const focusInput = async () => {
|
||||||
elm?.focus();
|
elm?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToHistory = (cmd: string) => {
|
const addToHistory = (cmd: string): void => {
|
||||||
commandHistory.value = [cmd, ...commandHistory.value.filter((h) => h !== cmd)].slice(
|
commandHistory.value = [cmd, ...commandHistory.value.filter((h) => h !== cmd)].slice(
|
||||||
0,
|
0,
|
||||||
MAX_HISTORY_ITEMS,
|
MAX_HISTORY_ITEMS,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCommand = async (cmd: string) => {
|
const loadCommand = async (cmd: string): Promise<void> => {
|
||||||
command.value = cmd;
|
command.value = cmd;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
focusInput();
|
await focusInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearHistory = async () => {
|
const clearHistory = async (): Promise<void> => {
|
||||||
if (commandHistory.value.length === 0) {
|
if (commandHistory.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await dialog.confirmDialog({
|
const { status } = await dialog.confirmDialog({
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
message: `Clear commands history?`,
|
message: 'Clear commands history?',
|
||||||
confirmColor: 'is-danger',
|
confirmColor: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commandHistory.value = [];
|
commandHistory.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromHistory = (index: number) =>
|
const removeFromHistory = (index: number): void => {
|
||||||
(commandHistory.value = commandHistory.value.filter((_, i) => i !== index));
|
commandHistory.value = commandHistory.value.filter((_, i) => i !== index);
|
||||||
|
};
|
||||||
|
|
||||||
watch(isMultiLineInput, () => focusInput());
|
watch(isMultiLineInput, async () => {
|
||||||
|
await focusInput();
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('resize', handle_event);
|
if (config.app.console_enabled !== true) {
|
||||||
focusInput();
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
||||||
|
await navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handle_event);
|
||||||
disableOpacity();
|
disableOpacity();
|
||||||
|
|
||||||
await ensureTerminal();
|
await ensureTerminal();
|
||||||
|
|
@ -491,11 +564,13 @@ onMounted(async () => {
|
||||||
command.value = storedCommand.value;
|
command.value = storedCommand.value;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await focusInput();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
sseController.value?.abort();
|
sseController.value?.abort();
|
||||||
document.removeEventListener('resize', handle_event);
|
window.removeEventListener('resize', handle_event);
|
||||||
enableOpacity();
|
enableOpacity();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,187 +1,162 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon-text">
|
<UIcon name="i-lucide-braces" class="size-5 text-toned" />
|
||||||
<template v-if="toggleForm">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
|
|
||||||
/></span>
|
|
||||||
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
|
||||||
<span>Custom Fields</span>
|
<span>Custom Fields</span>
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
|
||||||
placeholder="Filter displayed content"
|
|
||||||
/>
|
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="items && items.length > 0">
|
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
|
||||||
<span v-if="!isMobile">Filter</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
@click="
|
|
||||||
resetForm(false);
|
|
||||||
toggleForm = !toggleForm;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
<span v-if="!isMobile">New Field</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
v-tooltip.bottom="'Change display style'"
|
|
||||||
class="button has-tooltip-bottom"
|
|
||||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-table': display_style !== 'list',
|
|
||||||
'fa-table-list': display_style === 'list',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-info"
|
|
||||||
@click="async () => await loadContent(page)"
|
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
:disabled="isLoading"
|
|
||||||
v-if="items && items.length > 0"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
<span v-if="!isMobile">Reload</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
<p class="text-sm text-toned">
|
||||||
<span class="subtitle">
|
|
||||||
Custom fields allow you to add new fields to the download form.
|
Custom fields allow you to add new fields to the download form.
|
||||||
|
</p>
|
||||||
|
</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>
|
</span>
|
||||||
|
<input
|
||||||
|
id="filter"
|
||||||
|
ref="filterInput"
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter displayed content"
|
||||||
|
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilterPanel"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Filter</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">New Field</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="toggleDisplayStyle"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="() => void reloadContent()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
|
||||||
<Pager
|
<Pager
|
||||||
|
v-if="paging?.total_pages > 1"
|
||||||
:page="paging.page"
|
:page="paging.page"
|
||||||
:last_page="paging.total_pages"
|
:last_page="paging.total_pages"
|
||||||
:isLoading="isLoading"
|
:isLoading="isLoading"
|
||||||
@navigate="
|
@navigate="navigatePage"
|
||||||
async (newPage) => {
|
|
||||||
page = newPage;
|
|
||||||
await loadContent(newPage);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
|
||||||
<DLFieldForm
|
|
||||||
:addInProgress="dlFields.addInProgress.value"
|
|
||||||
:reference="itemRef"
|
|
||||||
:item="item as DLField"
|
|
||||||
@cancel="resetForm(true)"
|
|
||||||
@submit="updateItem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="displayStyle === 'list' && filteredItems.length > 0"
|
||||||
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
>
|
>
|
||||||
<div class="column is-12" v-if="'list' === display_style">
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
<div class="table-container">
|
<table class="min-w-225 w-full text-sm">
|
||||||
<table
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||||
style="min-width: 850px; table-layout: fixed"
|
<th class="w-full text-left">Field</th>
|
||||||
>
|
<th class="w-[1%]">Actions</th>
|
||||||
<thead>
|
|
||||||
<tr class="has-text-centered is-unselectable">
|
|
||||||
<th width="80%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
|
||||||
<span>Field</span>
|
|
||||||
</th>
|
|
||||||
<th width="20%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
|
||||||
<tr v-for="field in filteredItems" :key="field.id">
|
<tbody class="divide-y divide-default">
|
||||||
<td class="is-vcentered">
|
<tr v-for="field in filteredItems" :key="field.id" class="hover:bg-muted/20">
|
||||||
<div class="is-text-overflow is-bold">
|
<td class="px-3 py-3 align-middle">
|
||||||
{{ field.name }}
|
<div class="space-y-2">
|
||||||
</div>
|
<div class="font-semibold text-highlighted">{{ field.name }}</div>
|
||||||
<div class="is-unselectable">
|
|
||||||
<span class="icon-text">
|
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-terminal" class="size-3.5" />
|
||||||
<span>{{ field.field }}</span>
|
<span>{{ field.field }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="icon-text">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||||
<span>Order: {{ field.order }}</span>
|
<span>Order: {{ field.order }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-shapes" class="size-3.5" />
|
||||||
|
<span>{{ field.kind }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="field.description" class="text-xs text-toned">
|
||||||
|
{{ field.description }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<td class="w-[1%] px-3 py-3 align-middle whitespace-nowrap">
|
||||||
<div class="control">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<UButton
|
||||||
class="button is-info is-small is-fullwidth"
|
color="info"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
@click="exportItem(field)"
|
@click="exportItem(field)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="warning"
|
||||||
class="button is-warning is-small is-fullwidth"
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
@click="editItem(field)"
|
@click="editItem(field)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="error"
|
||||||
class="button is-danger is-small is-fullwidth"
|
variant="outline"
|
||||||
@click="deleteItem(field)"
|
size="xs"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="() => void deleteItem(field)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -189,107 +164,162 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
|
||||||
<div class="column is-6" v-for="field in filteredItems" :key="field.id">
|
<div v-else-if="filteredItems.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
<UCard
|
||||||
<header class="card-header">
|
v-for="field in filteredItems"
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="field.name" />
|
:key="field.id"
|
||||||
<div class="card-header-icon">
|
class="flex h-full flex-col border bg-default"
|
||||||
<div class="field is-grouped">
|
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||||
<div class="control">
|
>
|
||||||
<span class="tag is-dark">
|
<template #header>
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<span v-text="field.order" />
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<div class="truncate text-sm font-semibold text-highlighted">{{ field.name }}</div>
|
||||||
|
|
||||||
|
<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-list-ordered" class="size-3.5" />
|
||||||
|
<span>Order {{ field.order }}</span>
|
||||||
|
</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>{{ field.kind }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
|
||||||
<a
|
|
||||||
class="has-text-info"
|
|
||||||
v-tooltip="'Export item'"
|
|
||||||
@click.prevent="exportItem(field)"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="card-content is-flex-grow-1">
|
|
||||||
<div class="content">
|
|
||||||
<p class="is-text-overflow">
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
|
||||||
<span v-text="field.field" />
|
|
||||||
</p>
|
|
||||||
<p class="is-text-overflow" v-if="field.description">
|
|
||||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
|
||||||
<span>{{ field.description }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(field)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(field)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="info"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
square
|
||||||
|
@click="exportItem(field)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="field.description"
|
||||||
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
|
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
<div class="flex items-start gap-2">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<UIcon
|
||||||
Loading data. Please wait...
|
name="i-lucide-message-square-text"
|
||||||
</Message>
|
class="mt-0.5 size-4 shrink-0 text-toned"
|
||||||
<Message
|
/>
|
||||||
|
<span class="wrap-break-word">{{ field.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto grid gap-2 pt-2 sm:grid-cols-2">
|
||||||
|
<UButton
|
||||||
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="editItem(field)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="() => void deleteItem(field)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="query && filteredItems.length < 1" class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
title="No Results"
|
title="No Results"
|
||||||
class="is-warning"
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||||
icon="fas fa-search"
|
/>
|
||||||
v-else-if="query"
|
|
||||||
:useClose="true"
|
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||||
@close="query = ''"
|
Clear filter
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="!filteredItems.length"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-alert"
|
||||||
|
title="No items"
|
||||||
|
description="There are no custom defined fields yet. Click the New Field button to add your first field."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
v-if="editorOpen"
|
||||||
|
:open="editorOpen"
|
||||||
|
:title="modalTitle"
|
||||||
|
:description="modalDescription"
|
||||||
|
: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()"
|
||||||
>
|
>
|
||||||
<p>
|
<template #body>
|
||||||
No results found for the query: <code>{{ query }}</code
|
<DLFieldForm
|
||||||
>.
|
:key="modalKey"
|
||||||
</p>
|
:addInProgress="dlFields.addInProgress.value"
|
||||||
<p>Please try a different search term.</p>
|
:reference="itemRef"
|
||||||
</Message>
|
:item="item as DLField"
|
||||||
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
@cancel="closeEditor()"
|
||||||
There are no custom defined fields yet. Click the
|
@submit="updateItem"
|
||||||
<span class="icon"><i class="fas fa-add" /></span> <strong>New Field</strong> button to
|
/>
|
||||||
add your first field.
|
</template>
|
||||||
</Message>
|
</UModal>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { DLField } from '~/types/dl_fields';
|
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { useDlFields } from '~/composables/useDlFields';
|
import { useDlFields } from '~/composables/useDlFields';
|
||||||
|
import type { DLField } from '~/types/dl_fields';
|
||||||
import type { APIResponse } from '~/types/responses';
|
import type { APIResponse } from '~/types/responses';
|
||||||
import { copyText, encode } from '~/utils';
|
import { copyText, encode } from '~/utils';
|
||||||
|
|
||||||
const box = useConfirm();
|
const box = useConfirm();
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
|
const displayStyle = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
|
||||||
const dlFields = useDlFields();
|
const dlFields = useDlFields();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const items = dlFields.dlFields as Ref<DLField[]>;
|
const items = dlFields.dlFields as Ref<DLField[]>;
|
||||||
const paging = dlFields.pagination;
|
const paging = dlFields.pagination;
|
||||||
|
|
@ -297,42 +327,97 @@ const isLoading = dlFields.isLoading;
|
||||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
||||||
const item = ref<Partial<DLField>>({});
|
const item = ref<Partial<DLField>>({});
|
||||||
const itemRef = ref<number | null | undefined>(null);
|
const itemRef = ref<number | null | undefined>(null);
|
||||||
const toggleForm = ref(false);
|
const editorOpen = ref(false);
|
||||||
const query = ref<string>('');
|
const query = ref('');
|
||||||
const toggleFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const filteredItems = computed<DLField[]>(() => {
|
const filteredItems = computed<DLField[]>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
if (!q) return items.value;
|
if (!normalizedQuery) {
|
||||||
return items.value.filter((entry) => deepIncludes(entry, q, new WeakSet()));
|
return items.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.value.filter((entry) => deepIncludes(entry, normalizedQuery, new WeakSet()));
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadContent = async (pageNumber: number = 1): Promise<void> => {
|
const modalTitle = computed(() => (itemRef.value ? `Edit - ${item.value.name}` : 'Add new field'));
|
||||||
await dlFields.loadDlFields(pageNumber);
|
const modalDescription = computed(
|
||||||
await nextTick();
|
() => 'Custom fields allow you to add new fields to the download form.',
|
||||||
if (dlFields.pagination.value.total_pages > 1) {
|
);
|
||||||
useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } });
|
const modalKey = computed(
|
||||||
}
|
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
||||||
};
|
);
|
||||||
|
|
||||||
watch(toggleFilter, (value) => {
|
watch(showFilter, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = (closeForm = false): void => {
|
const syncPageQuery = async (pageNumber: number): Promise<void> => {
|
||||||
|
const totalPages = dlFields.pagination.value.total_pages;
|
||||||
|
const nextQuery = { ...route.query };
|
||||||
|
|
||||||
|
if (totalPages > 1) {
|
||||||
|
nextQuery.page = String(pageNumber);
|
||||||
|
} else {
|
||||||
|
delete nextQuery.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace({ query: nextQuery });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFilterPanel = async (): Promise<void> => {
|
||||||
|
showFilter.value = !showFilter.value;
|
||||||
|
if (!showFilter.value) {
|
||||||
|
query.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
filterInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadContent = async (pageNumber = 1): Promise<void> => {
|
||||||
|
page.value = pageNumber;
|
||||||
|
await dlFields.loadDlFields(pageNumber);
|
||||||
|
await nextTick();
|
||||||
|
await syncPageQuery(pageNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadContent = async (): Promise<void> => {
|
||||||
|
await loadContent(page.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
|
await loadContent(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetEditor = (): void => {
|
||||||
item.value = {};
|
item.value = {};
|
||||||
itemRef.value = null;
|
itemRef.value = null;
|
||||||
if (closeForm) {
|
};
|
||||||
toggleForm.value = false;
|
|
||||||
}
|
const closeEditor = (): void => {
|
||||||
|
editorOpen.value = false;
|
||||||
|
resetEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
resetEditor();
|
||||||
|
editorOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDisplayStyle = (): void => {
|
||||||
|
displayStyle.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (field: DLField): Promise<void> => {
|
const deleteItem = async (field: DLField): Promise<void> => {
|
||||||
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
|
if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dlFields.deleteDlField(field.id!);
|
await dlFields.deleteDlField(field.id!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -343,33 +428,36 @@ const updateItem = async ({
|
||||||
reference: number | null | undefined;
|
reference: number | null | undefined;
|
||||||
item: DLField;
|
item: DLField;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const cb = (resp: APIResponse) => {
|
const callback = (response: APIResponse) => {
|
||||||
if (resp.success) {
|
if (response.success) {
|
||||||
resetForm(true);
|
closeEditor();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await dlFields.patchDlField(reference, updatedItem, cb);
|
await dlFields.patchDlField(reference, updatedItem, callback);
|
||||||
} else {
|
} else {
|
||||||
await dlFields.createDlField(updatedItem, cb);
|
await dlFields.createDlField(updatedItem, callback);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (field: DLField): void => {
|
const editItem = (field: DLField): void => {
|
||||||
item.value = { ...field };
|
item.value = JSON.parse(JSON.stringify(field)) as DLField;
|
||||||
itemRef.value = field.id;
|
itemRef.value = field.id;
|
||||||
toggleForm.value = true;
|
editorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportItem = (field: DLField): void =>
|
const exportItem = (field: DLField): void => {
|
||||||
copyText(
|
copyText(
|
||||||
encode({
|
encode({
|
||||||
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
|
...Object.fromEntries(
|
||||||
|
Object.entries(field).filter(([key, value]) => !!value && 'id' !== key),
|
||||||
|
),
|
||||||
_type: 'dl_field',
|
_type: 'dl_field',
|
||||||
_version: '1.0',
|
_version: '1.0',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => await loadContent(page.value));
|
onMounted(async () => await loadContent(page.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
71
ui/app/pages/docs/[...slug].vue
Normal file
71
ui/app/pages/docs/[...slug].vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{{ entry.navLabel }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageHeader>
|
||||||
|
|
||||||
|
<UPageCard variant="outline" :ui="pageCardUi">
|
||||||
|
<template #body>
|
||||||
|
<div class="px-4 py-5 sm:px-6 sm:py-6 lg:px-7">
|
||||||
|
<Markdown :file="`/api/docs/${docEntry.file}`" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Markdown from '~/components/Markdown.vue';
|
||||||
|
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const docsEntries = DOCS_ENTRIES;
|
||||||
|
|
||||||
|
const docEntry = computed(() => {
|
||||||
|
const entry = getDocsEntryBySlug(route.params.slug as string | string[] | undefined);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Documentation not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full p-0',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
@ -1,147 +1,162 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="space-y-6">
|
||||||
<div class="mt-1 columns is-multiline">
|
<UPageHeader
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
title="Downloads"
|
||||||
<span class="title is-4" v-if="!isMobile">
|
description="Queued and completed downloads are displayed here."
|
||||||
<span class="icon-text">
|
:ui="{
|
||||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
root: 'border-b border-default py-4',
|
||||||
<span>Downloads</span>
|
headline: 'hidden',
|
||||||
</span>
|
title: 'text-2xl font-semibold text-highlighted',
|
||||||
</span>
|
description: 'text-sm text-toned',
|
||||||
|
wrapper: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
|
||||||
<div class="is-pulled-right">
|
links: 'flex flex-wrap items-center gap-2',
|
||||||
<div class="field is-grouped">
|
}"
|
||||||
<p class="control has-icons-left" v-if="toggleFilter">
|
>
|
||||||
<input
|
<template #links>
|
||||||
type="search"
|
<UDashboardToolbar
|
||||||
v-model.lazy="query"
|
:ui="{
|
||||||
class="input"
|
root: 'w-full border-0 p-0',
|
||||||
|
left: 'flex flex-wrap items-center gap-2',
|
||||||
|
right: 'flex flex-wrap items-center gap-2',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #left>
|
||||||
|
<UInput
|
||||||
|
v-if="toggleFilter"
|
||||||
id="filter"
|
id="filter"
|
||||||
|
v-model.lazy="query"
|
||||||
|
type="search"
|
||||||
placeholder="Filter displayed content"
|
placeholder="Filter displayed content"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
size="sm"
|
||||||
|
class="w-full sm:w-72"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
color="neutral"
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilter = !toggleFilter"
|
||||||
|
>
|
||||||
<span v-if="!isMobile">Filter</span>
|
<span v-if="!isMobile">Filter</span>
|
||||||
</button>
|
</UButton>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button
|
|
||||||
class="button is-warning"
|
|
||||||
@click="pauseDownload"
|
|
||||||
v-if="false === config.paused"
|
v-if="false === config.paused"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-pause"
|
||||||
|
@click="() => void pauseDownload()"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-pause" /></span>
|
|
||||||
<span v-if="!isMobile">Pause</span>
|
<span v-if="!isMobile">Pause</span>
|
||||||
</button>
|
</UButton>
|
||||||
<button
|
|
||||||
class="button is-danger"
|
|
||||||
@click="resumeDownload"
|
|
||||||
v-else
|
|
||||||
v-tooltip.bottom="'Resume downloading.'"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-play" /></span>
|
|
||||||
<span v-if="!isMobile">Resume</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button
|
v-else
|
||||||
class="button is-primary has-tooltip-bottom"
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-play"
|
||||||
|
@click="() => void resumeDownload()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Resume</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-plus"
|
||||||
@click="config.showForm = !config.showForm"
|
@click="config.showForm = !config.showForm"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
|
||||||
<span v-if="!isMobile">Add</span>
|
<span v-if="!isMobile">Add</span>
|
||||||
</button>
|
</UButton>
|
||||||
</p>
|
</template>
|
||||||
|
|
||||||
<p class="control">
|
<template #right>
|
||||||
<button
|
<UButton
|
||||||
v-tooltip.bottom="'Change display style'"
|
color="neutral"
|
||||||
class="button has-tooltip-bottom"
|
variant="outline"
|
||||||
@click="() => changeDisplay()"
|
size="sm"
|
||||||
|
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="changeDisplay"
|
||||||
>
|
>
|
||||||
<span class="icon"
|
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
><i
|
</UButton>
|
||||||
class="fa-solid"
|
</template>
|
||||||
:class="{
|
</UDashboardToolbar>
|
||||||
'fa-table': display_style !== 'list',
|
</template>
|
||||||
'fa-table-list': display_style === 'list',
|
</UPageHeader>
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isMobile">
|
|
||||||
<span class="subtitle"> Queued and completed downloads are displayed here. </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div v-if="config.showForm" class="page-form-wrap">
|
||||||
<NewDownload
|
<NewDownload
|
||||||
v-if="config.showForm"
|
|
||||||
:item="item_form"
|
:item="item_form"
|
||||||
@clear_form="item_form = {}"
|
@clear_form="item_form = {}"
|
||||||
@getInfo="
|
@getInfo="
|
||||||
(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)
|
(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="tabs is-boxed is-medium">
|
|
||||||
<ul>
|
|
||||||
<li :class="{ 'is-active': activeTab === 'queue' }">
|
|
||||||
<a @click="setActiveTab('queue')">
|
|
||||||
<span class="icon is-small"><i class="fas fa-download" /></span>
|
|
||||||
<span>Downloads</span>
|
|
||||||
<span class="tag is-info is-rounded is-bold ml-2">{{ queueCount }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li :class="{ 'is-active': activeTab === 'history' }">
|
|
||||||
<a @click="setActiveTab('history')">
|
|
||||||
<span class="icon is-small"><i class="fas fa-history" /></span>
|
|
||||||
<span>History</span>
|
|
||||||
<span class="tag is-primary is-rounded is-bold ml-2">{{ historyCount }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<UPageCard
|
||||||
<div v-show="'queue' === activeTab">
|
variant="outline"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full min-w-0 max-w-full bg-default',
|
||||||
|
container: 'w-full min-w-0 max-w-full p-0 sm:p-0',
|
||||||
|
wrapper: 'w-full min-w-0 items-stretch',
|
||||||
|
body: 'w-full min-w-0 max-w-full p-0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<UTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
:items="tabItems"
|
||||||
|
value-key="value"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
size="md"
|
||||||
|
:content="false"
|
||||||
|
:ui="{
|
||||||
|
root: 'flex-col gap-4',
|
||||||
|
list: 'border-b border-default px-2 sm:px-4',
|
||||||
|
trigger: 'justify-start rounded-none px-3 py-3 text-sm font-medium',
|
||||||
|
trailingBadge: 'ml-1',
|
||||||
|
}"
|
||||||
|
@update:model-value="(value) => void setActiveTab(value as TabType)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="w-full min-w-0 max-w-full px-0 pt-2">
|
||||||
|
<div v-show="'queue' === activeTab" class="w-full min-w-0 max-w-full">
|
||||||
<Queue
|
<Queue
|
||||||
|
:thumbnails="show_thumbnail"
|
||||||
|
:query="query"
|
||||||
@getInfo="
|
@getInfo="
|
||||||
(url: string, preset: string = '', cli: string = '') =>
|
(url: string, preset: string = '', cli: string = '') =>
|
||||||
view_info(url, false, preset, cli)
|
view_info(url, false, preset, cli)
|
||||||
"
|
"
|
||||||
:thumbnails="show_thumbnail"
|
|
||||||
:query="query"
|
|
||||||
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||||
@clear_search="query = ''"
|
@clear_search="query = ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="'history' === activeTab">
|
<div v-show="'history' === activeTab" class="w-full min-w-0 max-w-full">
|
||||||
<History
|
<History
|
||||||
|
:query="query"
|
||||||
|
:thumbnails="show_thumbnail"
|
||||||
@getInfo="
|
@getInfo="
|
||||||
(url: string, preset: string = '', cli: string = '') =>
|
(url: string, preset: string = '', cli: string = '') =>
|
||||||
view_info(url, false, preset, cli)
|
view_info(url, false, preset, cli)
|
||||||
"
|
"
|
||||||
@add_new="(item: item_request) => toNewDownload(item)"
|
@add_new="(item: item_request) => toNewDownload(item)"
|
||||||
:query="query"
|
|
||||||
:thumbnails="show_thumbnail"
|
|
||||||
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||||
@clear_search="query = ''"
|
@clear_search="query = ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
|
||||||
<GetInfo
|
<GetInfo
|
||||||
v-if="info_view.url"
|
v-if="info_view.url"
|
||||||
|
|
@ -151,21 +166,12 @@
|
||||||
:useUrl="info_view.useUrl"
|
:useUrl="info_view.useUrl"
|
||||||
@closeModel="close_info()"
|
@closeModel="close_info()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-if="dialog_confirm.visible"
|
|
||||||
:visible="dialog_confirm.visible"
|
|
||||||
:title="dialog_confirm.title"
|
|
||||||
:html_message="dialog_confirm.html_message"
|
|
||||||
:options="dialog_confirm.options"
|
|
||||||
@confirm="dialog_confirm.confirm"
|
|
||||||
@cancel="() => (dialog_confirm.visible = false)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import type { item_request } from '~/types/item';
|
import type { item_request } from '~/types/item';
|
||||||
import type { StoreItem } from '~/types/store';
|
import type { StoreItem } from '~/types/store';
|
||||||
|
|
||||||
|
|
@ -173,6 +179,7 @@ const config = useConfigStore();
|
||||||
const stateStore = useStateStore();
|
const stateStore = useStateStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { confirmDialog } = useDialog();
|
||||||
|
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||||
|
|
@ -187,17 +194,9 @@ const info_view = ref({
|
||||||
useUrl: false,
|
useUrl: false,
|
||||||
}) as Ref<{ url: string; preset: string; cli: string; useUrl: boolean }>;
|
}) as Ref<{ url: string; preset: string; cli: string; useUrl: boolean }>;
|
||||||
const item_form = ref<item_request | object>({});
|
const item_form = ref<item_request | object>({});
|
||||||
const query = ref();
|
const query = ref('');
|
||||||
const toggleFilter = ref(false);
|
const toggleFilter = ref(false);
|
||||||
const dialog_confirm = ref({
|
|
||||||
visible: false,
|
|
||||||
title: 'Confirm Action',
|
|
||||||
confirm: () => {},
|
|
||||||
html_message: '',
|
|
||||||
options: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tab management with URL state
|
|
||||||
type TabType = 'queue' | 'history';
|
type TabType = 'queue' | 'history';
|
||||||
|
|
||||||
const activeTab = ref<TabType>('queue');
|
const activeTab = ref<TabType>('queue');
|
||||||
|
|
@ -241,6 +240,25 @@ onMounted(async () => {
|
||||||
const queueCount = computed(() => stateStore.count('queue'));
|
const queueCount = computed(() => stateStore.count('queue'));
|
||||||
const historyCount = computed(() => stateStore.count('history'));
|
const historyCount = computed(() => stateStore.count('history'));
|
||||||
|
|
||||||
|
const tabItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Downloads',
|
||||||
|
icon: 'i-lucide-download',
|
||||||
|
value: 'queue',
|
||||||
|
badge: { label: String(queueCount.value), color: 'info' as const, variant: 'soft' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'History',
|
||||||
|
icon: 'i-lucide-history',
|
||||||
|
value: 'history',
|
||||||
|
badge: {
|
||||||
|
label: String(historyCount.value),
|
||||||
|
color: 'primary' as const,
|
||||||
|
variant: 'soft' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|
@ -276,26 +294,24 @@ watch(
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' });
|
const resumeDownload = async (): Promise<void> => {
|
||||||
|
await request('/api/system/resume', { method: 'POST' });
|
||||||
const pauseDownload = () => {
|
|
||||||
dialog_confirm.value.visible = true;
|
|
||||||
dialog_confirm.value.html_message = `
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
|
|
||||||
<span class="is-bold">Pause All non-active downloads?</span>
|
|
||||||
</span>
|
|
||||||
<br>
|
|
||||||
<span class="has-text-danger">
|
|
||||||
<ul>
|
|
||||||
<li>This will not stop downloads that are currently in progress.</li>
|
|
||||||
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
|
|
||||||
</ul>
|
|
||||||
</span>`;
|
|
||||||
dialog_confirm.value.confirm = async () => {
|
|
||||||
await request('/api/system/pause', { method: 'POST' });
|
|
||||||
dialog_confirm.value.visible = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pauseDownload = async (): Promise<void> => {
|
||||||
|
const { status } = await confirmDialog({
|
||||||
|
title: 'Pause Downloads',
|
||||||
|
confirmText: 'Pause',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
confirmColor: 'warning',
|
||||||
|
message: 'Are you sure you want to pause all non-active downloads?',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await request('/api/system/pause', { method: 'POST' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const close_info = () => {
|
const close_info = () => {
|
||||||
|
|
@ -322,8 +338,9 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeDisplay = () =>
|
const changeDisplay = (): void => {
|
||||||
(display_style.value = display_style.value === 'grid' ? 'list' : 'grid');
|
display_style.value = display_style.value === 'grid' ? 'list' : 'grid';
|
||||||
|
};
|
||||||
|
|
||||||
const toNewDownload = async (item: item_request | Partial<StoreItem>) => {
|
const toNewDownload = async (item: item_request | Partial<StoreItem>) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
|
|
@ -341,3 +358,9 @@ const toNewDownload = async (item: item_request | Partial<StoreItem>) => {
|
||||||
config.showForm = true;
|
config.showForm = true;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-form-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,163 @@
|
||||||
<style scoped>
|
|
||||||
#logView {
|
|
||||||
min-height: 72vh;
|
|
||||||
min-width: inherit;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logView > span:nth-child(even) {
|
|
||||||
color: #ffc9d4;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logView > span:nth-child(odd) {
|
|
||||||
color: #e3c981;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logbox {
|
|
||||||
background-color: #1f2229 !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 8px 0 rgba(0, 0, 0, 0.2),
|
|
||||||
0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
|
||||||
min-width: 100%;
|
|
||||||
max-height: 73vh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background-color: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logline {
|
|
||||||
word-break: break-all;
|
|
||||||
line-height: 1.75rem;
|
|
||||||
padding: 1em;
|
|
||||||
color: #fff1b8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex flex-wrap items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon"><i class="fas fa-file-lines" /></span>
|
<UIcon name="i-lucide-file-text" class="size-5 text-toned" />
|
||||||
Logs
|
<span>Logs</span>
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control">
|
|
||||||
<button v-if="!autoScroll" @click="scrollToBottom(false)" class="button is-primary">
|
|
||||||
<span class="icon"><i class="fas fa-arrow-down"></i></span>
|
|
||||||
<span>Go to Bottom</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control has-icons-left" v-if="toggleFilter || query">
|
<UBadge :color="loading ? 'info' : 'success'" variant="soft" size="sm">
|
||||||
<input
|
{{ loading ? 'Loading history' : 'Live stream' }}
|
||||||
type="search"
|
</UBadge>
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
|
||||||
placeholder="Filter displayed content"
|
|
||||||
/>
|
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control">
|
<UBadge :color="autoScroll ? 'success' : 'neutral'" variant="soft" size="sm">
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
{{ autoScroll ? 'Auto-follow' : 'Manual scroll' }}
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
</UBadge>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="control">
|
<UBadge color="neutral" variant="soft" size="sm">
|
||||||
<button
|
{{ filteredLogs.length }} shown
|
||||||
class="button is-purple"
|
</UBadge>
|
||||||
@click="textWrap = !textWrap"
|
|
||||||
v-tooltip.bottom="'Toggle text wrap'"
|
<UBadge
|
||||||
|
v-if="logs.length !== filteredLogs.length"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-text-width"></i></span>
|
{{ logs.length }} loaded
|
||||||
</button>
|
</UBadge>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<UButton
|
||||||
|
v-if="!autoScroll"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-arrow-down"
|
||||||
|
@click="scrollToBottom(false)"
|
||||||
|
>
|
||||||
|
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'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilter = !toggleFilter"
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<USwitch
|
||||||
|
v-model="textWrap"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
:label="textWrap ? 'Wrap lines' : 'Horizontal scroll'"
|
||||||
|
:ui="{ root: 'items-center gap-2', wrapper: 'ms-0 text-xs text-toned' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UPageCard variant="naked" :ui="pageCardUi">
|
||||||
|
<template #body>
|
||||||
|
<div class="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 ref="logContainer" class="logbox overflow-auto" @scroll.passive="handleScroll">
|
||||||
|
<div
|
||||||
|
class="min-w-full space-y-2 font-mono text-[12px] leading-6 text-default"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div v-if="reachedEnd && !hasActiveFilter" class="flex justify-center">
|
||||||
|
<div
|
||||||
|
class="rounded-full border border-default bg-muted/40 px-3 py-1 text-[11px] text-toned"
|
||||||
|
>
|
||||||
|
No older lines remain in this file.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredLogs.length > 0" class="space-y-1.5">
|
||||||
|
<article
|
||||||
|
v-for="(entry, index) in filteredLogs"
|
||||||
|
:key="entry.log.id"
|
||||||
|
:class="logRowClass(entry, index)"
|
||||||
|
>
|
||||||
|
<span class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
|
||||||
|
{{ logTimeLabel(entry.log.datetime) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
|
||||||
|
{{ entry.log.line }}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<p v-if="hasActiveFilter" class="text-xs text-toned">
|
||||||
|
Try a different term or clear <code>{{ query }}</code
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="is-hidden-mobile">
|
<div ref="bottomMarker" />
|
||||||
<span class="subtitle"
|
|
||||||
>The logs are being streamed in real-time. You can scroll up to view older logs.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12">
|
|
||||||
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
|
||||||
<code
|
|
||||||
id="logView"
|
|
||||||
class="p-2 logline is-block"
|
|
||||||
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="is-block m-0 notification is-info is-dark has-text-centered"
|
|
||||||
v-if="reachedEnd && !query"
|
|
||||||
>
|
|
||||||
<span class="notification-title">
|
|
||||||
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
|
||||||
No more logs available for this file.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span v-for="log in filteredItems" :key="log.id" class="is-block">
|
|
||||||
<template v-if="log?.datetime"
|
|
||||||
>[<span class="has-tooltip" :title="log.datetime">{{
|
|
||||||
moment(log.datetime).format('HH:mm:ss')
|
|
||||||
}}</span
|
|
||||||
>]</template
|
|
||||||
>
|
|
||||||
{{ log.line }}
|
|
||||||
</span>
|
|
||||||
<span class="is-block" v-if="filteredItems.length < 1">
|
|
||||||
<span
|
|
||||||
class="is-block m-0 notification is-warning is-dark has-text-centered"
|
|
||||||
v-if="query"
|
|
||||||
>
|
|
||||||
<span class="notification-title is-danger">
|
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
|
||||||
No logs match this query: <u>{{ query }}</u>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span v-else> <span class="has-text-danger">No logs available</span></span>
|
|
||||||
</span>
|
|
||||||
</code>
|
|
||||||
<div ref="bottomMarker"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</UPageCard>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||||
|
|
@ -140,7 +165,17 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { log_line } from '~/types/logs';
|
import type { log_line } from '~/types/logs';
|
||||||
import { parse_api_error, uri } from '~/utils';
|
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
|
||||||
|
|
||||||
|
type FilteredLogEntry = {
|
||||||
|
log: log_line;
|
||||||
|
isMatch: boolean;
|
||||||
|
isContext: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogTone = 'default' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
const FILTER_CONTEXT_REGEX = /context:(\d+)/;
|
||||||
|
|
||||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
|
@ -151,15 +186,20 @@ const route = useRoute();
|
||||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
|
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
|
||||||
const textWrap = useStorage<boolean>('logs_wrap', true);
|
const textWrap = useStorage<boolean>('logs_wrap', true);
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
|
||||||
const sseController = ref<AbortController | null>(null);
|
const sseController = ref<AbortController | null>(null);
|
||||||
|
|
||||||
const logs = ref<Array<log_line>>([]);
|
const logs = ref<Array<log_line>>([]);
|
||||||
const offset = ref<number>(0);
|
const offset = ref(0);
|
||||||
const loading = ref<boolean>(false);
|
const loading = ref(false);
|
||||||
const autoScroll = ref<boolean>(true);
|
const autoScroll = ref(true);
|
||||||
const reachedEnd = ref<boolean>(false);
|
const reachedEnd = ref(false);
|
||||||
|
|
||||||
|
const pageCardUi = {
|
||||||
|
root: 'w-full bg-transparent',
|
||||||
|
container: 'w-full p-4 sm:p-5',
|
||||||
|
wrapper: 'w-full items-stretch',
|
||||||
|
body: 'w-full',
|
||||||
|
};
|
||||||
|
|
||||||
const query = ref<string>(
|
const query = ref<string>(
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -167,17 +207,28 @@ const query = ref<string>(
|
||||||
if (!filter) {
|
if (!filter) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof filter === 'string') {
|
if (typeof filter === 'string') {
|
||||||
return filter.trim();
|
return filter.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(filter) && filter.length > 0) {
|
if (Array.isArray(filter) && filter.length > 0) {
|
||||||
return filter[0]?.trim() ?? '';
|
return filter[0]?.trim() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleFilter = ref(false);
|
const toggleFilter = ref(Boolean(query.value));
|
||||||
|
|
||||||
|
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
|
||||||
|
const filterContext = computed(() => {
|
||||||
|
const match = normalizedQuery.value.match(FILTER_CONTEXT_REGEX);
|
||||||
|
return match ? parseInt(match[1] ?? '0', 10) : 0;
|
||||||
|
});
|
||||||
|
const searchTerm = computed(() => normalizedQuery.value.replace(FILTER_CONTEXT_REGEX, '').trim());
|
||||||
|
const hasActiveFilter = computed(() => Boolean(searchTerm.value));
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|
@ -187,52 +238,58 @@ watch(toggleFilter, () => {
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.app.file_logging,
|
() => config.app.file_logging,
|
||||||
async (v) => {
|
async (value) => {
|
||||||
if (v) {
|
if (value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigateTo('/');
|
await navigateTo('/');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredLogs = computed<FilteredLogEntry[]>(() => {
|
||||||
const raw = query.value.trim().toLowerCase();
|
if (!hasActiveFilter.value) {
|
||||||
const contextMatch = raw.match(/context:(\d+)/);
|
return logs.value.map((log) => ({ log, isMatch: false, isContext: false }));
|
||||||
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0;
|
|
||||||
const searchTerm = raw.replace(/context:\d+/, '').trim();
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
|
||||||
return logs.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Array<log_line> = [];
|
const result: Array<FilteredLogEntry> = [];
|
||||||
const matchedIndexes = new Set();
|
const visibleIndexes = new Set<number>();
|
||||||
|
const matchedIndexes = new Set<number>();
|
||||||
|
|
||||||
|
logs.value.forEach((log, index) => {
|
||||||
|
if (log.line.toLowerCase().includes(searchTerm.value)) {
|
||||||
|
matchedIndexes.add(index);
|
||||||
|
|
||||||
logs.value.forEach((log, i) => {
|
|
||||||
if (log.line.toLowerCase().includes(searchTerm)) {
|
|
||||||
for (
|
for (
|
||||||
let j = Math.max(0, i - context);
|
let i = Math.max(0, index - filterContext.value);
|
||||||
j <= Math.min(logs.value.length - 1, i + context);
|
i <= Math.min(logs.value.length - 1, index + filterContext.value);
|
||||||
j++
|
i++
|
||||||
) {
|
) {
|
||||||
matchedIndexes.add(j);
|
visibleIndexes.add(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.from(matchedIndexes)
|
Array.from(visibleIndexes)
|
||||||
.sort((a: any, b: any) => a - b)
|
.sort((a, b) => a - b)
|
||||||
.forEach((index: any) => {
|
.forEach((index) => {
|
||||||
result.push(logs.value[index] as log_line);
|
result.push({
|
||||||
|
log: logs.value[index] as log_line,
|
||||||
|
isMatch: matchedIndexes.has(index),
|
||||||
|
isContext: !matchedIndexes.has(index),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const matchCount = computed(() => filteredLogs.value.filter((entry) => entry.isMatch).length);
|
||||||
|
|
||||||
|
const fetchLogs = async (): Promise<void> => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
if (reachedEnd.value || query.value) {
|
if (reachedEnd.value || (hasActiveFilter.value && logs.value.length > 0)) {
|
||||||
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +307,6 @@ const fetchLogs = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = response.logs ?? [];
|
const lines = response.logs ?? [];
|
||||||
|
|
||||||
if (lines.length) {
|
if (lines.length) {
|
||||||
logs.value.unshift(...response.logs);
|
logs.value.unshift(...response.logs);
|
||||||
}
|
}
|
||||||
|
|
@ -268,15 +324,15 @@ const fetchLogs = async () => {
|
||||||
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
|
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch logs:', err);
|
console.error('Failed to fetch logs:', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = (): void => {
|
||||||
if (!logContainer.value || query.value) {
|
if (!logContainer.value || hasActiveFilter.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,21 +355,15 @@ const handleScroll = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToBottom = (fast = false) => {
|
const scrollToBottom = (fast = false): void => {
|
||||||
autoScroll.value = true;
|
autoScroll.value = true;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (bottomMarker.value) {
|
bottomMarker.value?.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
|
||||||
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStreamMessage = (event: EventSourceMessage) => {
|
const handleStreamMessage = (event: EventSourceMessage): void => {
|
||||||
if ('log_lines' !== event.event) {
|
if (event.event !== 'log_lines' || !event.data) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!event.data) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,7 +387,7 @@ const handleStreamMessage = (event: EventSourceMessage) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const startLogStream = async () => {
|
const startLogStream = async (): Promise<void> => {
|
||||||
sseController.value?.abort();
|
sseController.value?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
sseController.value = controller;
|
sseController.value = controller;
|
||||||
|
|
@ -354,6 +404,7 @@ const startLogStream = async () => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = response.statusText || 'Failed to start log stream.';
|
let message = response.statusText || 'Failed to start log stream.';
|
||||||
try {
|
try {
|
||||||
message = await parse_api_error(response.clone().json());
|
message = await parse_api_error(response.clone().json());
|
||||||
|
|
@ -367,6 +418,7 @@ const startLogStream = async () => {
|
||||||
message = response.statusText || 'Failed to start log stream.';
|
message = response.statusText || 'Failed to start log stream.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
},
|
},
|
||||||
onmessage: handleStreamMessage,
|
onmessage: handleStreamMessage,
|
||||||
|
|
@ -374,6 +426,7 @@ const startLogStream = async () => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Log stream error:', error);
|
console.error('Log stream error:', error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -388,21 +441,159 @@ const startLogStream = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logTimeLabel = (value?: string): string =>
|
||||||
|
value ? moment(value).format('HH:mm:ss') : '--:--:--';
|
||||||
|
|
||||||
|
const logTimeTitle = (value?: string): string =>
|
||||||
|
value ? moment(value).format('YYYY-MM-DD HH:mm:ss Z') : 'No timestamp';
|
||||||
|
|
||||||
|
const detectLogTone = (line: string): LogTone => {
|
||||||
|
const normalized = line.toLowerCase();
|
||||||
|
|
||||||
|
if (/error|failed|exception|traceback|fatal/.test(normalized)) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/warn|deprecated|retry/.test(normalized)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/info|started|connected|listening|ready/.test(normalized)) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const logRowClass = (entry: FilteredLogEntry, index: number): string[] => {
|
||||||
|
const classes = ['log-row', `log-row--${detectLogTone(entry.log.line)}`];
|
||||||
|
|
||||||
|
if (entry.isMatch) {
|
||||||
|
classes.push('log-row--match');
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isContext) {
|
||||||
|
classes.push('log-row--context');
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index % 2 === 1) {
|
||||||
|
classes.push('log-row--alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!config.app.file_logging) {
|
||||||
|
await navigateTo('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disableOpacity();
|
||||||
await fetchLogs();
|
await fetchLogs();
|
||||||
await startLogStream();
|
await startLogStream();
|
||||||
if (bg_enable.value) {
|
|
||||||
document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
sseController.value?.abort();
|
sseController.value?.abort();
|
||||||
if (bg_enable.value) {
|
enableOpacity();
|
||||||
document.querySelector('body')?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
scrollTimeout = null;
|
||||||
}
|
}
|
||||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({ title: 'Logs' });
|
useHead({ title: 'Logs' });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logbox {
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 55vh;
|
||||||
|
max-height: 60vh;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--ui-bg);
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--alt {
|
||||||
|
background: var(--ui-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--match {
|
||||||
|
border-color: color-mix(in oklab, var(--ui-warning) 35%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--ui-warning) 12%, var(--ui-bg) 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--context {
|
||||||
|
border-color: color-mix(in oklab, var(--ui-border) 75%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 30%, var(--ui-bg) 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row:hover {
|
||||||
|
border-color: var(--ui-border-accented);
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-elevated) 65%, var(--ui-bg-muted) 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-timestamp {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 4.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--ui-border) 80%, transparent);
|
||||||
|
background: color-mix(in oklab, var(--ui-bg-muted) 50%, var(--ui-bg) 50%);
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-text-toned);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line--wrap {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line--nowrap {
|
||||||
|
min-width: max-content;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--default .log-line {
|
||||||
|
color: var(--ui-text-highlighted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--info .log-line {
|
||||||
|
color: color-mix(in oklab, var(--ui-info) 55%, var(--ui-text-highlighted) 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--warning .log-line {
|
||||||
|
color: color-mix(in oklab, var(--ui-warning) 60%, var(--ui-text-highlighted) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row--error .log-line {
|
||||||
|
color: color-mix(in oklab, var(--ui-error) 70%, var(--ui-text-highlighted) 30%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,219 +1,195 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon-text">
|
<UIcon name="i-lucide-bell" class="size-5 text-toned" />
|
||||||
<template v-if="toggleForm">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }"
|
|
||||||
/></span>
|
|
||||||
<span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p
|
|
||||||
class="control has-icons-left"
|
|
||||||
v-if="toggleFilter && notifications && notifications.length > 0"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
|
||||||
placeholder="Filter displayed content"
|
|
||||||
/>
|
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="notifications && notifications.length > 0">
|
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
|
||||||
<span v-if="!isMobile">Filter</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
@click="
|
|
||||||
resetForm(false);
|
|
||||||
toggleForm = true;
|
|
||||||
"
|
|
||||||
v-tooltip="'Add new notification target.'"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
<span v-if="!isMobile">New Notification</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control" v-if="notifications.length > 0">
|
|
||||||
<button
|
|
||||||
class="button is-warning"
|
|
||||||
@click="sendTest"
|
|
||||||
v-tooltip="'Send test notification.'"
|
|
||||||
:class="{ 'is-loading': sendingTest }"
|
|
||||||
:disabled="!notifications.length || sendingTest"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-paper-plane" /></span>
|
|
||||||
<span v-if="!isMobile">Send Test</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<p class="control" v-if="notifications.length > 0">
|
|
||||||
<button
|
|
||||||
v-tooltip.bottom="'Change display style'"
|
|
||||||
class="button has-tooltip-bottom"
|
|
||||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-table': display_style !== 'list',
|
|
||||||
'fa-table-list': display_style === 'list',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="notifications.length > 0">
|
|
||||||
<button
|
|
||||||
class="button is-info"
|
|
||||||
@click="loadContent(page)"
|
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
:disabled="isLoading || notifications.length < 1"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
<span v-if="!isMobile">Reload</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
<p class="text-sm text-toned">
|
||||||
<span class="subtitle">
|
|
||||||
Send notifications to your webhooks based on specified events or presets.
|
Send notifications to your webhooks based on specified events or presets.
|
||||||
|
</p>
|
||||||
|
</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>
|
</span>
|
||||||
|
<input
|
||||||
|
id="filter"
|
||||||
|
ref="filterInput"
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
placeholder="Filter displayed content"
|
||||||
|
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilterPanel"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Filter</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">New Notification</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-send"
|
||||||
|
:loading="sendingTest"
|
||||||
|
:disabled="sendingTest"
|
||||||
|
@click="() => void sendTest()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Send Test</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="displayStyle === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="toggleDisplayStyle"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">{{ displayStyle === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="() => void reloadContent()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
|
||||||
<Pager
|
<Pager
|
||||||
|
v-if="paging?.total_pages > 1"
|
||||||
:page="paging.page"
|
:page="paging.page"
|
||||||
:last_page="paging.total_pages"
|
:last_page="paging.total_pages"
|
||||||
:isLoading="isLoading"
|
:isLoading="isLoading"
|
||||||
@navigate="
|
@navigate="navigatePage"
|
||||||
async (newPage) => {
|
|
||||||
page = newPage;
|
|
||||||
await loadContent(newPage);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
|
||||||
<NotificationForm
|
|
||||||
:addInProgress="addInProgress"
|
|
||||||
:reference="targetRef"
|
|
||||||
:item="target"
|
|
||||||
@cancel="resetForm(true)"
|
|
||||||
@submit="updateItem"
|
|
||||||
:allowedEvents="allowedEvents"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="displayStyle === 'list' && filteredTargets.length > 0"
|
||||||
v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0"
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
>
|
>
|
||||||
<template v-if="'list' === display_style">
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
<div class="column is-12">
|
<table class="min-w-225 w-full text-sm">
|
||||||
<div class="table-container">
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||||
<table
|
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<th class="w-full text-left">Targets</th>
|
||||||
style="min-width: 850px; table-layout: fixed"
|
<th class="w-[1%]">Actions</th>
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr class="has-text-centered is-unselectable">
|
|
||||||
<th width="80%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
|
||||||
<span>Targets</span>
|
|
||||||
</th>
|
|
||||||
<th width="20%">
|
|
||||||
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in filteredTargets" :key="item.id">
|
<tbody class="divide-y divide-default">
|
||||||
<td class="is-text-overflow is-vcentered">
|
<tr v-for="item in filteredTargets" :key="item.id" class="hover:bg-muted/20">
|
||||||
<div class="is-bold">
|
<td class="px-3 py-3 align-middle">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="min-w-0 text-sm font-semibold text-highlighted">
|
||||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
<a
|
||||||
</div>
|
:href="item.request.url"
|
||||||
<div class="is-unselectable">
|
target="_blank"
|
||||||
<span class="icon-text">
|
rel="noreferrer"
|
||||||
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
class="break-all text-primary hover:underline"
|
||||||
<span>On: {{ join_events(item.on) }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
|
||||||
<span>Presets: {{ join_presets(item.presets) }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
|
||||||
<span
|
|
||||||
class="icon"
|
|
||||||
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
||||||
v-tooltip="
|
|
||||||
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-power-off" />
|
{{ item.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-bell-ring" class="size-3.5" />
|
||||||
|
<span>On: {{ joinEvents(item.on) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-sliders-horizontal" class="size-3.5" />
|
||||||
|
<span>Presets: {{ joinPresets(item.presets) }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span v-if="headerKeys(item).length > 0" class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-key" class="size-3.5" />
|
||||||
|
<span>Headers: {{ headerKeys(item).join(', ') }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<td class="w-[1%] px-3 py-3 align-middle whitespace-nowrap">
|
||||||
<div class="control">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<UButton
|
||||||
class="button is-info is-small is-fullwidth"
|
color="info"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
@click="exportItem(item)"
|
@click="exportItem(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="warning"
|
||||||
class="button is-warning is-small is-fullwidth"
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
@click="editItem(item)"
|
@click="editItem(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="error"
|
||||||
class="button is-danger is-small is-fullwidth"
|
variant="outline"
|
||||||
@click="deleteItem(item)"
|
size="xs"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="() => void deleteItem(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -221,154 +197,211 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template v-else>
|
<div v-else-if="filteredTargets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="column is-6 is-12-mobile" v-for="item in filteredTargets" :key="item.id">
|
<UCard
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
v-for="item in filteredTargets"
|
||||||
<header class="card-header">
|
:key="item.id"
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
class="flex h-full flex-col border bg-default"
|
||||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<div class="card-header-icon">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control" @click="toggleEnabled(item)">
|
|
||||||
<span
|
|
||||||
class="icon"
|
|
||||||
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
||||||
v-tooltip="
|
|
||||||
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-power-off" />
|
<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>
|
</span>
|
||||||
</div>
|
@
|
||||||
<div class="control">
|
|
||||||
<a
|
<a
|
||||||
class="has-text-info"
|
:href="item.request.url"
|
||||||
v-tooltip="'Export target.'"
|
target="_blank"
|
||||||
@click.prevent="exportItem(item)"
|
rel="noreferrer"
|
||||||
|
class="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
{{ item.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||||
</div>
|
<button
|
||||||
</header>
|
type="button"
|
||||||
<div class="card-content is-flex-grow-1">
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||||
<div class="content">
|
:disabled="addInProgress"
|
||||||
<p>
|
@click="() => void toggleEnabled(item)"
|
||||||
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
>
|
||||||
<span>On: {{ join_events(item.on) }}</span>
|
<UIcon
|
||||||
</p>
|
name="i-lucide-power"
|
||||||
<p>
|
class="size-3.5"
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
:class="item.enabled !== false ? 'text-success' : 'text-error'"
|
||||||
<span>Presets: {{ join_presets(item.presets) }}</span>
|
/>
|
||||||
</p>
|
<span>{{ item.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
|
||||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
|
||||||
<span>Headers: {{ item.request.headers.map((h) => h.key).join(', ') }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<UButton
|
||||||
|
color="info"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
square
|
||||||
|
@click="exportItem(item)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="headerKeys(item).length > 0"
|
||||||
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"
|
class="rounded-md border border-default bg-muted/20 px-3 py-2"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
<div class="flex items-start gap-2">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<UIcon name="i-lucide-key" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||||
Loading data. Please wait...
|
<span class="wrap-break-word">Headers: {{ headerKeys(item).join(', ') }}</span>
|
||||||
</Message>
|
</div>
|
||||||
<Message
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto grid gap-2 pt-2 sm:grid-cols-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="query && filteredTargets.length < 1" class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
title="No Results"
|
title="No Results"
|
||||||
class="is-warning"
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||||
icon="fas fa-search"
|
/>
|
||||||
v-else-if="query"
|
|
||||||
:useClose="true"
|
<UButton color="neutral" variant="outline" size="sm" @click="query = ''">
|
||||||
@close="query = ''"
|
Clear filter
|
||||||
>
|
</UButton>
|
||||||
<p>
|
|
||||||
No results found for the query: <code>{{ query }}</code
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p>Please try a different search term.</p>
|
|
||||||
</Message>
|
|
||||||
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
|
|
||||||
No notification targets found. Click on the
|
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
<strong>New Notification</strong> button to add your first notification target.
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-else-if="!filteredTargets.length"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-alert"
|
||||||
|
title="No targets"
|
||||||
|
description="No notification targets found. Click on the New Notification button to add your first notification target."
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="!query && filteredTargets.length > 0"
|
||||||
v-if="!toggleForm && filteredTargets && filteredTargets.length > 0"
|
class="rounded-lg border border-info/30 bg-info/10 p-4 text-sm text-default"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
<ul class="list-disc space-y-2 pl-5 text-sm text-default">
|
||||||
<Message class="is-info" :body_class="'pl-0'">
|
|
||||||
<ul>
|
|
||||||
<li>
|
<li>
|
||||||
When you export notification target, We remove <code>Authorization</code> header key
|
When you export notification target, We remove <code>Authorization</code> header key by
|
||||||
by default, However this might not be enough to remove credentials from the exported
|
default, However this might not be enough to remove credentials from the exported data.
|
||||||
data. it's your responsibility to ensure that the exported data does not contain any
|
it's your responsibility to ensure that the exported data does not contain any sensitive
|
||||||
sensitive information for sharing.
|
information for sharing.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
When you set the request type as <code>Form</code>, the event data will be JSON
|
When you set the request type as <code>Form</code>, the event data will be JSON encoded
|
||||||
encoded and sent as <code>...&data_key=json_string</code>, only the
|
and sent as <code>...&data_key=json_string</code>, only the <code>data</code> field
|
||||||
<code>data</code> field will be JSON encoded. The other keys <code>id</code>,
|
will be JSON encoded. The other keys <code>id</code>, <code>event</code> and
|
||||||
<code>event</code> and <code>created_at</code> will be sent as they are.
|
<code>created_at</code> will be sent as they are.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with
|
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the
|
||||||
the request.
|
request.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If you have selected specific presets or events, this will take priority, For example,
|
If you have selected specific presets or events, this will take priority, For example, if
|
||||||
if you limited the target to <code>default</code> preset and selected
|
you limited the target to <code>default</code> preset and selected
|
||||||
<code>ALL</code> events, only events that reference the <code>default</code> preset
|
<code>ALL</code> events, only events that reference the <code>default</code> preset will
|
||||||
will be sent to that target. Like wise, if you have limited both events and presets,
|
be sent to that target. Like wise, if you have limited both events and presets, then ONLY
|
||||||
then ONLY events that satisfy both conditions will be sent to that target. Only the
|
events that satisfy both conditions will be sent to that target. Only the
|
||||||
<code>test</code> events can bypass these conditions.
|
<code>test</code> events can bypass these conditions.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
v-if="editorOpen"
|
||||||
|
:open="editorOpen"
|
||||||
|
:title="modalTitle"
|
||||||
|
:description="modalDescription"
|
||||||
|
: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()"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<NotificationForm
|
||||||
|
:key="modalKey"
|
||||||
|
:addInProgress="addInProgress"
|
||||||
|
:reference="targetRef"
|
||||||
|
:item="target"
|
||||||
|
:allowedEvents="allowedEvents"
|
||||||
|
@cancel="closeEditor()"
|
||||||
|
@submit="updateItem"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { notification } from '~/types/notification';
|
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { useNotifications } from '~/composables/useNotifications';
|
import { useNotifications } from '~/composables/useNotifications';
|
||||||
|
import { copyText, encode, parse_api_error, request, ucFirst } from '~/utils';
|
||||||
import type { ImportedItem } from '~/types';
|
import type { ImportedItem } from '~/types';
|
||||||
|
import type { notification } from '~/types/notification';
|
||||||
|
|
||||||
const toast = useNotification();
|
const toast = useNotification();
|
||||||
const box = useConfirm();
|
const box = useConfirm();
|
||||||
const display_style = useStorage<string>('notification_display_style', 'cards');
|
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
const displayStyleState = useStorage<'list' | 'grid' | 'cards'>(
|
||||||
|
'notification_display_style',
|
||||||
|
'cards',
|
||||||
|
);
|
||||||
|
|
||||||
const notificationsStore = useNotifications();
|
const notificationsStore = useNotifications();
|
||||||
const notifications = notificationsStore.notifications;
|
const notifications = notificationsStore.notifications;
|
||||||
|
|
@ -380,47 +413,100 @@ const lastError = notificationsStore.lastError;
|
||||||
|
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const targetRef = ref<number | undefined>(undefined);
|
const targetRef = ref<number | undefined>(undefined);
|
||||||
const toggleForm = ref(false);
|
const target = ref<notification>(defaultState());
|
||||||
|
const editorOpen = ref(false);
|
||||||
const sendingTest = ref(false);
|
const sendingTest = ref(false);
|
||||||
|
const query = ref('');
|
||||||
|
const showFilter = ref(false);
|
||||||
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const defaultState = (): notification => ({
|
const displayStyle = computed<'list' | 'grid'>(() =>
|
||||||
|
displayStyleState.value === 'list' ? 'list' : 'grid',
|
||||||
|
);
|
||||||
|
|
||||||
|
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 filteredTargets = computed<notification[]>(() => {
|
||||||
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
|
const items = notifications.value as notification[];
|
||||||
|
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(showFilter, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
query.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function defaultState(): notification {
|
||||||
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
on: [],
|
on: [],
|
||||||
presets: [],
|
presets: [],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
||||||
});
|
};
|
||||||
|
|
||||||
const target = ref<notification>(defaultState());
|
|
||||||
const query = ref<string>('');
|
|
||||||
const toggleFilter = ref(false);
|
|
||||||
|
|
||||||
const filteredTargets = computed<notification[]>(() => {
|
|
||||||
const q = query.value?.toLowerCase();
|
|
||||||
const items = notifications.value.map((item) => ({ ...item })) as notification[];
|
|
||||||
if (!q) return items;
|
|
||||||
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()));
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(toggleFilter, (val) => {
|
|
||||||
if (!val) {
|
|
||||||
query.value = '';
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const loadContent = async (pageNumber = page.value) => {
|
const toggleFilterPanel = async (): Promise<void> => {
|
||||||
|
showFilter.value = !showFilter.value;
|
||||||
|
if (!showFilter.value) {
|
||||||
|
query.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
filterInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadContent = async (pageNumber = page.value): Promise<void> => {
|
||||||
|
page.value = pageNumber;
|
||||||
await notificationsStore.loadNotifications(pageNumber);
|
await notificationsStore.loadNotifications(pageNumber);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = (closeForm = false) => {
|
const reloadContent = async (): Promise<void> => {
|
||||||
target.value = defaultState();
|
await loadContent(page.value);
|
||||||
targetRef.value = undefined;
|
|
||||||
if (closeForm) {
|
|
||||||
toggleForm.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteItem = async (item: notification) => {
|
const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
|
await loadContent(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetEditor = (): void => {
|
||||||
|
target.value = defaultState();
|
||||||
|
targetRef.value = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = (): void => {
|
||||||
|
editorOpen.value = false;
|
||||||
|
resetEditor();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
resetEditor();
|
||||||
|
editorOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item: notification): void => {
|
||||||
|
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
||||||
|
targetRef.value = item.id ?? undefined;
|
||||||
|
editorOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = async (item: notification): Promise<void> => {
|
||||||
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +519,7 @@ const deleteItem = async (item: notification) => {
|
||||||
await notificationsStore.deleteNotification(item.id);
|
await notificationsStore.deleteNotification(item.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleEnabled = async (item: notification) => {
|
const toggleEnabled = async (item: notification): Promise<void> => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Notification target not found.');
|
toast.error('Notification target not found.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -448,7 +534,7 @@ const updateItem = async ({
|
||||||
}: {
|
}: {
|
||||||
reference: number | undefined;
|
reference: number | undefined;
|
||||||
item: notification;
|
item: notification;
|
||||||
}) => {
|
}): Promise<void> => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await notificationsStore.updateNotification(reference, item);
|
await notificationsStore.updateNotification(reference, item);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -456,22 +542,24 @@ const updateItem = async ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lastError.value) {
|
if (!lastError.value) {
|
||||||
resetForm(true);
|
closeEditor();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (item: notification) => {
|
const toggleDisplayStyle = (): void => {
|
||||||
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
displayStyleState.value = displayStyle.value === 'list' ? 'grid' : 'list';
|
||||||
targetRef.value = item.id ?? undefined;
|
|
||||||
toggleForm.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const join_events = (events: Array<string>) =>
|
const joinEvents = (events: string[]): string =>
|
||||||
!events || events.length < 1 ? 'ALL' : events.map((e) => ucFirst(e)).join(', ');
|
!events || events.length < 1 ? 'ALL' : events.map((event) => ucFirst(event)).join(', ');
|
||||||
const join_presets = (presets: Array<string>) =>
|
|
||||||
!presets || presets.length < 1 ? 'ALL' : presets.map((e) => ucFirst(e)).join(', ');
|
|
||||||
|
|
||||||
const sendTest = async () => {
|
const joinPresets = (presets: string[]): string =>
|
||||||
|
!presets || presets.length < 1 ? 'ALL' : presets.map((preset) => ucFirst(preset)).join(', ');
|
||||||
|
|
||||||
|
const headerKeys = (item: notification): string[] =>
|
||||||
|
item.request?.headers?.map((header) => header.key).filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const sendTest = async (): Promise<void> => {
|
||||||
if (true !== (await box.confirm('Send test notification?'))) {
|
if (true !== (await box.confirm('Send test notification?'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -489,7 +577,6 @@ const sendTest = async () => {
|
||||||
|
|
||||||
toast.success('Test notification sent.');
|
toast.success('Test notification sent.');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
|
||||||
const message = error?.message || 'Unknown error';
|
const message = error?.message || 'Unknown error';
|
||||||
toast.error(`Failed to send test notification. ${message}`);
|
toast.error(`Failed to send test notification. ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -497,9 +584,7 @@ const sendTest = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => await notificationsStore.loadNotifications(page.value));
|
const exportItem = async (item: notification): Promise<void> => {
|
||||||
|
|
||||||
const exportItem = async (item: notification) => {
|
|
||||||
const data: notification & ImportedItem = {
|
const data: notification & ImportedItem = {
|
||||||
...JSON.parse(JSON.stringify(item)),
|
...JSON.parse(JSON.stringify(item)),
|
||||||
_type: 'notification',
|
_type: 'notification',
|
||||||
|
|
@ -507,19 +592,21 @@ const exportItem = async (item: notification) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const keys = ['id', 'raw'];
|
const keys = ['id', 'raw'];
|
||||||
keys.forEach((k) => {
|
keys.forEach((key) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||||
const { [k]: _, ...rest } = data as any;
|
const { [key]: _, ...rest } = data as Record<string, unknown>;
|
||||||
Object.assign(data, rest);
|
Object.assign(data, rest);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.request?.headers?.length) {
|
if (data.request?.headers?.length) {
|
||||||
data.request.headers = data.request.headers.filter(
|
data.request.headers = data.request.headers.filter(
|
||||||
(h) => 'authorization' !== h.key.toLowerCase(),
|
(header) => 'authorization' !== header.key.toLowerCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyText(encode(data));
|
copyText(encode(data));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(async () => await loadContent(page.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,179 +1,150 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon-text">
|
<UIcon name="i-lucide-sliders-horizontal" class="size-5 text-toned" />
|
||||||
<template v-if="toggleForm">
|
|
||||||
<span class="icon"
|
|
||||||
><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }"
|
|
||||||
/></span>
|
|
||||||
<span>{{ presetRef ? `Edit - ${prettyName(preset.name || '')}` : 'Add' }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
|
||||||
<span>Presets</span>
|
<span>Presets</span>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</span>
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
|
||||||
v-model.lazy="query"
|
|
||||||
class="input"
|
|
||||||
id="filter"
|
id="filter"
|
||||||
|
ref="filterInput"
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
placeholder="Filter displayed content"
|
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"
|
||||||
/>
|
/>
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control" v-if="presets && presets.length > 0">
|
<UButton
|
||||||
<button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
|
v-if="presetsNoDefault.length > 0"
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
color="neutral"
|
||||||
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilterPanel"
|
||||||
|
>
|
||||||
<span v-if="!isMobile">Filter</span>
|
<span v-if="!isMobile">Filter</span>
|
||||||
</button>
|
</UButton>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button
|
color="neutral"
|
||||||
class="button is-primary"
|
variant="outline"
|
||||||
@click="
|
size="sm"
|
||||||
resetForm(false);
|
icon="i-lucide-plus"
|
||||||
toggleForm = !toggleForm;
|
@click="editor.openCreate()"
|
||||||
"
|
|
||||||
v-tooltip.bottom="'Toggle add form'"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
|
||||||
<span v-if="!isMobile">New Preset</span>
|
<span v-if="!isMobile">New Preset</span>
|
||||||
</button>
|
</UButton>
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button
|
color="neutral"
|
||||||
v-tooltip.bottom="'Change display style'"
|
variant="outline"
|
||||||
class="button has-tooltip-bottom"
|
size="sm"
|
||||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="toggleDisplayStyle"
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
<i
|
</UButton>
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-table': display_style !== 'list',
|
|
||||||
'fa-table-list': display_style === 'list',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
<UButton
|
||||||
<button
|
v-if="presets.length > 0"
|
||||||
class="button is-info"
|
color="neutral"
|
||||||
@click="reloadContent()"
|
variant="outline"
|
||||||
:class="{ 'is-loading': isLoading }"
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
v-if="presets && presets.length > 0"
|
@click="() => void reloadContent()"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</UButton>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
|
||||||
<span class="subtitle"
|
|
||||||
>Presets are pre-defined command options for yt-dlp that you want to apply to given
|
|
||||||
download.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns" v-if="toggleForm">
|
|
||||||
<div class="column is-12">
|
|
||||||
<PresetForm
|
|
||||||
:addInProgress="addInProgress"
|
|
||||||
:reference="presetRef"
|
|
||||||
:preset="preset"
|
|
||||||
@cancel="resetForm(true)"
|
|
||||||
@submit="updateItem"
|
|
||||||
:presets="presets"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!toggleForm">
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-if="display_style === 'list' && filteredPresets.length > 0"
|
||||||
v-if="!isLoading && presetsNoDefault && presetsNoDefault.length > 0"
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
>
|
>
|
||||||
<template v-if="'list' === display_style">
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
<div class="column is-12">
|
<table class="min-w-190 w-full text-sm">
|
||||||
<div class="table-container">
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||||
<table
|
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<th class="w-full text-left">Preset</th>
|
||||||
style="min-width: 650px; table-layout: fixed"
|
<th class="w-[1%]">Actions</th>
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr class="has-text-centered is-unselectable">
|
|
||||||
<th width="80%">Preset</th>
|
|
||||||
<th width="20%">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
|
||||||
<tr v-for="item in filteredPresets" :key="item.id">
|
<tbody class="divide-y divide-default">
|
||||||
<td class="is-text-overflow is-vcentered">
|
<tr v-for="item in filteredPresets" :key="item.id" class="hover:bg-muted/20">
|
||||||
<div class="is-text-overflow is-bold">
|
<td class="px-3 py-3 align-middle">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-semibold text-highlighted">
|
||||||
{{ prettyName(item.name) }}
|
{{ prettyName(item.name) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="is-unselectable">
|
|
||||||
<span class="icon-text" :class="{ 'has-text-primary': item.cookies }">
|
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<span
|
||||||
|
class="inline-flex items-center gap-1"
|
||||||
|
:class="item.cookies ? 'text-info' : ''"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-cookie" class="size-3.5" />
|
||||||
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
|
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<template v-if="item.priority > 0">
|
<span v-if="item.priority > 0" class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
|
||||||
<span>Priority: {{ item.priority }}</span>
|
<span>Priority: {{ item.priority }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<td class="w-[1%] px-3 py-3 align-middle whitespace-nowrap">
|
||||||
<div class="control">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<UButton
|
||||||
class="button is-info is-small is-fullwidth"
|
color="info"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
@click="exportItem(item)"
|
@click="exportItem(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="warning"
|
||||||
class="button is-warning is-small is-fullwidth"
|
variant="outline"
|
||||||
@click="editItem(item)"
|
size="xs"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
@click="editor.openEdit(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="error"
|
||||||
class="button is-danger is-small is-fullwidth"
|
variant="outline"
|
||||||
@click="deleteItem(item)"
|
size="xs"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
@click="() => void deleteItem(item)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -181,168 +152,189 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
<div v-else-if="filteredPresets.length > 0" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="column is-6" v-for="item in filteredPresets" :key="item.id">
|
<UCard
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
v-for="item in filteredPresets"
|
||||||
<header class="card-header">
|
:key="item.id"
|
||||||
<div
|
class="flex h-full flex-col border bg-default"
|
||||||
class="card-header-title is-block is-clickable"
|
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
||||||
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }"
|
>
|
||||||
|
<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')"
|
@click="toggleExpand(item.id, 'title')"
|
||||||
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'"
|
>
|
||||||
v-text="prettyName(item.name)"
|
<span
|
||||||
|
:class="
|
||||||
|
!isExpanded(item.id, 'title') ? 'block truncate' : 'block whitespace-pre-wrap'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ prettyName(item.name) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="info"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
square
|
||||||
|
@click="exportItem(item)"
|
||||||
/>
|
/>
|
||||||
<div class="card-header-icon">
|
</div>
|
||||||
<div class="field is-grouped">
|
|
||||||
<div class="control" v-if="item.priority > 0">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||||
<span class="tag is-dark">
|
<span
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||||
<span v-text="item.priority" />
|
: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>
|
||||||
|
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control" v-if="item.cookies" v-tooltip="'This preset has cookies'">
|
</template>
|
||||||
<span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span>
|
|
||||||
</div>
|
<div class="space-y-2 text-sm text-default">
|
||||||
<div class="control">
|
|
||||||
<button
|
<button
|
||||||
class="has-text-info"
|
|
||||||
v-tooltip="'Export preset'"
|
|
||||||
@click="exportItem(item)"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="card-content is-flex-grow-1">
|
|
||||||
<div class="content">
|
|
||||||
<template v-if="item.priority > 0">
|
|
||||||
<p>
|
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
|
||||||
<span>Priority: {{ item.priority }}</span>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<p
|
|
||||||
:class="{
|
|
||||||
'is-text-overflow': !isExpanded(item.id, 'folder'),
|
|
||||||
'is-clickable': true,
|
|
||||||
}"
|
|
||||||
v-if="item.folder"
|
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')"
|
@click="toggleExpand(item.id, 'folder')"
|
||||||
:title="
|
|
||||||
!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
<UIcon name="i-lucide-folder-output" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||||
<span>{{ calcPath(item.folder) }}</span>
|
<span :class="expandClass(item.id, 'folder')">{{ calcPath(item.folder) }}</span>
|
||||||
</p>
|
</button>
|
||||||
<p
|
|
||||||
:class="{
|
<button
|
||||||
'is-text-overflow': !isExpanded(item.id, 'template'),
|
|
||||||
'is-clickable': true,
|
|
||||||
}"
|
|
||||||
v-if="item.template"
|
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')"
|
@click="toggleExpand(item.id, 'template')"
|
||||||
:title="
|
|
||||||
!isExpanded(item.id, 'template') ? 'Click to expand' : 'Click to collapse'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file" /></span>
|
<UIcon name="i-lucide-file-code-2" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||||
<span>{{ item.template }}</span>
|
<span :class="expandClass(item.id, 'template')">{{ item.template }}</span>
|
||||||
</p>
|
</button>
|
||||||
<p
|
|
||||||
:class="{
|
<button
|
||||||
'is-text-overflow': !isExpanded(item.id, 'cli'),
|
|
||||||
'is-clickable': true,
|
|
||||||
}"
|
|
||||||
v-if="item.cli"
|
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')"
|
@click="toggleExpand(item.id, 'cli')"
|
||||||
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<UIcon name="i-lucide-terminal" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||||
<span>{{ item.cli }}</span>
|
<span :class="expandClass(item.id, 'cli')">{{ item.cli }}</span>
|
||||||
</p>
|
</button>
|
||||||
<p
|
|
||||||
:class="{
|
<button
|
||||||
'is-text-overflow': !isExpanded(item.id, 'description'),
|
|
||||||
'is-clickable': true,
|
|
||||||
}"
|
|
||||||
v-if="item.description"
|
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')"
|
@click="toggleExpand(item.id, 'description')"
|
||||||
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"
|
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-d" /></span>
|
<UIcon name="i-lucide-align-left" class="mt-0.5 size-4 shrink-0 text-toned" />
|
||||||
<span>{{ item.description }}</span>
|
<span :class="expandClass(item.id, 'description')">{{ item.description }}</span>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="card-content content m-1 p-1 is-overflow-auto"
|
|
||||||
style="max-height: 300px"
|
|
||||||
v-if="item?.toggle_description"
|
|
||||||
>
|
|
||||||
<div class="is-pre-wrap">{{ item.description }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div class="mt-auto grid gap-2 pt-2 sm:grid-cols-2">
|
||||||
class="columns is-multiline"
|
<UButton
|
||||||
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)"
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="editor.openEdit(item)"
|
||||||
>
|
>
|
||||||
<div class="column is-12">
|
Edit
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
</UButton>
|
||||||
Loading data. Please wait...
|
|
||||||
</Message>
|
<UButton
|
||||||
<Message
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="() => void deleteItem(item)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="query && filteredPresets.length < 1" class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
title="No Results"
|
title="No Results"
|
||||||
class="is-warning"
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
||||||
icon="fas fa-search"
|
/>
|
||||||
v-else-if="query"
|
|
||||||
:useClose="true"
|
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
|
||||||
@close="query = ''"
|
>Clear filter</UButton
|
||||||
>
|
>
|
||||||
<p>
|
|
||||||
No results found for the query: <code>{{ query }}</code
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p>Please try a different search term.</p>
|
|
||||||
</Message>
|
|
||||||
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
|
|
||||||
There are no custom defined presets.
|
|
||||||
</Message>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!query && presets && presets.length > 0">
|
<UAlert
|
||||||
<div class="column is-12">
|
v-else-if="!filteredPresets.length"
|
||||||
<Message class="is-info">
|
color="warning"
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
variant="soft"
|
||||||
When you <b>export</b> preset, it doesn't include the <code>cookies</code> field
|
icon="i-lucide-circle-alert"
|
||||||
contents for security reasons.
|
title="No presets"
|
||||||
</Message>
|
description="There are no custom defined presets."
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
<UAlert v-if="!query && presets.length > 0" color="info" variant="soft">
|
||||||
|
<template #description>
|
||||||
|
<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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
v-if="editor.isOpen.value"
|
||||||
|
:open="editor.isOpen.value"
|
||||||
|
:title="editor.modalTitle.value"
|
||||||
|
: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()"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<PresetForm
|
||||||
|
:key="editor.modalKey.value"
|
||||||
|
:addInProgress="editor.addInProgress.value"
|
||||||
|
:reference="editor.reference.value"
|
||||||
|
:preset="editor.preset.value"
|
||||||
|
:presets="presets"
|
||||||
|
@cancel="() => void editor.requestClose()"
|
||||||
|
@submit="editor.submit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -350,7 +342,6 @@
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { Preset } from '~/types/presets';
|
import type { Preset } from '~/types/presets';
|
||||||
import { useConfirm } from '~/composables/useConfirm';
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { usePresets } from '~/composables/usePresets';
|
|
||||||
import { prettyName } from '~/utils';
|
import { prettyName } from '~/utils';
|
||||||
|
|
||||||
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
||||||
|
|
@ -358,72 +349,54 @@ type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
||||||
const presetsStore = usePresets();
|
const presetsStore = usePresets();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
const box = useConfirm();
|
const box = useConfirm();
|
||||||
|
const editor = usePresetEditor();
|
||||||
|
|
||||||
const display_style = useStorage<string>('preset_display_style', 'cards');
|
const display_style = useStorage<string>('preset_display_style', 'grid');
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
const query = ref<string>('');
|
const query = ref('');
|
||||||
const toggleFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const presets = presetsStore.presets as Ref<PresetWithUI[]>;
|
const presets = computed(() => presetsStore.presets.value as PresetWithUI[]);
|
||||||
const preset = ref<Partial<Preset>>({});
|
|
||||||
const presetRef = ref<number | null>(null);
|
|
||||||
const toggleForm = ref(false);
|
|
||||||
const isLoading = presetsStore.isLoading;
|
const isLoading = presetsStore.isLoading;
|
||||||
const addInProgress = presetsStore.addInProgress;
|
|
||||||
const remove_keys = ['raw', 'toggle_description'];
|
|
||||||
const expandedItems = ref<Record<string, Set<string>>>({});
|
const expandedItems = ref<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default));
|
const presetsNoDefault = computed(() => presets.value.filter((item) => !item.default));
|
||||||
|
|
||||||
const filteredPresets = computed<PresetWithUI[]>(() => {
|
const filteredPresets = computed<PresetWithUI[]>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
if (!q) return presetsNoDefault.value;
|
if (!normalizedQuery) {
|
||||||
return presetsNoDefault.value.filter((item: PresetWithUI) =>
|
return presetsNoDefault.value;
|
||||||
deepIncludes(item, q, new WeakSet()),
|
}
|
||||||
|
|
||||||
|
return presetsNoDefault.value.filter((item) =>
|
||||||
|
deepIncludes(item, normalizedQuery, new WeakSet()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpand = (itemId: number | string | undefined, field: string) => {
|
watch(showFilter, (value) => {
|
||||||
if (itemId === undefined || itemId === null) return;
|
if (!value) {
|
||||||
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;
|
|
||||||
const key = String(itemId);
|
|
||||||
return expandedItems.value[key]?.has(field) ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(toggleFilter, (val) => {
|
|
||||||
if (!val) {
|
|
||||||
query.value = '';
|
query.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const reloadContent = async () => {
|
const toggleFilterPanel = async (): Promise<void> => {
|
||||||
|
showFilter.value = !showFilter.value;
|
||||||
|
if (!showFilter.value) {
|
||||||
|
query.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
filterInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadContent = async (): Promise<void> => {
|
||||||
await presetsStore.loadPresets(1, 1000);
|
await presetsStore.loadPresets(1, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = (closeForm = false) => {
|
const deleteItem = async (item: Preset): Promise<void> => {
|
||||||
preset.value = {};
|
|
||||||
presetRef.value = null;
|
|
||||||
if (closeForm) {
|
|
||||||
toggleForm.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteItem = async (item: Preset) => {
|
|
||||||
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
|
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -433,44 +406,40 @@ const deleteItem = async (item: Preset) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateItem = async ({
|
const toggleDisplayStyle = (): void => {
|
||||||
reference,
|
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
||||||
preset: item,
|
};
|
||||||
}: {
|
|
||||||
reference: number | null;
|
const toggleExpand = (itemId: number | string | undefined, field: string): void => {
|
||||||
preset: Preset;
|
if (itemId === undefined || itemId === null) {
|
||||||
}) => {
|
return;
|
||||||
item = cleanObject(item, remove_keys) as Preset;
|
|
||||||
if (reference) {
|
|
||||||
const updated = await presetsStore.updatePreset(reference, item);
|
|
||||||
if (updated) {
|
|
||||||
resetForm(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
const created = await presetsStore.createPreset(item);
|
expandedItems.value[key]?.add(field);
|
||||||
if (created) {
|
|
||||||
resetForm(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterItem = (item: Preset) => {
|
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
|
||||||
const rest = cleanObject(item, remove_keys);
|
if (itemId === undefined || itemId === null) {
|
||||||
if ('default' in rest) {
|
return false;
|
||||||
delete rest.default;
|
|
||||||
}
|
}
|
||||||
return JSON.stringify(rest, null, 2);
|
|
||||||
|
return expandedItems.value[String(itemId)]?.has(field) ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (item: Preset) => {
|
const expandClass = (itemId: number | string | undefined, field: string): string => {
|
||||||
preset.value = JSON.parse(filterItem(item));
|
return isExpanded(itemId, field) ? 'whitespace-pre-wrap break-words' : 'truncate';
|
||||||
presetRef.value = item.id ?? null;
|
|
||||||
toggleForm.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => await reloadContent());
|
const exportItem = (item: Preset): void => {
|
||||||
|
|
||||||
const exportItem = (item: Preset) => {
|
|
||||||
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
|
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
|
||||||
const userData = Object.fromEntries(
|
const userData = Object.fromEntries(
|
||||||
Object.entries(JSON.parse(JSON.stringify(item))).filter(
|
Object.entries(JSON.parse(JSON.stringify(item))).filter(
|
||||||
|
|
@ -485,7 +454,9 @@ const exportItem = (item: Preset) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const calcPath = (path?: string): string => {
|
const calcPath = (path?: string): string => {
|
||||||
const loc = config.app.download_path || '/downloads';
|
const location = config.app.download_path || '/downloads';
|
||||||
return path ? loc + '/' + sTrim(path, '/') : loc;
|
return path ? location + '/' + sTrim(path, '/') : location;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(async () => await reloadContent());
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,161 +1,194 @@
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
||||||
<div class="mt-1 columns is-multiline">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div class="column is-12 is-clearfix is-unselectable">
|
<div class="min-w-0 space-y-1">
|
||||||
<span class="title is-4">
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
||||||
<span class="icon-text">
|
<UIcon name="i-lucide-workflow" class="size-5 text-toned" />
|
||||||
<template v-if="isEditorOpen">
|
|
||||||
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
|
|
||||||
<span>{{ editorTitle }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
|
||||||
<span>Task Definitions</span>
|
<span>Task Definitions</span>
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="is-pulled-right" v-if="!isEditorOpen">
|
|
||||||
<div class="field is-grouped">
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-primary"
|
|
||||||
@click="isEditorOpen ? closeEditor() : openCreate()"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
|
||||||
<span v-if="!isMobile">New Definition</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button @click="() => (inspect = true)" class="button is-warning">
|
|
||||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
|
||||||
<span v-if="!isMobile">Inspect</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
v-tooltip.bottom="'Change display style'"
|
|
||||||
class="button has-tooltip-bottom"
|
|
||||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
|
||||||
>
|
|
||||||
<span class="icon">
|
|
||||||
<i
|
|
||||||
class="fa-solid"
|
|
||||||
:class="{
|
|
||||||
'fa-table': display_style !== 'list',
|
|
||||||
'fa-table-list': display_style === 'list',
|
|
||||||
}"
|
|
||||||
/></span>
|
|
||||||
<span v-if="!isMobile">
|
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="control">
|
|
||||||
<button
|
|
||||||
class="button is-info"
|
|
||||||
@click="async () => await loadDefinitions()"
|
|
||||||
:class="{ 'is-loading': isLoading }"
|
|
||||||
>
|
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
|
||||||
<span v-if="!isMobile">Reload</span>
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="is-hidden-mobile" v-if="!isEditorOpen">
|
<p class="text-sm text-toned">
|
||||||
<span class="subtitle">
|
|
||||||
Create definitions to turn any website into a downloadable feed of links.
|
Create definitions to turn any website into a downloadable feed of links.
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="definitions.length > 0"
|
||||||
|
color="neutral"
|
||||||
|
:variant="showFilter ? 'soft' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
@click="toggleFilterPanel"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Filter</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
@click="inspect = true"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Inspect</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">New Definition</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
||||||
|
@click="toggleDisplayStyle"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="() => void reloadContent()"
|
||||||
|
>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
|
<UAlert
|
||||||
<div class="column is-12">
|
v-if="lastError"
|
||||||
<div class="table-container">
|
color="error"
|
||||||
<table
|
variant="soft"
|
||||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
icon="i-lucide-circle-alert"
|
||||||
style="min-width: 850px; table-layout: fixed"
|
title="Error"
|
||||||
|
:description="lastError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="display_style === 'list' && filteredDefinitions.length > 0"
|
||||||
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
||||||
>
|
>
|
||||||
<thead>
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
||||||
<tr class="has-text-centered is-unselectable">
|
<table class="min-w-245 w-full text-sm">
|
||||||
<th width="40%">Name</th>
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
||||||
<th width="20%">Priority</th>
|
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
||||||
<th width="20%">Updated</th>
|
<th class="w-full text-left">Definition</th>
|
||||||
<th width="20%">Actions</th>
|
<th class="w-[18%]">Priority</th>
|
||||||
|
<th class="w-[18%]">Updated</th>
|
||||||
|
<th class="w-[1%]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
|
||||||
<tr v-for="definition in definitions" :key="definition.id">
|
<tbody class="divide-y divide-default">
|
||||||
<td class="is-vcentered">
|
<tr
|
||||||
<div class="is-text-overflow">
|
v-for="definition in filteredDefinitions"
|
||||||
|
:key="definition.id"
|
||||||
|
class="hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
<td class="px-3 py-3 align-middle">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-semibold text-highlighted">
|
||||||
{{ definition.name || '(Unnamed definition)' }}
|
{{ definition.name || '(Unnamed definition)' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="is-size-7">
|
|
||||||
<span
|
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
||||||
class="icon-text is-clickable"
|
<button
|
||||||
@click="toggle(definition)"
|
type="button"
|
||||||
v-tooltip="
|
class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 transition hover:bg-muted"
|
||||||
'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'
|
@click="() => void toggle(definition)"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<UIcon
|
||||||
<i
|
name="i-lucide-power"
|
||||||
class="fa-solid fa-power-off"
|
class="size-3.5"
|
||||||
:class="{
|
:class="definition.enabled ? 'text-success' : 'text-error'"
|
||||||
'has-text-success': definition.enabled,
|
|
||||||
'has-text-danger': !definition.enabled,
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
|
||||||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center gap-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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
|
||||||
<td class="is-vcentered has-text-centered">
|
<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')">
|
||||||
<span
|
<span
|
||||||
class="has-tooltip"
|
class="inline-flex"
|
||||||
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
|
||||||
v-rtime="definition.updated_at"
|
v-rtime="definition.updated_at"
|
||||||
/>
|
/>
|
||||||
|
</UTooltip>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<td class="w-[1%] px-3 py-3 align-middle whitespace-nowrap">
|
||||||
<div class="control">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<UButton
|
||||||
class="button is-small is-info"
|
color="info"
|
||||||
type="button"
|
variant="outline"
|
||||||
@click="exportDefinition(definition)"
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
@click="() => void exportDefinition(definition)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="warning"
|
||||||
class="button is-small is-warning"
|
variant="outline"
|
||||||
type="button"
|
size="xs"
|
||||||
@click="openEdit(definition)"
|
icon="i-lucide-pencil"
|
||||||
|
@click="() => void openEdit(definition)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
<div class="control">
|
<UButton
|
||||||
<button
|
color="error"
|
||||||
class="button is-small is-danger"
|
variant="outline"
|
||||||
type="button"
|
size="xs"
|
||||||
@click="remove(definition)"
|
icon="i-lucide-trash"
|
||||||
|
@click="() => void remove(definition)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</UButton>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -163,113 +196,162 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="columns is-multiline"
|
v-else-if="filteredDefinitions.length > 0"
|
||||||
v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen"
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
|
<UCard
|
||||||
<div class="card">
|
v-for="definition in filteredDefinitions"
|
||||||
<header class="card-header">
|
:key="definition.id"
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
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="text-sm font-semibold text-highlighted wrap-break-word">
|
||||||
{{ definition.name || '(Unnamed definition)' }}
|
{{ definition.name || '(Unnamed definition)' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
|
||||||
<div class="field has-addons">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
|
||||||
<div class="control" @click="toggle(definition)">
|
|
||||||
<span
|
|
||||||
class="icon"
|
|
||||||
:class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
|
|
||||||
v-tooltip="
|
|
||||||
`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-power-off" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button
|
<button
|
||||||
class="has-text-info"
|
type="button"
|
||||||
v-tooltip="'Export'"
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
||||||
@click="exportDefinition(definition)"
|
@click="() => void toggle(definition)"
|
||||||
>
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<UIcon
|
||||||
</button>
|
name="i-lucide-power"
|
||||||
</div>
|
class="size-3.5"
|
||||||
</div>
|
:class="definition.enabled ? 'text-success' : 'text-error'"
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="card-content">
|
|
||||||
<div class="content">
|
|
||||||
<p>
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
|
||||||
<span>Priority: {{ definition.priority }}</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
|
||||||
<span
|
|
||||||
>Updated:
|
|
||||||
<span
|
|
||||||
class="has-tooltip"
|
|
||||||
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
|
||||||
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
|
||||||
v-rtime="definition.updated_at"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer class="card-footer mt-auto">
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-warning is-fullwidth" @click="openEdit(definition)">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
|
|
||||||
<span>Edit</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="card-footer-item">
|
|
||||||
<button class="button is-danger is-fullwidth" @click="remove(definition)">
|
|
||||||
<span class="icon-text">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
|
||||||
<span>Delete</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!definitions.length">
|
<span
|
||||||
<div class="column is-12">
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
|
||||||
Loading data. Please wait...
|
|
||||||
</Message>
|
|
||||||
<Message
|
|
||||||
v-if="!isLoading && !isEditorOpen"
|
|
||||||
title="No definitions"
|
|
||||||
class="is-warning"
|
|
||||||
icon="fas fa-exclamation-circle"
|
|
||||||
>
|
>
|
||||||
There are no task definitions. Click the
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
||||||
<span class="icon"><i class="fas fa-add" /></span> <strong>New Definition</strong> button
|
<span>Priority {{ definition.priority }}</span>
|
||||||
to create your first task definition.
|
</span>
|
||||||
</Message>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns" v-if="isEditorOpen">
|
<UButton
|
||||||
<div class="column is-12">
|
color="info"
|
||||||
<TaskDefinitionEditor
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-file-up"
|
||||||
|
square
|
||||||
|
@click="() => void exportDefinition(definition)"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{{ pattern }}
|
||||||
|
</div>
|
||||||
|
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
|
||||||
|
+{{ definition.match_url.length - 3 }} more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto grid gap-2 pt-2 sm:grid-cols-2">
|
||||||
|
<UButton
|
||||||
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="() => void openEdit(definition)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="w-full justify-center"
|
||||||
|
@click="() => void remove(definition)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="isLoading"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-loader-circle"
|
||||||
|
title="Loading"
|
||||||
|
description="Loading data. Please wait..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="query && filteredDefinitions.length < 1" class="space-y-3">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
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
|
||||||
|
v-else-if="!definitions.length"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-circle-alert"
|
||||||
|
title="No definitions"
|
||||||
|
description="There are no task definitions. Click the New Definition button to create your first task definition."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UModal
|
||||||
|
v-if="isEditorOpen"
|
||||||
|
:open="isEditorOpen"
|
||||||
:title="editorTitle"
|
:title="editorTitle"
|
||||||
|
:description="editorDescription"
|
||||||
|
: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()"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<TaskDefinitionEditor
|
||||||
:document="workingDefinition"
|
:document="workingDefinition"
|
||||||
:initial-show-import="'create' === editorMode"
|
:initial-show-import="showImportByDefault"
|
||||||
:available-definitions="definitions"
|
:available-definitions="definitions"
|
||||||
:loading="editorLoading"
|
:loading="editorLoading"
|
||||||
:submitting="editorSubmitting"
|
:submitting="editorSubmitting"
|
||||||
|
|
@ -277,26 +359,33 @@
|
||||||
@cancel="closeEditor"
|
@cancel="closeEditor"
|
||||||
@import-existing="importExistingDefinition"
|
@import-existing="importExistingDefinition"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</UModal>
|
||||||
|
|
||||||
<Modal v-if="inspect" @close="() => (inspect = false)" :contentClass="`modal-content-max`">
|
<UModal
|
||||||
|
v-if="inspect"
|
||||||
|
:open="inspect"
|
||||||
|
title="Inspect Task Handler"
|
||||||
|
description="Enter the URL of the resource you want to inspect."
|
||||||
|
:ui="{ content: 'w-full sm:max-w-4xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
||||||
|
@update:open="(open) => !open && (inspect = false)"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
<TaskInspect />
|
<TaskInspect />
|
||||||
</Modal>
|
</template>
|
||||||
|
</UModal>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useStorage } from '@vueuse/core';
|
import { useStorage } from '@vueuse/core';
|
||||||
|
|
||||||
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue';
|
|
||||||
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
|
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
|
||||||
import { useDialog } from '~/composables/useDialog';
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import { useNotification } from '~/composables/useNotification';
|
|
||||||
import { copyText, encode } from '~/utils';
|
|
||||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||||
|
import { copyText, encode } from '~/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TaskDefinitionDetailed,
|
TaskDefinitionDetailed,
|
||||||
|
|
@ -328,6 +417,7 @@ const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
const taskDefs = useTaskDefinitionsComposable();
|
const taskDefs = useTaskDefinitionsComposable();
|
||||||
const definitionsRef = taskDefs.definitions;
|
const definitionsRef = taskDefs.definitions;
|
||||||
const isLoading = taskDefs.isLoading;
|
const isLoading = taskDefs.isLoading;
|
||||||
|
const lastError = taskDefs.lastError;
|
||||||
const loadDefinitions = taskDefs.loadDefinitions;
|
const loadDefinitions = taskDefs.loadDefinitions;
|
||||||
const getDefinition = taskDefs.getDefinition;
|
const getDefinition = taskDefs.getDefinition;
|
||||||
const createDefinition = taskDefs.createDefinition;
|
const createDefinition = taskDefs.createDefinition;
|
||||||
|
|
@ -335,45 +425,107 @@ const updateDefinition = taskDefs.updateDefinition;
|
||||||
const deleteDefinition = taskDefs.deleteDefinition;
|
const deleteDefinition = taskDefs.deleteDefinition;
|
||||||
const toggleEnabled = taskDefs.toggleEnabled;
|
const toggleEnabled = taskDefs.toggleEnabled;
|
||||||
|
|
||||||
const definitions = computed(() => definitionsRef.value);
|
const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.value]);
|
||||||
|
|
||||||
const { confirmDialog } = useDialog();
|
const { confirmDialog } = useDialog();
|
||||||
const toast = useNotification();
|
|
||||||
|
|
||||||
const isEditorOpen = ref<boolean>(false);
|
const isEditorOpen = ref(false);
|
||||||
const editorMode = ref<'create' | 'edit'>('create');
|
const editorMode = ref<'create' | 'edit'>('create');
|
||||||
const editorLoading = ref<boolean>(false);
|
const editorLoading = ref(false);
|
||||||
const editorSubmitting = ref<boolean>(false);
|
const editorSubmitting = ref(false);
|
||||||
const workingDefinition = ref<TaskDefinitionDocument | null>(null);
|
const workingDefinition = ref<TaskDefinitionDocument | null>(null);
|
||||||
const workingId = ref<number | null>(null);
|
const workingId = ref<number | null>(null);
|
||||||
const inspect = ref<boolean>(false);
|
const inspect = ref(false);
|
||||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const showFilter = ref(false);
|
||||||
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const hideImportByDefault = ref(false);
|
||||||
|
|
||||||
|
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
|
||||||
|
const normalizedQuery = query.value.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return definitions.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions.value.filter((definition) => {
|
||||||
|
const haystack = [
|
||||||
|
definition.name,
|
||||||
|
definition.priority,
|
||||||
|
definition.enabled ? 'enabled' : 'disabled',
|
||||||
|
...definition.match_url,
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return haystack.includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
||||||
if ('edit' !== editorMode.value || !workingId.value) {
|
if (editorMode.value !== 'edit' || !workingId.value) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return definitions.value.find((item) => item.id === workingId.value);
|
return definitions.value.find((item) => item.id === workingId.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorTitle = computed<string>(() => {
|
const editorTitle = computed(() => {
|
||||||
return 'create' === editorMode.value
|
return editorMode.value === 'create'
|
||||||
? 'Create Task Definition'
|
? 'Create Task Definition'
|
||||||
: `Edit - ${currentSummary.value?.name || '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,
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(showFilter, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
query.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
||||||
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
|
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleFilterPanel = async (): Promise<void> => {
|
||||||
|
showFilter.value = !showFilter.value;
|
||||||
|
if (!showFilter.value) {
|
||||||
|
query.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
filterInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadContent = async (): Promise<void> => {
|
||||||
|
await loadDefinitions(1, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDisplayStyle = (): void => {
|
||||||
|
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
||||||
|
};
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
editorMode.value = 'create';
|
editorMode.value = 'create';
|
||||||
workingId.value = null;
|
workingId.value = null;
|
||||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
||||||
isEditorOpen.value = true;
|
|
||||||
editorLoading.value = false;
|
editorLoading.value = false;
|
||||||
editorSubmitting.value = false;
|
editorSubmitting.value = false;
|
||||||
|
hideImportByDefault.value = false;
|
||||||
|
isEditorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
|
|
@ -382,47 +534,43 @@ const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
workingDefinition.value = null;
|
workingDefinition.value = null;
|
||||||
editorLoading.value = true;
|
editorLoading.value = true;
|
||||||
editorSubmitting.value = false;
|
editorSubmitting.value = false;
|
||||||
|
hideImportByDefault.value = true;
|
||||||
isEditorOpen.value = true;
|
isEditorOpen.value = true;
|
||||||
|
|
||||||
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id);
|
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id);
|
||||||
if (!detailed) {
|
if (!detailed) {
|
||||||
isEditorOpen.value = false;
|
closeEditor();
|
||||||
editorLoading.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document: TaskDefinitionDocument = {
|
workingDefinition.value = {
|
||||||
name: detailed.name,
|
name: detailed.name,
|
||||||
priority: detailed.priority,
|
priority: detailed.priority,
|
||||||
enabled: detailed.enabled,
|
enabled: detailed.enabled,
|
||||||
match_url: [...detailed.match_url],
|
match_url: [...detailed.match_url],
|
||||||
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
||||||
};
|
};
|
||||||
|
|
||||||
workingDefinition.value = document;
|
|
||||||
editorLoading.value = false;
|
editorLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const importExistingDefinition = async (id: number): Promise<void> => {
|
const importExistingDefinition = async (id: number): Promise<void> => {
|
||||||
const detailed = await getDefinition(id);
|
const detailed = await getDefinition(id);
|
||||||
if (!detailed) {
|
if (!detailed) {
|
||||||
toast.error('Failed to load task definition for import.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document: TaskDefinitionDocument = {
|
editorMode.value = 'create';
|
||||||
|
workingId.value = null;
|
||||||
|
workingDefinition.value = {
|
||||||
name: detailed.name,
|
name: detailed.name,
|
||||||
priority: detailed.priority,
|
priority: detailed.priority,
|
||||||
enabled: detailed.enabled,
|
enabled: detailed.enabled,
|
||||||
match_url: [...detailed.match_url],
|
match_url: [...detailed.match_url],
|
||||||
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
||||||
};
|
};
|
||||||
|
hideImportByDefault.value = true;
|
||||||
editorMode.value = 'create';
|
|
||||||
workingId.value = null;
|
|
||||||
workingDefinition.value = document;
|
|
||||||
isEditorOpen.value = true;
|
|
||||||
editorLoading.value = false;
|
editorLoading.value = false;
|
||||||
|
isEditorOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = (): void => {
|
const closeEditor = (): void => {
|
||||||
|
|
@ -434,36 +582,40 @@ const closeEditor = (): void => {
|
||||||
workingDefinition.value = null;
|
workingDefinition.value = null;
|
||||||
workingId.value = null;
|
workingId.value = null;
|
||||||
editorLoading.value = false;
|
editorLoading.value = false;
|
||||||
|
editorSubmitting.value = false;
|
||||||
|
hideImportByDefault.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
||||||
|
let shouldClose = false;
|
||||||
editorSubmitting.value = true;
|
editorSubmitting.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ('create' === editorMode.value) {
|
if (editorMode.value === 'create') {
|
||||||
const created = await createDefinition(definition);
|
const created = await createDefinition(definition);
|
||||||
if (created) {
|
if (created) {
|
||||||
isEditorOpen.value = false;
|
shouldClose = true;
|
||||||
workingDefinition.value = null;
|
|
||||||
workingId.value = null;
|
|
||||||
}
|
}
|
||||||
} else if (workingId.value) {
|
} else if (workingId.value) {
|
||||||
const updated = await updateDefinition(workingId.value, definition);
|
const updated = await updateDefinition(workingId.value, definition);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
isEditorOpen.value = false;
|
shouldClose = true;
|
||||||
workingDefinition.value = null;
|
|
||||||
workingId.value = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
editorSubmitting.value = false;
|
editorSubmitting.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldClose) {
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
const result = await confirmDialog({
|
const result = await confirmDialog({
|
||||||
title: 'Delete Task Definition',
|
title: 'Delete Task Definition',
|
||||||
message: `Are you sure you want to delete "${summary.name || summary.id}"?`,
|
message: `Are you sure you want to delete "${summary.name || summary.id}"?`,
|
||||||
confirmColor: 'is-danger',
|
confirmColor: 'error',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.status) {
|
if (!result.status) {
|
||||||
|
|
@ -483,7 +635,7 @@ const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> =
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return copyText(
|
copyText(
|
||||||
encode({
|
encode({
|
||||||
_type: 'task_definition',
|
_type: 'task_definition',
|
||||||
_version: '2.0',
|
_version: '2.0',
|
||||||
|
|
@ -498,7 +650,7 @@ const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> =
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!definitions.value.length) {
|
if (!definitions.value.length) {
|
||||||
await loadDefinitions();
|
await reloadContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
21
ui/app/plugins/color-mode.client.ts
Normal file
21
ui/app/plugins/color-mode.client.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useColorMode } from '#imports';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const legacyTheme = window.localStorage.getItem('theme');
|
||||||
|
if (!legacyTheme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPreference = 'auto' === legacyTheme ? 'system' : legacyTheme;
|
||||||
|
if (!['system', 'light', 'dark'].includes(nextPreference)) {
|
||||||
|
window.localStorage.removeItem('theme');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.localStorage.getItem('nuxt-color-mode')) {
|
||||||
|
const colorMode = useColorMode();
|
||||||
|
colorMode.preference = nextPreference as 'system' | 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
57
ui/app/plugins/modal-opacity.client.ts
Normal file
57
ui/app/plugins/modal-opacity.client.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
|
const OVERLAY_SELECTOR = '[data-slot="overlay"]';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer: MutationObserver | null = null;
|
||||||
|
let isLocked = false;
|
||||||
|
|
||||||
|
const syncOverlayOpacity = (): void => {
|
||||||
|
const hasOverlay = document.querySelector(OVERLAY_SELECTOR) !== null;
|
||||||
|
|
||||||
|
if (hasOverlay && !isLocked) {
|
||||||
|
disableOpacity();
|
||||||
|
isLocked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOverlay && isLocked) {
|
||||||
|
enableOpacity();
|
||||||
|
isLocked = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startObserver = (): void => {
|
||||||
|
if (observer || !document.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer = new MutationObserver(() => syncOverlayOpacity());
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
syncOverlayOpacity();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', startObserver, { once: true });
|
||||||
|
} else {
|
||||||
|
startObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'beforeunload',
|
||||||
|
() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
enableOpacity();
|
||||||
|
isLocked = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
10
ui/app/plugins/nuxt-toast.client.ts
Normal file
10
ui/app/plugins/nuxt-toast.client.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { setNuxtToastManager } from '~/utils/nuxtToastManager';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
setNuxtToastManager({
|
||||||
|
add: (payload) => toast.add(payload),
|
||||||
|
remove: (id) => toast.remove(id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { defineNuxtPlugin } from '#app';
|
|
||||||
import Toast, { type PluginOptions } from 'vue-toastification';
|
|
||||||
import 'vue-toastification/dist/index.css';
|
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
|
||||||
nuxtApp.vueApp.use(Toast, {
|
|
||||||
transition: 'Vue-Toastification__bounce',
|
|
||||||
//position: "bottom-right",
|
|
||||||
maxToasts: 5,
|
|
||||||
newestOnTop: true,
|
|
||||||
} as PluginOptions);
|
|
||||||
});
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { useStorage } from '@vueuse/core';
|
||||||
import type { notification, notificationType } from '~/composables/useNotification';
|
import type { notification, notificationType } from '~/composables/useNotification';
|
||||||
|
|
||||||
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
|
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
|
||||||
error: { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' },
|
error: { level: 3, color: 'error', icon: 'i-lucide-triangle-alert' },
|
||||||
warning: { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
|
warning: { level: 2, color: 'warning', icon: 'i-lucide-circle-alert' },
|
||||||
success: { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
|
success: { level: 1, color: 'success', icon: 'i-lucide-badge-check' },
|
||||||
info: { level: 0, color: 'is-info', icon: 'fas fa-circle-info' },
|
info: { level: 0, color: 'info', icon: 'i-lucide-info' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notifications', () => {
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
|
|
|
||||||
2
ui/app/types/dl_fields.d.ts
vendored
2
ui/app/types/dl_fields.d.ts
vendored
|
|
@ -16,7 +16,7 @@ type DLField = {
|
||||||
/** The kind of the field. i.e. string, bool */
|
/** The kind of the field. i.e. string, bool */
|
||||||
kind: DLFieldType;
|
kind: DLFieldType;
|
||||||
|
|
||||||
/** The icon of the field, it can be a font-awesome icon */
|
/** The icon of the field, use a Lucide/Iconify name like `i-lucide-image` */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|
||||||
/** The order of the field, used to sort the fields in the UI. */
|
/** The order of the field, used to sort the fields in the UI. */
|
||||||
|
|
|
||||||
16
ui/app/types/popover.d.ts
vendored
16
ui/app/types/popover.d.ts
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
|
|
||||||
export type PopoverTrigger = 'hover' | 'click' | 'focus';
|
|
||||||
|
|
||||||
export interface PopoverProps {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
placement?: PopoverPlacement;
|
|
||||||
trigger?: PopoverTrigger;
|
|
||||||
offset?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
closeOnClickOutside?: boolean;
|
|
||||||
minWidth?: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
showDelay?: number;
|
|
||||||
}
|
|
||||||
|
|
@ -725,25 +725,49 @@ const decode = (str: string): object => {
|
||||||
return JSON.parse(jsonStr);
|
return JSON.parse(jsonStr);
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableOpacity = (): boolean => {
|
let opacityLockCount = 0;
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
|
||||||
if (!bg_enable) {
|
const getStorageValue = <T>(key: string, defaultValue: T, missingValue: T = defaultValue): T => {
|
||||||
|
const stored = useStorage<T>(key, defaultValue);
|
||||||
|
if (!stored || typeof stored !== 'object' || !('value' in stored)) {
|
||||||
|
return missingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (stored.value === undefined ? defaultValue : stored.value) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBodyOpacity = (value: string): boolean => {
|
||||||
|
const body = document.querySelector('body');
|
||||||
|
if (!body) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
|
body.setAttribute('style', `opacity: ${value}`);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableOpacity = (): boolean => {
|
const disableOpacity = (): boolean => {
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
if (!getStorageValue<boolean>('random_bg', true, false)) {
|
||||||
if (!bg_enable) {
|
opacityLockCount = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
opacityLockCount += 1;
|
||||||
document.querySelector('body')?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
return setBodyOpacity('1.0');
|
||||||
return true;
|
};
|
||||||
|
|
||||||
|
const enableOpacity = (): boolean => {
|
||||||
|
if (!getStorageValue<boolean>('random_bg', true, false)) {
|
||||||
|
opacityLockCount = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
opacityLockCount = Math.max(0, opacityLockCount - 1);
|
||||||
|
if (opacityLockCount > 0) {
|
||||||
|
return setBodyOpacity('1.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return setBodyOpacity(String(getStorageValue<number>('random_bg_opacity', 0.95)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripPath = (base_path: string, real_path: string): string => {
|
const stripPath = (base_path: string, real_path: string): string => {
|
||||||
|
|
|
||||||
21
ui/app/utils/nuxtToastManager.ts
Normal file
21
ui/app/utils/nuxtToastManager.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
type ToastPayload = {
|
||||||
|
id?: string | number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastInstance = {
|
||||||
|
id: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastManager = {
|
||||||
|
add: (toast: ToastPayload) => ToastInstance;
|
||||||
|
remove: (id: string | number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager: ToastManager | null = null;
|
||||||
|
|
||||||
|
export const setNuxtToastManager = (toastManager: ToastManager): void => {
|
||||||
|
manager = toastManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNuxtToastManager = (): ToastManager | null => manager;
|
||||||
17
ui/app/utils/ytdlpOptions.ts
Normal file
17
ui/app/utils/ytdlpOptions.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export const YTDLP_ALL_GROUPS = '__all_groups__';
|
||||||
|
|
||||||
|
type YTDLPGroupItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildYtdlpGroupItems = (groups: string[]): YTDLPGroupItem[] => {
|
||||||
|
return [
|
||||||
|
{ label: 'All groups', value: YTDLP_ALL_GROUPS },
|
||||||
|
...groups.map((group) => ({ label: group, value: group })),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeYtdlpGroupFilter = (value: string): string => {
|
||||||
|
return value === YTDLP_ALL_GROUPS ? '' : value;
|
||||||
|
};
|
||||||
612
ui/bun.lock
612
ui/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineNuxtConfig } from 'nuxt/config'
|
import { defineNuxtConfig } from 'nuxt/config';
|
||||||
|
|
||||||
let extraNitro = {}
|
let extraNitro = {};
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NUXT_API_URL;
|
const API_URL = process.env.NUXT_API_URL;
|
||||||
if (API_URL) {
|
if (API_URL) {
|
||||||
|
|
@ -8,84 +8,90 @@ try {
|
||||||
devProxy: {
|
devProxy: {
|
||||||
'/api/': {
|
'/api/': {
|
||||||
target: API_URL,
|
target: API_URL,
|
||||||
changeOrigin: true
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
} catch {}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: { enabled: false },
|
devtools: { enabled: true },
|
||||||
devServer: {
|
devServer: {
|
||||||
port: 8082,
|
port: 8082,
|
||||||
host: "0.0.0.0",
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
css: [
|
colorMode: {
|
||||||
'vue-toastification/dist/index.css'
|
preference: 'dark',
|
||||||
],
|
fallback: 'dark',
|
||||||
|
classSuffix: '',
|
||||||
|
},
|
||||||
|
css: ['~/assets/css/tailwind.css'],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
APP_ENV: process.env.NODE_ENV,
|
APP_ENV: process.env.NODE_ENV,
|
||||||
wss: process.env.NUXT_PUBLIC_WSS ?? ''
|
wss: process.env.NUXT_PUBLIC_WSS ?? '',
|
||||||
}
|
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
transpile: ['vue-toastification'],
|
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
baseURL: 'production' == process.env.NODE_ENV ? '/_base_path/' : '/',
|
baseURL: 'production' == process.env.NODE_ENV ? '/_base_path/' : '/',
|
||||||
buildAssetsDir: "assets",
|
buildAssetsDir: 'assets',
|
||||||
head: {
|
head: {
|
||||||
"meta": [
|
meta: [
|
||||||
{ "charset": "utf-8" },
|
{ charset: 'utf-8' },
|
||||||
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0' },
|
||||||
{ "name": "theme-color", "content": "#000000" },
|
{ name: 'theme-color', content: '#020817' },
|
||||||
{ "name": "mobile-web-app-capable", "content": "yes" },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ "name": "apple-mobile-web-app-capable", "content": "yes" },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ "name": "apple-mobile-web-app-status-bar-style", "content": "black-translucent" },
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
||||||
{ "name": "apple-mobile-web-app-title", "content": "YTPTube" },
|
{ name: 'apple-mobile-web-app-title', content: 'YTPTube' },
|
||||||
],
|
],
|
||||||
base: { "href": "/" },
|
base: { href: '/' },
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico?v=100' },
|
{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico?v=100' },
|
||||||
{ rel: 'manifest', href: 'manifest.webmanifest?v=100' },
|
{ rel: 'manifest', href: 'manifest.webmanifest?v=100' },
|
||||||
{ rel: 'apple-touch-icon', href: 'images/favicon.png' },
|
{ rel: 'apple-touch-icon', href: 'images/favicon.png' },
|
||||||
{ rel: 'apple-touch-startup-image', href: 'images/logo.png' }
|
{ rel: 'apple-touch-startup-image', href: 'images/logo.png' },
|
||||||
]
|
|
||||||
},
|
|
||||||
pageTransition: { name: 'page', mode: 'out-in' }
|
|
||||||
},
|
|
||||||
router: {
|
|
||||||
options: {
|
|
||||||
linkActiveClass: "is-selected",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modules: [
|
|
||||||
'@pinia/nuxt',
|
|
||||||
'@vueuse/nuxt',
|
|
||||||
'floating-vue/nuxt',
|
|
||||||
'@nuxt/eslint',
|
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
pageTransition: { name: 'page' },
|
||||||
|
},
|
||||||
|
modules: ['@nuxt/ui', '@pinia/nuxt', '@vueuse/nuxt', '@nuxt/eslint'],
|
||||||
|
icon: {
|
||||||
|
serverBundle: 'local',
|
||||||
|
},
|
||||||
nitro: {
|
nitro: {
|
||||||
output: {
|
output: {
|
||||||
publicDir: 'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
|
publicDir:
|
||||||
|
'production' === process.env.NODE_ENV ? __dirname + '/exported' : __dirname + '/dist',
|
||||||
},
|
},
|
||||||
...extraNitro,
|
...extraNitro,
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'moment',
|
||||||
|
'@microsoft/fetch-event-source',
|
||||||
|
'@xterm/addon-fit',
|
||||||
|
'@xterm/xterm',
|
||||||
|
'cron-parser',
|
||||||
|
'marked',
|
||||||
|
'marked-base-url',
|
||||||
|
'marked-alert',
|
||||||
|
'marked-gfm-heading-id',
|
||||||
|
],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 2000,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
compatibilityDate: "2025-08-03",
|
compatibilityDate: '2025-08-03',
|
||||||
experimental: {
|
experimental: {
|
||||||
checkOutdatedBuildInterval: 1000 * 60 * 10,
|
checkOutdatedBuildInterval: 1000 * 60 * 10,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,46 +21,44 @@
|
||||||
},
|
},
|
||||||
"web-types": "./web-types.json",
|
"web-types": "./web-types.json",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.98",
|
||||||
"@microsoft/fetch-event-source": "^2.0.1",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
|
"@nuxt/eslint": "^1.15.2",
|
||||||
|
"@nuxt/eslint-config": "^1.15.2",
|
||||||
|
"@nuxt/ui": "^4.5.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"@vueuse/nuxt": "^14.2.1",
|
"@vueuse/nuxt": "^14.2.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"cronstrue": "^3.13.0",
|
"cronstrue": "^3.14.0",
|
||||||
"floating-vue": "^5.2.2",
|
|
||||||
"hls.js": "^1.6.15",
|
"hls.js": "^1.6.15",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.5",
|
||||||
"marked-alert": "^2.1.2",
|
"marked-alert": "^2.1.2",
|
||||||
"marked-base-url": "^1.1.8",
|
"marked-base-url": "^1.1.8",
|
||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
"vue-router": "^5.0.3",
|
"vue-router": "^5.0.4"
|
||||||
"vue-toastification": "^2.0.0-rc.5",
|
|
||||||
"@nuxt/eslint": "^1.15.2",
|
|
||||||
"@nuxt/eslint-config": "^1.15.2"
|
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"@parcel/watcher"
|
"@parcel/watcher"
|
||||||
],
|
],
|
||||||
"overrides": {
|
|
||||||
"vue-toastification": "^2.0.0-rc.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.10",
|
"@types/bun": "^1.3.11",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.5.0",
|
||||||
"@types/jsdom": "^28.0.0",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@typescript-eslint/parser": "^8.57.0",
|
"@typescript-eslint/parser": "^8.57.1",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.1.0",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.1",
|
||||||
"oxfmt": "^0.40.0",
|
"oxfmt": "^0.41.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vue-eslint-parser": "^10.4.0",
|
"vue-eslint-parser": "^10.4.0",
|
||||||
"vue-tsc": "^3.2.5"
|
"vue-tsc": "^3.2.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock, spyOn } from 'bun:test'
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||||
|
|
||||||
type StorageEntry = { value: unknown }
|
type StorageEntry = { value: unknown };
|
||||||
|
|
||||||
const notificationMock = {
|
const notificationMock = {
|
||||||
info: mock(() => {}),
|
info: mock(() => {}),
|
||||||
|
|
@ -8,515 +8,527 @@ const notificationMock = {
|
||||||
warning: mock(() => {}),
|
warning: mock(() => {}),
|
||||||
error: mock(() => {}),
|
error: mock(() => {}),
|
||||||
notify: mock(() => {}),
|
notify: mock(() => {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
app: {
|
app: {
|
||||||
baseURL: '/base-path',
|
baseURL: '/base-path',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
mock.module('#imports', () => ({
|
mock.module('#imports', () => ({
|
||||||
useRuntimeConfig: () => runtimeConfig,
|
useRuntimeConfig: () => runtimeConfig,
|
||||||
useNotification: () => notificationMock,
|
useNotification: () => notificationMock,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
globalThis.useRuntimeConfig = () => runtimeConfig as any
|
globalThis.useRuntimeConfig = () => runtimeConfig as any;
|
||||||
globalThis.useNotification = () => notificationMock as any
|
globalThis.useNotification = () => notificationMock as any;
|
||||||
|
|
||||||
const storageMap = new Map<string, StorageEntry | unknown>()
|
const storageMap = new Map<string, StorageEntry | unknown>();
|
||||||
|
|
||||||
const useStorageFn = mock(<T>(key: string, defaultValue: T) => {
|
const useStorageFn = mock(<T>(key: string, defaultValue: T) => {
|
||||||
if (!storageMap.has(key)) {
|
if (!storageMap.has(key)) {
|
||||||
storageMap.set(key, { value: defaultValue })
|
storageMap.set(key, { value: defaultValue });
|
||||||
}
|
}
|
||||||
return storageMap.get(key)
|
return storageMap.get(key);
|
||||||
})
|
});
|
||||||
|
|
||||||
mock.module('@vueuse/core', () => ({
|
mock.module('@vueuse/core', () => ({
|
||||||
useStorage: useStorageFn,
|
useStorage: useStorageFn,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const clipboardWriteMock = mock(() => Promise.resolve())
|
const clipboardWriteMock = mock(() => Promise.resolve());
|
||||||
const fetchMock = mock(() => Promise.resolve({ status: 200 } as Response))
|
const fetchMock = mock(() => Promise.resolve({ status: 200 } as Response));
|
||||||
const getRandomValuesMock = mock((buffer: Uint8Array) => {
|
const getRandomValuesMock = mock((buffer: Uint8Array) => {
|
||||||
buffer.fill(1)
|
buffer.fill(1);
|
||||||
return buffer
|
return buffer;
|
||||||
})
|
});
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch;
|
||||||
const originalClipboard = globalThis.navigator?.clipboard
|
const originalClipboard = globalThis.navigator?.clipboard;
|
||||||
const originalCrypto = globalThis.crypto
|
const originalCrypto = globalThis.crypto;
|
||||||
|
|
||||||
let utils: Awaited<typeof import('~/utils/index')>
|
let utils: Awaited<typeof import('~/utils/index')>;
|
||||||
let fetchSpy: ReturnType<typeof spyOn> | undefined
|
let fetchSpy: ReturnType<typeof spyOn> | undefined;
|
||||||
|
|
||||||
const resetStorage = () => {
|
const resetStorage = () => {
|
||||||
storageMap.clear()
|
storageMap.clear();
|
||||||
storageMap.set('random_bg', { value: true })
|
storageMap.set('random_bg', { value: true });
|
||||||
storageMap.set('random_bg_opacity', { value: 0.95 })
|
storageMap.set('random_bg_opacity', { value: 0.95 });
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
utils = await import('~/utils/index')
|
utils = await import('~/utils/index');
|
||||||
})
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetStorage()
|
resetStorage();
|
||||||
runtimeConfig.app.baseURL = '/base-path'
|
runtimeConfig.app.baseURL = '/base-path';
|
||||||
notificationMock.info.mockClear()
|
notificationMock.info.mockClear();
|
||||||
notificationMock.success.mockClear()
|
notificationMock.success.mockClear();
|
||||||
notificationMock.warning.mockClear()
|
notificationMock.warning.mockClear();
|
||||||
notificationMock.error.mockClear()
|
notificationMock.error.mockClear();
|
||||||
notificationMock.notify.mockClear()
|
notificationMock.notify.mockClear();
|
||||||
useStorageFn.mockClear()
|
useStorageFn.mockClear();
|
||||||
|
|
||||||
fetchMock.mockClear()
|
fetchMock.mockClear();
|
||||||
clipboardWriteMock.mockClear()
|
clipboardWriteMock.mockClear();
|
||||||
getRandomValuesMock.mockClear()
|
getRandomValuesMock.mockClear();
|
||||||
|
|
||||||
if (typeof originalFetch === 'function') {
|
if (typeof originalFetch === 'function') {
|
||||||
fetchSpy = spyOn(globalThis, 'fetch')
|
fetchSpy = spyOn(globalThis, 'fetch');
|
||||||
fetchSpy.mockImplementation(fetchMock as any)
|
fetchSpy.mockImplementation(fetchMock as any);
|
||||||
} else {
|
} else {
|
||||||
;(globalThis as any).fetch = fetchMock
|
(globalThis as any).fetch = fetchMock;
|
||||||
fetchSpy = undefined
|
fetchSpy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: { writeText: clipboardWriteMock },
|
value: { writeText: clipboardWriteMock },
|
||||||
})
|
});
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'crypto', {
|
Object.defineProperty(globalThis, 'crypto', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: { getRandomValues: getRandomValuesMock },
|
value: { getRandomValues: getRandomValuesMock },
|
||||||
})
|
});
|
||||||
Object.defineProperty(window as any, 'crypto', {
|
Object.defineProperty(window as any, 'crypto', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: { getRandomValues: getRandomValuesMock },
|
value: { getRandomValues: getRandomValuesMock },
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (fetchSpy) {
|
if (fetchSpy) {
|
||||||
fetchSpy.mockRestore()
|
fetchSpy.mockRestore();
|
||||||
} else if (!originalFetch) {
|
} else if (!originalFetch) {
|
||||||
delete (globalThis as any).fetch
|
delete (globalThis as any).fetch;
|
||||||
} else {
|
} else {
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalClipboard) {
|
if (originalClipboard) {
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: originalClipboard,
|
value: originalClipboard,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
delete (navigator as any).clipboard
|
delete (navigator as any).clipboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'crypto', {
|
Object.defineProperty(globalThis, 'crypto', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: originalCrypto,
|
value: originalCrypto,
|
||||||
})
|
});
|
||||||
Object.defineProperty(window as any, 'crypto', {
|
Object.defineProperty(window as any, 'crypto', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: originalCrypto,
|
value: originalCrypto,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (document.body) {
|
if (document.body) {
|
||||||
document.body.innerHTML = ''
|
document.body.innerHTML = '';
|
||||||
document.body.removeAttribute('style')
|
document.body.removeAttribute('style');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('utils/index setup', () => {
|
describe('utils/index setup', () => {
|
||||||
it('exposes core utilities after mocks initialize', () => {
|
it('exposes core utilities after mocks initialize', () => {
|
||||||
expect(Array.isArray(utils.separators)).toBe(true)
|
expect(Array.isArray(utils.separators)).toBe(true);
|
||||||
expect(typeof utils.getValue).toBe('function')
|
expect(typeof utils.getValue).toBe('function');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('object access helpers', () => {
|
describe('object access helpers', () => {
|
||||||
it('getValue resolves direct values and callables', () => {
|
it('getValue resolves direct values and callables', () => {
|
||||||
expect(utils.getValue(5)).toBe(5)
|
expect(utils.getValue(5)).toBe(5);
|
||||||
expect(utils.getValue(() => 7)).toBe(7)
|
expect(utils.getValue(() => 7)).toBe(7);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('ag returns nested value or default value', () => {
|
it('ag returns nested value or default value', () => {
|
||||||
const payload = { a: { b: { c: 42 } } }
|
const payload = { a: { b: { c: 42 } } };
|
||||||
expect(utils.ag(payload, 'a.b.c')).toBe(42)
|
expect(utils.ag(payload, 'a.b.c')).toBe(42);
|
||||||
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback')
|
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback');
|
||||||
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default')
|
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('ag_set sets nested path creating objects as needed', () => {
|
it('ag_set sets nested path creating objects as needed', () => {
|
||||||
const payload: Record<string, unknown> = {}
|
const payload: Record<string, unknown> = {};
|
||||||
utils.ag_set(payload, 'a.b.c', 99)
|
utils.ag_set(payload, 'a.b.c', 99);
|
||||||
expect(payload).toEqual({ a: { b: { c: 99 } } })
|
expect(payload).toEqual({ a: { b: { c: 99 } } });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('cleanObject removes requested keys', () => {
|
it('cleanObject removes requested keys', () => {
|
||||||
const source = { id: 1, keep: true, drop: false }
|
const source = { id: 1, keep: true, drop: false };
|
||||||
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true })
|
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true });
|
||||||
expect(utils.cleanObject(source, [])).toEqual(source)
|
expect(utils.cleanObject(source, [])).toEqual(source);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('stripPath removes base prefix and leading slashes', () => {
|
it('stripPath removes base prefix and leading slashes', () => {
|
||||||
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4')
|
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4');
|
||||||
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt')
|
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('string manipulation helpers', () => {
|
describe('string manipulation helpers', () => {
|
||||||
it('r replaces tokens with context values', () => {
|
it('r replaces tokens with context values', () => {
|
||||||
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } })
|
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } });
|
||||||
expect(result).toBe('Hello YTPTube!')
|
expect(result).toBe('Hello YTPTube!');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim trims delimiters at requested positions', () => {
|
it('iTrim trims delimiters at requested positions', () => {
|
||||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
|
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('::value', ':', 'start')).toBe('value')
|
expect(utils.iTrim('::value', ':', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
|
expect(utils.iTrim('value::', ':', 'end')).toBe('value');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles forward slash delimiter', () => {
|
it('iTrim handles forward slash delimiter', () => {
|
||||||
expect(utils.iTrim('//value//', '/', 'both')).toBe('value')
|
expect(utils.iTrim('//value//', '/', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('/value', '/', 'start')).toBe('value')
|
expect(utils.iTrim('/value', '/', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value/', '/', 'end')).toBe('value')
|
expect(utils.iTrim('value/', '/', 'end')).toBe('value');
|
||||||
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple')
|
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles backslash delimiter', () => {
|
it('iTrim handles backslash delimiter', () => {
|
||||||
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value')
|
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value')
|
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value')
|
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles hyphen delimiter', () => {
|
it('iTrim handles hyphen delimiter', () => {
|
||||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
|
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('-value', '-', 'start')).toBe('value')
|
expect(utils.iTrim('-value', '-', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value-', '-', 'end')).toBe('value')
|
expect(utils.iTrim('value-', '-', 'end')).toBe('value');
|
||||||
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple')
|
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles caret delimiter', () => {
|
it('iTrim handles caret delimiter', () => {
|
||||||
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value')
|
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('^value', '^', 'start')).toBe('value')
|
expect(utils.iTrim('^value', '^', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value^', '^', 'end')).toBe('value')
|
expect(utils.iTrim('value^', '^', 'end')).toBe('value');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles bracket delimiters', () => {
|
it('iTrim handles bracket delimiters', () => {
|
||||||
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]')
|
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]');
|
||||||
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[')
|
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles dot delimiter', () => {
|
it('iTrim handles dot delimiter', () => {
|
||||||
expect(utils.iTrim('..value..', '.', 'both')).toBe('value')
|
expect(utils.iTrim('..value..', '.', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('.value', '.', 'start')).toBe('value')
|
expect(utils.iTrim('.value', '.', 'start')).toBe('value');
|
||||||
expect(utils.iTrim('value.', '.', 'end')).toBe('value')
|
expect(utils.iTrim('value.', '.', 'end')).toBe('value');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles special regex characters', () => {
|
it('iTrim handles special regex characters', () => {
|
||||||
expect(utils.iTrim('**value**', '*', 'both')).toBe('value')
|
expect(utils.iTrim('**value**', '*', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('++value++', '+', 'both')).toBe('value')
|
expect(utils.iTrim('++value++', '+', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('??value??', '?', 'both')).toBe('value')
|
expect(utils.iTrim('??value??', '?', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('||value||', '|', 'both')).toBe('value')
|
expect(utils.iTrim('||value||', '|', 'both')).toBe('value');
|
||||||
expect(utils.iTrim('((value))', '(', 'both')).toBe('value))')
|
expect(utils.iTrim('((value))', '(', 'both')).toBe('value))');
|
||||||
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value')
|
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim handles empty string', () => {
|
it('iTrim handles empty string', () => {
|
||||||
expect(utils.iTrim('', '/', 'both')).toBe('')
|
expect(utils.iTrim('', '/', 'both')).toBe('');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim throws error when delimiter is empty', () => {
|
it('iTrim throws error when delimiter is empty', () => {
|
||||||
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required')
|
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('iTrim preserves middle occurrences', () => {
|
it('iTrim preserves middle occurrences', () => {
|
||||||
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file')
|
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file');
|
||||||
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file')
|
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('eTrim and sTrim delegate to iTrim ends', () => {
|
it('eTrim and sTrim delegate to iTrim ends', () => {
|
||||||
expect(utils.eTrim('##name##', '#')).toBe('##name')
|
expect(utils.eTrim('##name##', '#')).toBe('##name');
|
||||||
expect(utils.sTrim('##name##', '#')).toBe('name##')
|
expect(utils.sTrim('##name##', '#')).toBe('name##');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('ucFirst capitalizes first character', () => {
|
it('ucFirst capitalizes first character', () => {
|
||||||
expect(utils.ucFirst('ytp')).toBe('Ytp')
|
expect(utils.ucFirst('ytp')).toBe('Ytp');
|
||||||
expect(utils.ucFirst('')).toBe('')
|
expect(utils.ucFirst('')).toBe('');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath safely encodes components', () => {
|
it('encodePath safely encodes components', () => {
|
||||||
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4')
|
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath handles % character correctly', () => {
|
it('encodePath handles % character correctly', () => {
|
||||||
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
|
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe(
|
||||||
})
|
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodePath handles multiple special characters', () => {
|
it('encodePath handles multiple special characters', () => {
|
||||||
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe('100%25%20complete%20%5BHD%5D%20%231.mp4')
|
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe(
|
||||||
})
|
'100%25%20complete%20%5BHD%5D%20%231.mp4',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodePath handles paths with % character', () => {
|
it('encodePath handles paths with % character', () => {
|
||||||
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe('folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
|
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe(
|
||||||
})
|
'folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodePath handles already encoded strings', () => {
|
it('encodePath handles already encoded strings', () => {
|
||||||
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')
|
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe(
|
||||||
})
|
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodePath handles mixed encoded and unencoded', () => {
|
it('encodePath handles mixed encoded and unencoded', () => {
|
||||||
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4')
|
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath handles special characters &, =, ?', () => {
|
it('encodePath handles special characters &, =, ?', () => {
|
||||||
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe('query%3Fparam%3Dvalue%26key%3D100%25.mp4')
|
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe(
|
||||||
})
|
'query%3Fparam%3Dvalue%26key%3D100%25.mp4',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodePath handles empty string', () => {
|
it('encodePath handles empty string', () => {
|
||||||
expect(utils.encodePath('')).toBe('')
|
expect(utils.encodePath('')).toBe('');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath handles simple filename', () => {
|
it('encodePath handles simple filename', () => {
|
||||||
expect(utils.encodePath('video.mp4')).toBe('video.mp4')
|
expect(utils.encodePath('video.mp4')).toBe('video.mp4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath handles unicode characters', () => {
|
it('encodePath handles unicode characters', () => {
|
||||||
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4')
|
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encodePath handles parentheses', () => {
|
it('encodePath handles parentheses', () => {
|
||||||
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4')
|
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('removeANSIColors strips escape codes', () => {
|
it('removeANSIColors strips escape codes', () => {
|
||||||
const sample = '\u001b[31mError\u001b[0m'
|
const sample = '\u001b[31mError\u001b[0m';
|
||||||
expect(utils.removeANSIColors(sample)).toBe('Error')
|
expect(utils.removeANSIColors(sample)).toBe('Error');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('dec2hex converts to two character hex strings', () => {
|
it('dec2hex converts to two character hex strings', () => {
|
||||||
expect(utils.dec2hex(15)).toBe('0f')
|
expect(utils.dec2hex(15)).toBe('0f');
|
||||||
expect(utils.dec2hex(255)).toBe('ff')
|
expect(utils.dec2hex(255)).toBe('ff');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('basename returns final segment optionally trimming extension', () => {
|
it('basename returns final segment optionally trimming extension', () => {
|
||||||
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4')
|
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
|
||||||
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video')
|
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
|
||||||
expect(utils.basename('', '.mp4')).toBe('')
|
expect(utils.basename('', '.mp4')).toBe('');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('dirname returns parent directory', () => {
|
it('dirname returns parent directory', () => {
|
||||||
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads')
|
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads');
|
||||||
expect(utils.dirname('video.mp4')).toBe('.')
|
expect(utils.dirname('video.mp4')).toBe('.');
|
||||||
expect(utils.dirname('/file')).toBe('/')
|
expect(utils.dirname('/file')).toBe('/');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('formatBytes returns human readable strings', () => {
|
it('formatBytes returns human readable strings', () => {
|
||||||
expect(utils.formatBytes(0)).toBe('0 Bytes')
|
expect(utils.formatBytes(0)).toBe('0 Bytes');
|
||||||
expect(utils.formatBytes(1024)).toBe('1 KiB')
|
expect(utils.formatBytes(1024)).toBe('1 KiB');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('formatTime renders hh:mm:ss or mm:ss', () => {
|
it('formatTime renders hh:mm:ss or mm:ss', () => {
|
||||||
expect(utils.formatTime(59)).toBe('59')
|
expect(utils.formatTime(59)).toBe('59');
|
||||||
expect(utils.formatTime(90)).toBe('01:30')
|
expect(utils.formatTime(90)).toBe('01:30');
|
||||||
expect(utils.formatTime(3661)).toBe('01:01:01')
|
expect(utils.formatTime(3661)).toBe('01:01:01');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('getSeparatorsName returns human readable label', () => {
|
it('getSeparatorsName returns human readable label', () => {
|
||||||
expect(utils.getSeparatorsName(',')).toContain('Comma')
|
expect(utils.getSeparatorsName(',')).toContain('Comma');
|
||||||
expect(utils.getSeparatorsName('*')).toBe('Unknown')
|
expect(utils.getSeparatorsName('*')).toBe('Unknown');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('data conversion helpers', () => {
|
describe('data conversion helpers', () => {
|
||||||
it('has_data detects arrays, objects, and json strings', () => {
|
it('has_data detects arrays, objects, and json strings', () => {
|
||||||
expect(utils.has_data({ key: 'value' })).toBe(true)
|
expect(utils.has_data({ key: 'value' })).toBe(true);
|
||||||
expect(utils.has_data('""')).toBe(false)
|
expect(utils.has_data('""')).toBe(false);
|
||||||
expect(utils.has_data('[1,2]')).toBe(true)
|
expect(utils.has_data('[1,2]')).toBe(true);
|
||||||
expect(utils.has_data('')).toBe(false)
|
expect(utils.has_data('')).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('encode and decode provide reversible transformation', () => {
|
it('encode and decode provide reversible transformation', () => {
|
||||||
const payload = { name: 'YTPTube', count: 2 }
|
const payload = { name: 'YTPTube', count: 2 };
|
||||||
const encoded = utils.encode(payload)
|
const encoded = utils.encode(payload);
|
||||||
expect(typeof encoded).toBe('string')
|
expect(typeof encoded).toBe('string');
|
||||||
expect(utils.decode(encoded)).toEqual(payload)
|
expect(utils.decode(encoded)).toEqual(payload);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('makePagination builds a ranged pagination list', () => {
|
it('makePagination builds a ranged pagination list', () => {
|
||||||
const pages = utils.makePagination(5, 10, 1)
|
const pages = utils.makePagination(5, 10, 1);
|
||||||
const selected = pages.find((page: any) => page.selected)
|
const selected = pages.find((page: any) => page.selected);
|
||||||
expect(selected?.page).toBe(5)
|
expect(selected?.page).toBe(5);
|
||||||
expect(pages.length).toBeGreaterThan(0)
|
expect(pages.length).toBeGreaterThan(0);
|
||||||
expect(pages[0]?.page).toBe(1)
|
expect(pages[0]?.page).toBe(1);
|
||||||
expect(pages[pages.length - 1]?.page).toBe(10)
|
expect(pages[pages.length - 1]?.page).toBe(10);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('getQueryParams parses query strings', () => {
|
it('getQueryParams parses query strings', () => {
|
||||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' })
|
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('uri prefixes runtime base path', () => {
|
it('uri prefixes runtime base path', () => {
|
||||||
runtimeConfig.app.baseURL = '/base-path'
|
runtimeConfig.app.baseURL = '/base-path';
|
||||||
expect(utils.uri('/api/test')).toBe('/base-path/api/test')
|
expect(utils.uri('/api/test')).toBe('/base-path/api/test');
|
||||||
runtimeConfig.app.baseURL = '/'
|
runtimeConfig.app.baseURL = '/';
|
||||||
expect(utils.uri('/api/test')).toBe('/api/test')
|
expect(utils.uri('/api/test')).toBe('/api/test');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('makeDownload builds expected url with folder and filename', () => {
|
it('makeDownload builds expected url with folder and filename', () => {
|
||||||
runtimeConfig.app.baseURL = '/base-path'
|
runtimeConfig.app.baseURL = '/base-path';
|
||||||
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' })
|
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' });
|
||||||
expect(url).toBe('/base-path/api/download/music/song.mp3')
|
expect(url).toBe('/base-path/api/download/music/song.mp3');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('makeDownload handles m3u8 base path', () => {
|
it('makeDownload handles m3u8 base path', () => {
|
||||||
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8')
|
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
|
||||||
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8')
|
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('dom and browser helpers', () => {
|
describe('dom and browser helpers', () => {
|
||||||
it('dEvent dispatches custom event with detail payload', () => {
|
it('dEvent dispatches custom event with detail payload', () => {
|
||||||
const detail = { foo: 'bar' }
|
const detail = { foo: 'bar' };
|
||||||
let received: unknown = null
|
let received: unknown = null;
|
||||||
const listener = (event: Event) => {
|
const listener = (event: Event) => {
|
||||||
received = (event as CustomEvent).detail
|
received = (event as CustomEvent).detail;
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener('custom-detail', listener)
|
window.addEventListener('custom-detail', listener);
|
||||||
const dispatched = utils.dEvent('custom-detail', detail)
|
const dispatched = utils.dEvent('custom-detail', detail);
|
||||||
window.removeEventListener('custom-detail', listener)
|
window.removeEventListener('custom-detail', listener);
|
||||||
|
|
||||||
expect(dispatched).toBe(true)
|
expect(dispatched).toBe(true);
|
||||||
expect(received).toEqual(detail)
|
expect(received).toEqual(detail);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('toggleClass adds and removes classes', () => {
|
it('toggleClass adds and removes classes', () => {
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div');
|
||||||
utils.toggleClass(el, 'active')
|
utils.toggleClass(el, 'active');
|
||||||
expect(el.classList.contains('active')).toBe(true)
|
expect(el.classList.contains('active')).toBe(true);
|
||||||
utils.toggleClass(el, 'active')
|
utils.toggleClass(el, 'active');
|
||||||
expect(el.classList.contains('active')).toBe(false)
|
expect(el.classList.contains('active')).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('copyText uses clipboard API and notifies success', async () => {
|
it('copyText uses clipboard API and notifies success', async () => {
|
||||||
utils.copyText('sample')
|
utils.copyText('sample');
|
||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(clipboardWriteMock).toHaveBeenCalledWith('sample')
|
expect(clipboardWriteMock).toHaveBeenCalledWith('sample');
|
||||||
|
|
||||||
await Promise.resolve()
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.')
|
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('disableOpacity toggles body opacity when enabled', () => {
|
it('disableOpacity toggles body opacity when enabled', () => {
|
||||||
const result = utils.disableOpacity()
|
const result = utils.disableOpacity();
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true);
|
||||||
expect(document.body.getAttribute('style')).toBe('opacity: 1.0')
|
expect(document.body.getAttribute('style')).toBe('opacity: 1.0');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('disableOpacity returns false when background disabled', () => {
|
it('disableOpacity returns false when background disabled', () => {
|
||||||
document.body.removeAttribute('style')
|
document.body.removeAttribute('style');
|
||||||
storageMap.clear()
|
storageMap.clear();
|
||||||
|
const originalOpacity = document.body.style.opacity;
|
||||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||||
if (key === 'random_bg') {
|
if (key === 'random_bg') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
if (!storageMap.has(key)) {
|
if (!storageMap.has(key)) {
|
||||||
storageMap.set(key, { value: defaultValue })
|
storageMap.set(key, { value: defaultValue });
|
||||||
}
|
}
|
||||||
return storageMap.get(key)
|
return storageMap.get(key);
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = utils.disableOpacity()
|
const result = utils.disableOpacity();
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false);
|
||||||
expect(document.body.getAttribute('style')).toBeNull()
|
expect(document.body.getAttribute('style')).toBeNull();
|
||||||
})
|
expect(document.body.style.opacity || '').toBe(originalOpacity || '');
|
||||||
|
});
|
||||||
|
|
||||||
it('enableOpacity applies stored opacity value', () => {
|
it('enableOpacity applies stored opacity value', () => {
|
||||||
|
utils.disableOpacity();
|
||||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||||
if (!storageMap.has(key)) {
|
if (!storageMap.has(key)) {
|
||||||
storageMap.set(key, { value: defaultValue })
|
storageMap.set(key, { value: defaultValue });
|
||||||
}
|
}
|
||||||
return storageMap.get(key)
|
return storageMap.get(key);
|
||||||
})
|
});
|
||||||
|
|
||||||
storageMap.set('random_bg_opacity', { value: 0.75 })
|
storageMap.set('random_bg_opacity', { value: 0.75 });
|
||||||
const result = utils.enableOpacity()
|
const result = utils.enableOpacity();
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true);
|
||||||
expect(document.body.getAttribute('style')).toBe('opacity: 0.75')
|
expect(document.body.getAttribute('style')).toBe('opacity: 0.75');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('network and id helpers', () => {
|
describe('network and id helpers', () => {
|
||||||
it('request prefixes relative urls and sets defaults', async () => {
|
it('request prefixes relative urls and sets defaults', async () => {
|
||||||
const responseMock = { status: 200 } as Response
|
const responseMock = { status: 200 } as Response;
|
||||||
fetchMock.mockResolvedValue(responseMock)
|
fetchMock.mockResolvedValue(responseMock);
|
||||||
|
|
||||||
const response = await utils.request('/api/test')
|
const response = await utils.request('/api/test');
|
||||||
|
|
||||||
expect(response).toBe(responseMock)
|
expect(response).toBe(responseMock);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [url, options] = fetchMock.mock.calls[0]!
|
const [url, options] = fetchMock.mock.calls[0]!;
|
||||||
expect(url).toBe('/base-path/api/test')
|
expect(url).toBe('/base-path/api/test');
|
||||||
expect(options?.method).toBe('GET')
|
expect(options?.method).toBe('GET');
|
||||||
expect(options?.credentials).toBe('same-origin')
|
expect(options?.credentials).toBe('same-origin');
|
||||||
expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json')
|
expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||||
expect((options?.headers as Record<string, string>)['Accept']).toBe('application/json')
|
expect((options?.headers as Record<string, string>)['Accept']).toBe('application/json');
|
||||||
expect((options as Record<string, unknown>).withCredentials).toBe(true)
|
expect((options as Record<string, unknown>).withCredentials).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('convertCliOptions posts payload and returns parsed json', async () => {
|
it('convertCliOptions posts payload and returns parsed json', async () => {
|
||||||
const jsonMock = mock().mockResolvedValue({ success: true })
|
const jsonMock = mock().mockResolvedValue({ success: true });
|
||||||
const responseMock = { status: 200, json: jsonMock }
|
const responseMock = { status: 200, json: jsonMock };
|
||||||
fetchMock.mockResolvedValue(responseMock)
|
fetchMock.mockResolvedValue(responseMock);
|
||||||
|
|
||||||
const result = await utils.convertCliOptions('--help')
|
const result = await utils.convertCliOptions('--help');
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [url, options] = fetchMock.mock.calls[0]!
|
const [url, options] = fetchMock.mock.calls[0]!;
|
||||||
expect(url).toBe('/base-path/api/yt-dlp/convert')
|
expect(url).toBe('/base-path/api/yt-dlp/convert');
|
||||||
expect(options?.method).toBe('POST')
|
expect(options?.method).toBe('POST');
|
||||||
expect(options?.body).toBe(JSON.stringify({ args: '--help' }))
|
expect(options?.body).toBe(JSON.stringify({ args: '--help' }));
|
||||||
expect(jsonMock).toHaveBeenCalled()
|
expect(jsonMock).toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: true })
|
expect(result).toEqual({ success: true });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('convertCliOptions throws on non-200 response', async () => {
|
it('convertCliOptions throws on non-200 response', async () => {
|
||||||
const jsonMock = mock().mockResolvedValue({ error: 'fail' })
|
const jsonMock = mock().mockResolvedValue({ error: 'fail' });
|
||||||
const responseMock = { status: 400, json: jsonMock }
|
const responseMock = { status: 400, json: jsonMock };
|
||||||
fetchMock.mockResolvedValue(responseMock)
|
fetchMock.mockResolvedValue(responseMock);
|
||||||
|
|
||||||
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail')
|
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('makeId uses crypto random values for deterministic id', () => {
|
it('makeId uses crypto random values for deterministic id', () => {
|
||||||
const id = utils.makeId(4)
|
const id = utils.makeId(4);
|
||||||
expect(id).toBe('0101')
|
expect(id).toBe('0101');
|
||||||
expect(getRandomValuesMock).toHaveBeenCalled()
|
expect(getRandomValuesMock).toHaveBeenCalled();
|
||||||
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array
|
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array;
|
||||||
expect(typedArray).toBeInstanceOf(Uint8Array)
|
expect(typedArray).toBeInstanceOf(Uint8Array);
|
||||||
expect(typedArray.length).toBe(2)
|
expect(typedArray.length).toBe(2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('async helpers', () => {
|
describe('async helpers', () => {
|
||||||
it('awaiter resolves when test becomes truthy', async () => {
|
it('awaiter resolves when test becomes truthy', async () => {
|
||||||
const values = [false, false, 'done']
|
const values = [false, false, 'done'];
|
||||||
const result = await utils.awaiter(() => values.shift(), 500, 0.01)
|
const result = await utils.awaiter(() => values.shift(), 500, 0.01);
|
||||||
expect(result).toBe('done')
|
expect(result).toBe('done');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('awaiter returns false when timeout reached', async () => {
|
it('awaiter returns false when timeout reached', async () => {
|
||||||
const result = await utils.awaiter(() => false, 50, 0.01)
|
const result = await utils.awaiter(() => false, 50, 0.01);
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
20
ui/tests/utils/ytdlpOptions.test.ts
Normal file
20
ui/tests/utils/ytdlpOptions.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,45 +13,11 @@
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"html": {
|
"html": {
|
||||||
"vue-directives": [
|
"vue-directives": [
|
||||||
{
|
|
||||||
"name": "tooltip",
|
|
||||||
"description": "Create a tooltip for an element.",
|
|
||||||
"doc-url": "",
|
|
||||||
"attribute-value": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"modifiers": [
|
|
||||||
{
|
|
||||||
"name": "top",
|
|
||||||
"description": "Position the tooltip at the top of the element."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bottom",
|
|
||||||
"description": "Position the tooltip at the bottom of the element."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "left",
|
|
||||||
"description": "Position the tooltip at the left of the element."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "right",
|
|
||||||
"description": "Position the tooltip at the right of the element."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "autoscroll",
|
"name": "autoscroll",
|
||||||
"description": "Automatically scroll to an element.",
|
"description": "Automatically scroll to an element.",
|
||||||
"doc-url": ""
|
"doc-url": ""
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"vue-components": [
|
|
||||||
{
|
|
||||||
"name": "VTooltip",
|
|
||||||
"description": "Create more advanced tooltips.",
|
|
||||||
"doc-url": "https://floating-vue.starpad.dev/guide/component#tooltip"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
uv.lock
74
uv.lock
|
|
@ -134,19 +134,19 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.12.1"
|
version = "4.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
|
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "apprise"
|
name = "apprise"
|
||||||
version = "1.9.8"
|
version = "1.9.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
|
|
@ -157,9 +157,9 @@ dependencies = [
|
||||||
{ name = "requests-oauthlib" },
|
{ name = "requests-oauthlib" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/65/341fce6f9c8848219ee588bed3acfa36314ae32640d79fd5c31bbcf83f04/apprise-1.9.8.tar.gz", hash = "sha256:2e06f9ebad47e67f3f184bb789a7966bec3261a53556c90acb1b1cdb85d84a2c", size = 2025145 }
|
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/5c/69b9eb602d2f80f5914d9ff17ad008023f2cfe108376feefca59ca4cf940/apprise-1.9.8-py3-none-any.whl", hash = "sha256:347051773cc320bc72e23f0579a26a81fbf9208b65dc01eb07301317515b1c5b", size = 1517521 },
|
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -173,11 +173,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "25.4.0"
|
version = "26.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 }
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 },
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1323,11 +1323,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pysubs2"
|
name = "pysubs2"
|
||||||
version = "1.8.0"
|
version = "1.8.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/31/4a/becf78d9d3df56e6c4a9c50b83794e5436b6c5ab6dd8a3f934e94c89338c/pysubs2-1.8.0.tar.gz", hash = "sha256:3397bb58a4a15b1325ba2ae3fd4d7c214e2c0ddb9f33190d6280d783bb433b20", size = 1130048 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/eff32fcc4babb32b76d7fce6d74995de2d04201f7b43c9a7764554d6ab49/pysubs2-1.8.1.tar.gz", hash = "sha256:af3a288643da87db6bb13dbde70e94c9570765cc8f6423b1c21de11f16d734da", size = 1153150 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/09/0fc0719162e5ad723f71d41cf336f18b6b5054d70dc0fe42ace6b4d2bdc9/pysubs2-1.8.0-py3-none-any.whl", hash = "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", size = 43516 },
|
{ url = "https://files.pythonhosted.org/packages/0b/bb/0dc8b9a38609701ca3f107dc516300b317641bed9b2ef1c964a9ad37ae9e/pysubs2-1.8.1-py3-none-any.whl", hash = "sha256:eb5d8872b7f87208070dd6f0d85fc110b7ad6cc2a7ec422f5b12363e9194e4e4", size = 44269 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1538,7 +1538,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.33.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
|
|
@ -1546,9 +1546,9 @@ dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1632,27 +1632,27 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.6"
|
version = "0.15.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916 }
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953 },
|
{ url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257 },
|
{ url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683 },
|
{ url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986 },
|
{ url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177 },
|
{ url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783 },
|
{ url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201 },
|
{ url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561 },
|
{ url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928 },
|
{ url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186 },
|
{ url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231 },
|
{ url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357 },
|
{ url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583 },
|
{ url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976 },
|
{ url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872 },
|
{ url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271 },
|
{ url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497 },
|
{ url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1845,11 +1845,11 @@ socks = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "w3lib"
|
name = "w3lib"
|
||||||
version = "2.4.0"
|
version = "2.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/91/186665abf1a6d16c0c5ea1f0e681d9c852b45c3a750aa8657f8f956690a8/w3lib-2.4.0.tar.gz", hash = "sha256:e233ad21649b69d0e047a10f30181ae9677524a29f6f71f6f3c758dc0c8d2648", size = 48302 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/91/b2eb59c2cf243de5de1e91c963655df78c015509f51297685a8c86a27b8c/w3lib-2.4.1.tar.gz", hash = "sha256:8dd69ee39ff6398d708c793abc779c334a69bac7cee1cdf71736c669ed6be864", size = 48494 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/f5/ce3ab627e0cb51591c9e3dc4b9b173f15d7f2bec1c0010420b15fc442940/w3lib-2.4.0-py3-none-any.whl", hash = "sha256:260b5a22aeb86ae73213857f69ed20829a45150f8d5b12050b1f02ada414db79", size = 21603 },
|
{ url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue