refactor: switch from pnpm to bun
This commit is contained in:
parent
f2e9dc97f5
commit
befc7df583
120 changed files with 37569 additions and 35175 deletions
33
.github/workflows/build-pr.yml
vendored
33
.github/workflows/build-pr.yml
vendored
|
|
@ -14,7 +14,7 @@ on:
|
||||||
- ".github/ISSUE_TEMPLATE/**"
|
- ".github/ISSUE_TEMPLATE/**"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PNPM_VERSION: 10
|
BUN_VERSION: latest
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
|
|
||||||
|
|
@ -43,49 +43,56 @@ jobs:
|
||||||
- name: Run Python tests
|
- name: Run Python tests
|
||||||
run: uv run pytest app/tests/ -v --tb=short
|
run: uv run pytest app/tests/ -v --tb=short
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Cache bun installation
|
||||||
uses: pnpm/action-setup@v4
|
id: cache-bun
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ env.PNPM_VERSION }}
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: ${{ env.BUN_VERSION }}
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: pnpm install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Prepare frontend (nuxt prepare)
|
- name: Prepare frontend (nuxt prepare)
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: pnpm nuxt prepare
|
run: bun nuxt prepare
|
||||||
|
|
||||||
- name: Run frontend linting
|
- name: Run frontend linting
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run lint
|
run: bun run lint
|
||||||
|
|
||||||
- name: Run frontend type checking
|
- name: Run frontend type checking
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
- name: Run frontend tsc
|
- name: Run frontend tsc
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run lint:tsc
|
run: bun run lint:tsc
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run test:ci
|
run: bun run test:ci
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run generate
|
run: bun run generate
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
|
||||||
35
.github/workflows/main.yml
vendored
35
.github/workflows/main.yml
vendored
|
|
@ -28,7 +28,7 @@ env:
|
||||||
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
|
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
|
||||||
DOCKERHUB_SLUG: arabcoders/ytptube
|
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||||
PNPM_VERSION: 10
|
BUN_VERSION: latest
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
PYTHON_VERSION: "3.13"
|
PYTHON_VERSION: "3.13"
|
||||||
|
|
||||||
|
|
@ -77,57 +77,64 @@ jobs:
|
||||||
- name: Run Python tests
|
- name: Run Python tests
|
||||||
run: uv run pytest app/tests/ -v --tb=short
|
run: uv run pytest app/tests/ -v --tb=short
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Cache bun installation
|
||||||
uses: pnpm/action-setup@v4
|
id: cache-bun
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ env.PNPM_VERSION }}
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: ${{ env.BUN_VERSION }}
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: pnpm install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Prepare frontend (nuxt prepare)
|
- name: Prepare frontend (nuxt prepare)
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: pnpm nuxt prepare
|
run: bun nuxt prepare
|
||||||
|
|
||||||
- name: Run frontend linting
|
- name: Run frontend linting
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run lint
|
run: bun run lint
|
||||||
|
|
||||||
- name: Run frontend type checking
|
- name: Run frontend type checking
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
- name: Run frontend tsc
|
- name: Run frontend tsc
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run lint:tsc
|
run: bun run lint:tsc
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run test:ci
|
run: bun run test:ci
|
||||||
|
|
||||||
- name: Remove dev dependencies
|
- name: Remove dev dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
run: pnpm install --frozen-lockfile --prod --ignore-scripts
|
run: bun install --frozen-lockfile --production --ignore-scripts
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
env:
|
env:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
run: pnpm run generate
|
run: bun run generate
|
||||||
|
|
||||||
- name: Install GitPython
|
- name: Install GitPython
|
||||||
run: pip install gitpython
|
run: pip install gitpython
|
||||||
|
|
|
||||||
23
.github/workflows/native-build.yml
vendored
23
.github/workflows/native-build.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: 3.11
|
PYTHON_VERSION: 3.11
|
||||||
PNPM_VERSION: 10
|
BUN_VERSION: latest
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
|
||||||
|
|
@ -85,24 +85,31 @@ jobs:
|
||||||
uv venv --system-site-packages --relocatable
|
uv venv --system-site-packages --relocatable
|
||||||
uv sync --link-mode=copy --active --extra installer
|
uv sync --link-mode=copy --active --extra installer
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Cache bun installation
|
||||||
uses: pnpm/action-setup@v4
|
id: cache-bun
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ env.PNPM_VERSION }}
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ matrix.arch }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.arch }}-bun-
|
||||||
|
|
||||||
|
- name: Install bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: ${{ env.BUN_VERSION }}
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: pnpm
|
|
||||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pnpm install --production --prefer-offline --frozen-lockfile
|
bun install --production --prefer-offline --frozen-lockfile
|
||||||
pnpm run generate
|
bun run generate
|
||||||
|
|
||||||
- name: Clean build outputs (Unix)
|
- name: Clean build outputs (Unix)
|
||||||
if: runner.os != 'Windows'
|
if: runner.os != 'Windows'
|
||||||
|
|
|
||||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -10,7 +10,7 @@
|
||||||
"--port",
|
"--port",
|
||||||
"8082"
|
"8082"
|
||||||
],
|
],
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "bun",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"cwd": "${workspaceFolder}/ui",
|
"cwd": "${workspaceFolder}/ui",
|
||||||
"env": {
|
"env": {
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"run",
|
"run",
|
||||||
"generate"
|
"generate"
|
||||||
],
|
],
|
||||||
"runtimeExecutable": "pnpm",
|
"runtimeExecutable": "bun",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"cwd": "${workspaceFolder}/ui",
|
"cwd": "${workspaceFolder}/ui",
|
||||||
"console": "internalConsole",
|
"console": "internalConsole",
|
||||||
|
|
|
||||||
273
.vscode/settings.json
vendored
273
.vscode/settings.json
vendored
|
|
@ -2,218 +2,9 @@
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true
|
"**/__pycache__": true
|
||||||
},
|
},
|
||||||
"autopep8.args": [
|
"editor.rulers": [120],
|
||||||
"--max-line-length",
|
"cSpell.enabled": false,
|
||||||
"120",
|
"css.styleSheets": ["ui/app/assets/css/*.css"],
|
||||||
"--experimental"
|
|
||||||
],
|
|
||||||
"editor.rulers": [
|
|
||||||
120
|
|
||||||
],
|
|
||||||
"cSpell.words": [
|
|
||||||
"aconvert",
|
|
||||||
"addopts",
|
|
||||||
"ahas",
|
|
||||||
"ahash",
|
|
||||||
"aiocron",
|
|
||||||
"aiosqlite",
|
|
||||||
"alnum",
|
|
||||||
"anyio",
|
|
||||||
"Archiver",
|
|
||||||
"arrowdown",
|
|
||||||
"arrowleft",
|
|
||||||
"arrowless",
|
|
||||||
"arrowright",
|
|
||||||
"arrowup",
|
|
||||||
"asyncio",
|
|
||||||
"attl",
|
|
||||||
"autonumber",
|
|
||||||
"autouse",
|
|
||||||
"bgutil",
|
|
||||||
"bgutilhttp",
|
|
||||||
"bilibili",
|
|
||||||
"brainicism",
|
|
||||||
"brotlicffi",
|
|
||||||
"buildcache",
|
|
||||||
"cand",
|
|
||||||
"caplog",
|
|
||||||
"Cfmrc",
|
|
||||||
"choco",
|
|
||||||
"cifs",
|
|
||||||
"connectionpool",
|
|
||||||
"consoletitle",
|
|
||||||
"contenteditable",
|
|
||||||
"continuedl",
|
|
||||||
"cookiesfrombrowser",
|
|
||||||
"copyts",
|
|
||||||
"creationflags",
|
|
||||||
"cronsim",
|
|
||||||
"crosshairs",
|
|
||||||
"currsize",
|
|
||||||
"dailymotion",
|
|
||||||
"datas",
|
|
||||||
"dateparser",
|
|
||||||
"daterange",
|
|
||||||
"defusedxml",
|
|
||||||
"delenv",
|
|
||||||
"denoland",
|
|
||||||
"dlfields",
|
|
||||||
"dotdot",
|
|
||||||
"dotenv",
|
|
||||||
"dpkg",
|
|
||||||
"dunder",
|
|
||||||
"Edes",
|
|
||||||
"edgechromium",
|
|
||||||
"EISGPM",
|
|
||||||
"engineio",
|
|
||||||
"Errno",
|
|
||||||
"esac",
|
|
||||||
"euuo",
|
|
||||||
"eventbus",
|
|
||||||
"excepthook",
|
|
||||||
"exitcode",
|
|
||||||
"falsey",
|
|
||||||
"faststart",
|
|
||||||
"Fetc",
|
|
||||||
"fetchall",
|
|
||||||
"fetchone",
|
|
||||||
"filterwarnings",
|
|
||||||
"finaldir",
|
|
||||||
"flac",
|
|
||||||
"forcejson",
|
|
||||||
"forceprint",
|
|
||||||
"Fpasswd",
|
|
||||||
"fribidi",
|
|
||||||
"Funsafe",
|
|
||||||
"getpid",
|
|
||||||
"gibibytes",
|
|
||||||
"gitea",
|
|
||||||
"gitpython",
|
|
||||||
"googledrive",
|
|
||||||
"gpac",
|
|
||||||
"hiddenimports",
|
|
||||||
"hookspath",
|
|
||||||
"httpx",
|
|
||||||
"imagetools",
|
|
||||||
"isinstance",
|
|
||||||
"jmespath",
|
|
||||||
"jsonschema",
|
|
||||||
"kibibytes",
|
|
||||||
"Kodi",
|
|
||||||
"kwdefaults",
|
|
||||||
"lastgroup",
|
|
||||||
"levelno",
|
|
||||||
"libcurl",
|
|
||||||
"libjavascriptcoregtk",
|
|
||||||
"libmfx",
|
|
||||||
"libstdc",
|
|
||||||
"libwebkit",
|
|
||||||
"libx",
|
|
||||||
"LPAREN",
|
|
||||||
"matchtitle",
|
|
||||||
"matroska",
|
|
||||||
"mbed",
|
|
||||||
"mccabe",
|
|
||||||
"mebibytes",
|
|
||||||
"MEIPASS",
|
|
||||||
"merch",
|
|
||||||
"metaclass",
|
|
||||||
"Metaclasses",
|
|
||||||
"metadataparser",
|
|
||||||
"mgmt",
|
|
||||||
"Microformat",
|
|
||||||
"microformats",
|
|
||||||
"mkvtoolsnix",
|
|
||||||
"MMDD",
|
|
||||||
"movflags",
|
|
||||||
"mpegts",
|
|
||||||
"msvideo",
|
|
||||||
"mult",
|
|
||||||
"multidict",
|
|
||||||
"muxdelay",
|
|
||||||
"mweb",
|
|
||||||
"nfsvers",
|
|
||||||
"nodesc",
|
|
||||||
"noninteractive",
|
|
||||||
"noprogress",
|
|
||||||
"onefile",
|
|
||||||
"oneline",
|
|
||||||
"onupdate",
|
|
||||||
"parsel",
|
|
||||||
"pathex",
|
|
||||||
"pickleable",
|
|
||||||
"platformdirs",
|
|
||||||
"playlistend",
|
|
||||||
"playlistrandom",
|
|
||||||
"playlistreverse",
|
|
||||||
"plexmatch",
|
|
||||||
"postprocessor",
|
|
||||||
"preferredcodec",
|
|
||||||
"preferredquality",
|
|
||||||
"printtraffic",
|
|
||||||
"procps",
|
|
||||||
"pycryptodome",
|
|
||||||
"pycryptodomex",
|
|
||||||
"pyinstaller",
|
|
||||||
"pypi",
|
|
||||||
"pythonpath",
|
|
||||||
"quicktime",
|
|
||||||
"ratecontrol",
|
|
||||||
"rejecttitle",
|
|
||||||
"remux",
|
|
||||||
"reqs",
|
|
||||||
"restrictfilenames",
|
|
||||||
"RPAREN",
|
|
||||||
"rtime",
|
|
||||||
"rvfc",
|
|
||||||
"sessionmaker",
|
|
||||||
"SIGUSR",
|
|
||||||
"smhd",
|
|
||||||
"socketio",
|
|
||||||
"softprops",
|
|
||||||
"solverr",
|
|
||||||
"SQLA",
|
|
||||||
"sstr",
|
|
||||||
"startswith",
|
|
||||||
"SUPPRESSHELP",
|
|
||||||
"tablehead",
|
|
||||||
"tebibytes",
|
|
||||||
"testpaths",
|
|
||||||
"threadsafe",
|
|
||||||
"tiktok",
|
|
||||||
"timespec",
|
|
||||||
"tinyurl",
|
|
||||||
"tmpfilename",
|
|
||||||
"toplevel",
|
|
||||||
"trixie",
|
|
||||||
"ungroup",
|
|
||||||
"unmark",
|
|
||||||
"unnegated",
|
|
||||||
"unpickleable",
|
|
||||||
"Unraid",
|
|
||||||
"upgrader",
|
|
||||||
"urandom",
|
|
||||||
"urlparse",
|
|
||||||
"urlsafe",
|
|
||||||
"usegmt",
|
|
||||||
"usermod",
|
|
||||||
"ustr",
|
|
||||||
"vainfo",
|
|
||||||
"vcodec",
|
|
||||||
"vconvert",
|
|
||||||
"Vitest",
|
|
||||||
"writedescription",
|
|
||||||
"WSEP",
|
|
||||||
"xerror",
|
|
||||||
"youtu",
|
|
||||||
"youtubepot",
|
|
||||||
"zstd",
|
|
||||||
"русский",
|
|
||||||
"العربية"
|
|
||||||
],
|
|
||||||
"css.styleSheets": [
|
|
||||||
"ui/app/assets/css/*.css"
|
|
||||||
],
|
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"ui/.nuxt": true,
|
"ui/.nuxt": true,
|
||||||
"ui/.output": true,
|
"ui/.output": true,
|
||||||
|
|
@ -221,51 +12,23 @@
|
||||||
"ui/exported": true,
|
"ui/exported": true,
|
||||||
"ui/node_modules": true,
|
"ui/node_modules": true,
|
||||||
"ui/.out": true,
|
"ui/.out": true,
|
||||||
"**/.venv": true,
|
"**/.venv": true
|
||||||
},
|
},
|
||||||
"eslint.format.enable": true,
|
"eslint.format.enable": false,
|
||||||
"eslint.ignoreUntitled": true,
|
"eslint.ignoreUntitled": true,
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
"json.schemas": [
|
"python.testing.pytestArgs": ["app"],
|
||||||
{
|
"oxc.path.oxfmt": "./ui/node_modules/.bin/oxfmt",
|
||||||
"fileMatch": [
|
"oxc.fmt.configPath": "./ui/.oxfmtrc.json",
|
||||||
"**/tasks/*.json"
|
"[typescript]": {
|
||||||
],
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
"url": "./app/schema/task_definition.json"
|
"editor.formatOnSave": true,
|
||||||
},
|
"editor.formatOnSaveMode": "file"
|
||||||
{
|
},
|
||||||
"fileMatch": [
|
"[vue]": {
|
||||||
"**/config/conditions.json"
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
],
|
"editor.formatOnSave": true,
|
||||||
"url": "./app/schema/conditions.json"
|
"editor.formatOnSaveMode": "file"
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"fileMatch": [
|
|
||||||
"**/config/dl_fields.json"
|
|
||||||
],
|
|
||||||
"url": "./app/schema/dl_fields.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fileMatch": [
|
|
||||||
"**/config/notifications.json"
|
|
||||||
],
|
|
||||||
"url": "./app/schema/notifications.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fileMatch": [
|
|
||||||
"**/config/presets.json"
|
|
||||||
],
|
|
||||||
"url": "./app/schema/presets.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fileMatch": [
|
|
||||||
"**/config/tasks.json"
|
|
||||||
],
|
|
||||||
"url": "./app/schema/tasks.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"python.testing.pytestArgs": [
|
|
||||||
"app"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
FROM node:lts-alpine AS node_builder
|
FROM node:lts-alpine AS node_builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN if [ ! -f "/app/exported/index.html" ]; then \
|
RUN if [ ! -f "/app/exported/index.html" ]; then \
|
||||||
npm install -g pnpm && \
|
npm install -g bun && \
|
||||||
NODE_ENV=production pnpm install --frozen-lockfile --prod --ignore-scripts && \
|
NODE_ENV=production bun install --frozen-lockfile --production && \
|
||||||
pnpm run generate; \
|
bun run generate; \
|
||||||
else echo "Skipping UI build, already built."; fi
|
else echo "Skipping UI build, already built."; fi
|
||||||
|
|
||||||
FROM python:3.13-bookworm AS python_builder
|
FROM python:3.13-bookworm AS python_builder
|
||||||
|
|
|
||||||
13
ui/.oxfmtrc.json
Normal file
13
ui/.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"experimentalSortPackageJson": false,
|
||||||
|
"ignorePatterns": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
31746
ui/app/assets/css/bulma.css
vendored
31746
ui/app/assets/css/bulma.css
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -142,7 +142,7 @@ hr {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
background-color: #F5F5F5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress,
|
.progress,
|
||||||
|
|
@ -292,7 +292,7 @@ hr {
|
||||||
}
|
}
|
||||||
|
|
||||||
.transparent-bg {
|
.transparent-bg {
|
||||||
opacity: 0.90;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-fanart {
|
.bg-fanart {
|
||||||
|
|
@ -357,7 +357,7 @@ div.is-centered {
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-overflow-visible {
|
.is-overflow-visible {
|
||||||
overflow: visible !important
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-max {
|
.modal-content-max {
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,29 @@
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||||
|
/></span>
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon" v-if="reference">
|
<div class="card-header-icon" v-if="reference">
|
||||||
<button type="button" @click="showImport = !showImport">
|
<button type="button" @click="showImport = !showImport">
|
||||||
<span class="icon"><i class="fa-solid" :class="{
|
<span class="icon"
|
||||||
'fa-arrow-down': !showImport,
|
><i
|
||||||
'fa-arrow-up': showImport,
|
class="fa-solid"
|
||||||
}" /></span>
|
:class="{
|
||||||
|
'fa-arrow-down': !showImport,
|
||||||
|
'fa-arrow-up': showImport,
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
|
|
||||||
<div class="column is-12" v-if="showImport || !reference">
|
<div class="column is-12" v-if="showImport || !reference">
|
||||||
<label class="label is-inline" for="import_string">
|
<label class="label is-inline" for="import_string">
|
||||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||||
|
|
@ -33,11 +37,22 @@
|
||||||
|
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="import_string"
|
||||||
|
v-model="import_string"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
:disabled="!import_string"
|
||||||
|
type="button"
|
||||||
|
@click="importItem"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
<span>Import</span>
|
<span>Import</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -56,8 +71,14 @@
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"
|
<input
|
||||||
placeholder="For the problematic channel or video name.">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="For the problematic channel or video name."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -73,8 +94,13 @@
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div class="control is-unselectable">
|
<div class="control is-unselectable">
|
||||||
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
<input
|
||||||
class="switch is-success" />
|
id="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="form.enabled"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="switch is-success"
|
||||||
|
/>
|
||||||
<label for="enabled" class="is-unselectable">
|
<label for="enabled" class="is-unselectable">
|
||||||
{{ form.enabled ? 'Yes' : 'No' }}
|
{{ form.enabled ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -93,8 +119,15 @@
|
||||||
Priority
|
Priority
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="number" class="input" id="priority" v-model.number="form.priority"
|
<input
|
||||||
:disabled="addInProgress" min="0" placeholder="0">
|
type="number"
|
||||||
|
class="input"
|
||||||
|
id="priority"
|
||||||
|
v-model.number="form.priority"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -109,19 +142,28 @@
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
Condition Filter
|
Condition Filter
|
||||||
<template v-if="!addInProgress || form.filter">
|
<template v-if="!addInProgress || form.filter">
|
||||||
- <NuxtLink @click="test_data.show = true" class="is-bold">
|
-
|
||||||
|
<NuxtLink @click="test_data.show = true" class="is-bold">
|
||||||
Test filter logic
|
Test filter logic
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress"
|
<input
|
||||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="filter"
|
||||||
|
v-model="form.filter"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
|
<span
|
||||||
support.</span>
|
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
|
||||||
|
<code>||</code> support.</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,15 +174,22 @@
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Command options for yt-dlp</span>
|
<span>Command options for yt-dlp</span>
|
||||||
</label>
|
</label>
|
||||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
<TextareaAutocomplete
|
||||||
:disabled="addInProgress" />
|
id="cli_options"
|
||||||
|
v-model="form.cli"
|
||||||
|
:options="ytDlpOpt"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
|
||||||
supported <NuxtLink target="_blank"
|
options are supported
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
<NuxtLink
|
||||||
are ignored</NuxtLink>. Use with caution.
|
target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
|
||||||
|
>some are ignored</NuxtLink
|
||||||
|
>. Use with caution.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,18 +203,38 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3">
|
<div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3">
|
||||||
<div v-for="(value, key) in form.extras" :key="key" class="field is-grouped mb-2">
|
<div
|
||||||
|
v-for="(value, key) in form.extras"
|
||||||
|
:key="key"
|
||||||
|
class="field is-grouped mb-2"
|
||||||
|
>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" :value="key" @input="updateExtraKey($event, key)"
|
<input
|
||||||
:disabled="addInProgress" placeholder="key_name">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:value="key"
|
||||||
|
@input="updateExtraKey($event, key)"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="key_name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" :value="value" @input="updateExtraValue(key, $event)"
|
<input
|
||||||
:disabled="addInProgress" placeholder="value">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:value="value"
|
||||||
|
@input="updateExtraValue(key, $event)"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="value"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-danger" @click="removeExtra(key)"
|
<button
|
||||||
:disabled="addInProgress">
|
type="button"
|
||||||
|
class="button is-danger"
|
||||||
|
@click="removeExtra(key)"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,36 +243,60 @@
|
||||||
|
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" v-model="newExtraKey" :disabled="addInProgress"
|
<input
|
||||||
placeholder="new_key" @keyup.enter="addExtra">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="newExtraKey"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="new_key"
|
||||||
|
@keyup.enter="addExtra"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" v-model="newExtraValue" :disabled="addInProgress"
|
<input
|
||||||
placeholder="new_value" @keyup.enter="addExtra">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="newExtraValue"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="new_value"
|
||||||
|
@keyup.enter="addExtra"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-primary" @click="addExtra"
|
<button
|
||||||
:disabled="addInProgress || !newExtraKey || !newExtraValue">
|
type="button"
|
||||||
|
class="button is-primary"
|
||||||
|
@click="addExtra"
|
||||||
|
:disabled="addInProgress || !newExtraKey || !newExtraValue"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
<span>Add</span>
|
<span>Add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="help ">
|
<span class="help">
|
||||||
<div class="message is-info">
|
<div class="message is-info">
|
||||||
<div class="message-body content pl-0 is-small pt-1">
|
<div class="message-body content pl-0 is-small pt-1">
|
||||||
<ul>
|
<ul>
|
||||||
<li>For advanced users only. This feature is meant to be expanded later.</li>
|
<li>
|
||||||
<li>Keys must be lowercase with underscores (e.g., custom_field).</li>
|
For advanced users only. This feature is meant to be expanded later.
|
||||||
<li class="has-text-danger is-bold">You must click on Add to actually add the option.</li>
|
|
||||||
<li>The key <code>ignore_download</code> with value of <code>true</code> will instruct
|
|
||||||
<b>YTPTube</b> to ignore the download and directly mark the item as archived. this is useful
|
|
||||||
to skip certain kind of downloads.
|
|
||||||
</li>
|
</li>
|
||||||
<li>The key <code>set_preset</code> with the name of an existing preset will instruct
|
<li>Keys must be lowercase with underscores (e.g., custom_field).</li>
|
||||||
<b>YTPTube</b> to switch the download to use the specified preset. This is useful
|
<li class="has-text-danger is-bold">
|
||||||
to apply different download settings based on content type or source.
|
You must click on Add to actually add the option.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The key <code>ignore_download</code> with value of
|
||||||
|
<code>true</code> will instruct <b>YTPTube</b> to ignore the download
|
||||||
|
and directly mark the item as archived. this is useful to skip certain
|
||||||
|
kind of downloads.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The key <code>set_preset</code> with the name of an existing preset will
|
||||||
|
instruct <b>YTPTube</b> to switch the download to use the specified
|
||||||
|
preset. This is useful to apply different download settings based on
|
||||||
|
content type or source.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -219,8 +312,13 @@
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress"
|
<textarea
|
||||||
placeholder="Describe what this condition does" />
|
class="textarea"
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="Describe what this condition does"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -228,22 +326,30 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
<button
|
||||||
:class="{ 'is-loading': addInProgress }" form="addForm">
|
class="button is-fullwidth is-primary"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="submit"
|
||||||
|
:class="{ 'is-loading': addInProgress }"
|
||||||
|
form="addForm"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
<button
|
||||||
type="button">
|
class="button is-fullwidth is-danger"
|
||||||
|
@click="emitter('cancel')"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -267,12 +373,23 @@
|
||||||
<div class="field-body">
|
<div class="field-body">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="url" class="input " id="url" v-model="test_data.url"
|
<input
|
||||||
:disabled="test_data.in_progress" placeholder="https://..." required>
|
type="url"
|
||||||
|
class="input"
|
||||||
|
id="url"
|
||||||
|
v-model="test_data.url"
|
||||||
|
:disabled="test_data.in_progress"
|
||||||
|
placeholder="https://..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" type="submit" :disabled="test_data.in_progress"
|
<button
|
||||||
:class="{ 'is-loading': test_data.in_progress }">
|
class="button is-primary"
|
||||||
|
type="submit"
|
||||||
|
:disabled="test_data.in_progress"
|
||||||
|
:class="{ 'is-loading': test_data.in_progress }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Test</span>
|
<span>Test</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -291,33 +408,49 @@
|
||||||
Condition Filter
|
Condition Filter
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="test_data.in_progress"
|
<input
|
||||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'" required>
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="filter"
|
||||||
|
v-model="form.filter"
|
||||||
|
:disabled="test_data.in_progress"
|
||||||
|
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
|
<span
|
||||||
support.</span>
|
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
|
||||||
|
<code>||</code> support.</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="is-bold" :class="{
|
<span
|
||||||
'has-text-success': true === logic_test,
|
class="is-bold"
|
||||||
'has-text-danger': false === logic_test,
|
:class="{
|
||||||
}">
|
'has-text-success': true === logic_test,
|
||||||
<span class="icon"><i class="fa-solid" :class="{
|
'has-text-danger': false === logic_test,
|
||||||
'fa-check': true === logic_test,
|
}"
|
||||||
'fa-xmark': false === logic_test,
|
>
|
||||||
'fa-question': null === logic_test,
|
<span class="icon"
|
||||||
}" /></span>
|
><i
|
||||||
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-check': true === logic_test,
|
||||||
|
'fa-xmark': false === logic_test,
|
||||||
|
'fa-question': null === logic_test,
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
Filter Status:
|
Filter Status:
|
||||||
<template v-if="null === test_data?.data?.status">Not tested</template>
|
<template v-if="null === test_data?.data?.status">Not tested</template>
|
||||||
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
|
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<pre style="height:60vh;"><code>{{ show_data() }}</code></pre>
|
<pre style="height: 60vh"><code>{{ show_data() }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,310 +464,323 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css';
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import type { Condition } from '~/types/conditions'
|
import type { Condition } from '~/types/conditions';
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import type { ImportedItem } from '~/types';
|
import type { ImportedItem } from '~/types';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void;
|
||||||
(e: 'submit', payload: { reference: number | null | undefined, item: Condition }): void
|
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reference?: number | null
|
reference?: number | null;
|
||||||
item: Condition
|
item: Condition;
|
||||||
addInProgress?: boolean
|
addInProgress?: boolean;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage('showImport', false);
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
|
|
||||||
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)))
|
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)));
|
||||||
const import_string = ref('')
|
const import_string = ref('');
|
||||||
const newExtraKey = ref('')
|
const newExtraKey = ref('');
|
||||||
const newExtraValue = ref('')
|
const newExtraValue = ref('');
|
||||||
const test_data = ref<{
|
const test_data = ref<{
|
||||||
show: boolean,
|
show: boolean;
|
||||||
url: string,
|
url: string;
|
||||||
in_progress: boolean,
|
in_progress: boolean;
|
||||||
changed: boolean,
|
changed: boolean;
|
||||||
data: { status: boolean | null, data: Record<string, any> }
|
data: { status: boolean | null; data: Record<string, any> };
|
||||||
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
|
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } });
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false);
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||||
|
|
||||||
if (!form.extras) {
|
if (!form.extras) {
|
||||||
form.extras = {}
|
form.extras = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.enabled === undefined) {
|
if (form.enabled === undefined) {
|
||||||
form.enabled = true
|
form.enabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.priority === undefined) {
|
if (form.priority === undefined) {
|
||||||
form.priority = 0
|
form.priority = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.description === undefined) {
|
if (form.description === undefined) {
|
||||||
form.description = ''
|
form.description = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(
|
||||||
.filter(opt => !opt.ignored)
|
() => config.ytdlp_options,
|
||||||
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
|
(newOptions) =>
|
||||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
(ytDlpOpt.value = newOptions
|
||||||
{ immediate: true }
|
.filter((opt) => !opt.ignored)
|
||||||
)
|
.flatMap((opt) =>
|
||||||
|
opt.flags
|
||||||
|
.filter((flag) => flag.startsWith('--'))
|
||||||
|
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
|
)),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
watch(() => form.filter, () => test_data.value.changed = true)
|
watch(
|
||||||
|
() => form.filter,
|
||||||
|
() => (test_data.value.changed = true),
|
||||||
|
);
|
||||||
|
|
||||||
const checkInfo = async (): Promise<void> => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
const required: (keyof Condition)[] = ['name', 'filter']
|
const required: (keyof Condition)[] = ['name', 'filter'];
|
||||||
|
|
||||||
for (const key of required) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) {
|
if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) {
|
||||||
toast.error('Command options for yt-dlp or at least one extra option is required.')
|
toast.error('Command options for yt-dlp or at least one extra option is required.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.cli && '' !== form.cli.trim()) {
|
if (form.cli && '' !== form.cli.trim()) {
|
||||||
const options = await convertOptions(form.cli)
|
const options = await convertOptions(form.cli);
|
||||||
if (options === null) {
|
if (options === null) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
form.cli = form.cli.trim()
|
form.cli = form.cli.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: Condition = JSON.parse(JSON.stringify(form))
|
const copy: Condition = JSON.parse(JSON.stringify(form));
|
||||||
|
|
||||||
for (const key in copy) {
|
for (const key in copy) {
|
||||||
if (typeof (copy as any)[key] !== 'string') {
|
if (typeof (copy as any)[key] !== 'string') {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
(copy as any)[key] = (copy as any)[key].trim()
|
(copy as any)[key] = (copy as any)[key].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||||
}
|
};
|
||||||
|
|
||||||
const convertOptions = async (args: string): Promise<any | null> => {
|
const convertOptions = async (args: string): Promise<any | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await convertCliOptions(args)
|
const response = await convertCliOptions(args);
|
||||||
return response.opts
|
return response.opts;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message)
|
toast.error(e.message);
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const run_test = async (): Promise<void> => {
|
const run_test = async (): Promise<void> => {
|
||||||
if (!test_data.value.url) {
|
if (!test_data.value.url) {
|
||||||
toast.error('The URL is required for testing.', { force: true })
|
toast.error('The URL is required for testing.', { force: true });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(test_data.value.url)
|
new URL(test_data.value.url);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('The URL is invalid.', { force: true })
|
toast.error('The URL is invalid.', { force: true });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
test_data.value.in_progress = true
|
test_data.value.in_progress = true;
|
||||||
test_data.value.data.status = false
|
test_data.value.data.status = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/conditions/test', {
|
const response = await request('/api/conditions/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url: test_data.value.url, condition: form.filter })
|
body: JSON.stringify({ url: test_data.value.url, condition: form.filter }),
|
||||||
})
|
});
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(json.message || json.error || 'Unknown error', { force: true })
|
toast.error(json.message || json.error || 'Unknown error', { force: true });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
test_data.value.data = json
|
test_data.value.data = json;
|
||||||
test_data.value.changed = false
|
test_data.value.changed = false;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to test condition. ${error.message}`)
|
toast.error(`Failed to test condition. ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
test_data.value.in_progress = false
|
test_data.value.in_progress = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
const val = import_string.value.trim()
|
const val = import_string.value.trim();
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as Condition & ImportedItem
|
const item = decode(val) as Condition & ImportedItem;
|
||||||
|
|
||||||
if (!item._type || item._type !== 'condition') {
|
if (!item._type || item._type !== 'condition') {
|
||||||
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
|
toast.error(
|
||||||
return
|
`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((form.filter || form.cli || Object.keys(form.extras).length > 0) && !(await box.confirm('Overwrite the current form fields?'))) {
|
if (
|
||||||
return
|
(form.filter || form.cli || Object.keys(form.extras).length > 0) &&
|
||||||
|
!(await box.confirm('Overwrite the current form fields?'))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
form.name = item.name
|
form.name = item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.filter) {
|
if (item.filter) {
|
||||||
form.filter = item.filter
|
form.filter = item.filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.cli) {
|
if (item.cli) {
|
||||||
form.cli = item.cli
|
form.cli = item.cli;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.extras) {
|
if (item.extras) {
|
||||||
form.extras = { ...item.extras }
|
form.extras = { ...item.extras };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.enabled !== undefined) {
|
if (item.enabled !== undefined) {
|
||||||
form.enabled = item.enabled
|
form.enabled = item.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.priority !== undefined) {
|
if (item.priority !== undefined) {
|
||||||
form.priority = item.priority
|
form.priority = item.priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.description !== undefined) {
|
if (item.description !== undefined) {
|
||||||
form.description = item.description
|
form.description = item.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = '';
|
||||||
showImport.value = false
|
showImport.value = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Failed to parse import string. ${e.message}`)
|
toast.error(`Failed to parse import string. ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const show_data = (): string => {
|
const show_data = (): string => {
|
||||||
if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) {
|
if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) {
|
||||||
return 'No data to show.'
|
return 'No data to show.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(test_data.value.data.data, null, 2)
|
return JSON.stringify(test_data.value.data.data, null, 2);
|
||||||
}
|
};
|
||||||
|
|
||||||
const logic_test = computed(() => {
|
const logic_test = computed(() => {
|
||||||
if (Object.keys(test_data.value.data?.data ?? {}).length < 1) {
|
if (Object.keys(test_data.value.data?.data ?? {}).length < 1) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!test_data.value.changed) {
|
if (!test_data.value.changed) {
|
||||||
return test_data.value.data.status
|
return test_data.value.data.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const st = match_str(form.filter, test_data.value.data.data)
|
const st = match_str(form.filter, test_data.value.data.data);
|
||||||
console.log('Logic test:', st, form.filter, test_data.value.data.data)
|
console.log('Logic test:', st, form.filter, test_data.value.data.data);
|
||||||
return st
|
return st;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key)
|
const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key);
|
||||||
|
|
||||||
const parseValue = (value: string): string | number | boolean => {
|
const parseValue = (value: string): string | number | boolean => {
|
||||||
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
|
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
|
||||||
return Number(value)
|
return Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('true' === value.toLowerCase()) {
|
if ('true' === value.toLowerCase()) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
if ('false' === value.toLowerCase()) {
|
if ('false' === value.toLowerCase()) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const addExtra = (): void => {
|
const addExtra = (): void => {
|
||||||
const key = newExtraKey.value.trim()
|
const key = newExtraKey.value.trim();
|
||||||
const value = newExtraValue.value.trim()
|
const value = newExtraValue.value.trim();
|
||||||
|
|
||||||
if (!key || !value) {
|
if (!key || !value) {
|
||||||
toast.error('Both key and value are required.')
|
toast.error('Both key and value are required.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateKey(key)) {
|
if (!validateKey(key)) {
|
||||||
toast.error('Key must be lower_case.')
|
toast.error('Key must be lower_case.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.extras[key]) {
|
if (form.extras[key]) {
|
||||||
toast.error(`Key '${key}' already exists.`)
|
toast.error(`Key '${key}' already exists.`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.extras[key] = parseValue(value)
|
form.extras[key] = parseValue(value);
|
||||||
newExtraKey.value = ''
|
newExtraKey.value = '';
|
||||||
newExtraValue.value = ''
|
newExtraValue.value = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeExtra = (key: string): void => {
|
const removeExtra = (key: string): void => {
|
||||||
const { [key]: _, ...rest } = form.extras
|
const { [key]: _, ...rest } = form.extras;
|
||||||
form.extras = rest
|
form.extras = rest;
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateExtraKey = (event: Event, oldKey: string): void => {
|
const updateExtraKey = (event: Event, oldKey: string): void => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement;
|
||||||
const newKey = target.value.trim()
|
const newKey = target.value.trim();
|
||||||
|
|
||||||
if (!newKey) {
|
if (!newKey) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateKey(newKey)) {
|
if (!validateKey(newKey)) {
|
||||||
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.')
|
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.');
|
||||||
target.value = oldKey
|
target.value = oldKey;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newKey !== oldKey) {
|
if (newKey !== oldKey) {
|
||||||
if (form.extras[newKey]) {
|
if (form.extras[newKey]) {
|
||||||
toast.error(`Key '${newKey}' already exists.`)
|
toast.error(`Key '${newKey}' already exists.`);
|
||||||
target.value = oldKey
|
target.value = oldKey;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = form.extras[oldKey]
|
const value = form.extras[oldKey];
|
||||||
const { [oldKey]: _, ...rest } = form.extras
|
const { [oldKey]: _, ...rest } = form.extras;
|
||||||
form.extras = { ...rest, [newKey]: value }
|
form.extras = { ...rest, [newKey]: value };
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateExtraValue = (key: string, event: Event): void => {
|
const updateExtraValue = (key: string, event: Event): void => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement;
|
||||||
const value = target.value.trim()
|
const value = target.value.trim();
|
||||||
form.extras[key] = value ? parseValue(value) : ''
|
form.extras[key] = value ? parseValue(value) : '';
|
||||||
}
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,20 @@
|
||||||
|
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<p class="mb-3 title is-5" v-if="!html_message">{{ message }}</p>
|
<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
|
||||||
|
class="content"
|
||||||
|
v-if="html_message"
|
||||||
|
v-html="html_message"
|
||||||
|
style="max-height: 40vh; overflow: auto"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="options?.length">
|
<div v-if="options?.length">
|
||||||
<hr class="">
|
<hr class="" />
|
||||||
<label v-for="opt in options" :key="opt.key" class="checkbox is-block mb-2 is-unselectable">
|
<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" />
|
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -21,14 +30,23 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="modal-card-foot p-5">
|
<footer class="modal-card-foot p-5">
|
||||||
<div class="field is-grouped" style="width:100%">
|
<div class="field is-grouped" style="width: 100%">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<button ref="confirmBtn" class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm">
|
<button
|
||||||
|
ref="confirmBtn"
|
||||||
|
class="button is-fullwidth"
|
||||||
|
:class="confirm_button_color"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
{{ confirm_button_label }}
|
{{ confirm_button_label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<button class="button is-success is-fullwidth" :class="cancel_button_color" @click="cancel">
|
<button
|
||||||
|
class="button is-success is-fullwidth"
|
||||||
|
:class="cancel_button_color"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
{{ cancel_button_label }}
|
{{ cancel_button_label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -39,74 +57,77 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Confirm'
|
default: 'Confirm',
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Are you sure?'
|
default: 'Are you sure?',
|
||||||
},
|
},
|
||||||
html_message: {
|
html_message: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
type: Array as () => Array<{ key: string; label: string, checked?: boolean }>,
|
type: Array as () => Array<{ key: string; label: string; checked?: boolean }>,
|
||||||
default: () => []
|
default: () => [],
|
||||||
},
|
},
|
||||||
confirm_button_label: {
|
confirm_button_label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Confirm'
|
default: 'Confirm',
|
||||||
},
|
},
|
||||||
cancel_button_label: {
|
cancel_button_label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Cancel'
|
default: 'Cancel',
|
||||||
},
|
},
|
||||||
confirm_button_color: {
|
confirm_button_color: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'is-danger'
|
default: 'is-danger',
|
||||||
},
|
},
|
||||||
cancel_button_color: {
|
cancel_button_color: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'is-success'
|
default: 'is-success',
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'confirm', options: Record<string, boolean>): void
|
(e: 'confirm', options: Record<string, boolean>): void;
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const confirmBtn = ref<HTMLButtonElement | null>(null)
|
const confirmBtn = ref<HTMLButtonElement | null>(null);
|
||||||
const selected = reactive<Record<string, boolean>>({})
|
const selected = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
watch(() => props.visible, async visible => {
|
watch(
|
||||||
if (!visible) {
|
() => props.visible,
|
||||||
return
|
async (visible) => {
|
||||||
}
|
if (!visible) {
|
||||||
|
return;
|
||||||
if (props.options) {
|
|
||||||
for (const opt of props.options) {
|
|
||||||
selected[opt.key] ??= false
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick()
|
if (props.options) {
|
||||||
confirmBtn.value?.focus()
|
for (const opt of props.options) {
|
||||||
}, { immediate: true })
|
selected[opt.key] ??= false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
confirmBtn.value?.focus();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const handleConfirm = () => emit('confirm', { ...selected })
|
const handleConfirm = () => emit('confirm', { ...selected });
|
||||||
const cancel = () => emit('cancel')
|
const cancel = () => emit('cancel');
|
||||||
|
|
||||||
onMounted(() => disableOpacity())
|
onMounted(() => disableOpacity());
|
||||||
onBeforeUnmount(() => enableOpacity())
|
onBeforeUnmount(() => enableOpacity());
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="message is-warning" v-if="status !== 'connected'">
|
<div class="message is-warning" v-if="status !== 'connected'">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<span class="icon"><i class="fas"
|
<span class="icon"
|
||||||
:class="{ 'fa-info-circle': status === 'disconnected', 'fa-spinner fa-pulse': status === 'connecting' }" /></span>
|
><i
|
||||||
|
class="fas"
|
||||||
|
:class="{
|
||||||
|
'fa-info-circle': status === 'disconnected',
|
||||||
|
'fa-spinner fa-pulse': status === 'connecting',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="status === 'disconnected'">
|
<span v-if="status === 'disconnected'">
|
||||||
Websocket connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here
|
Websocket connection lost,
|
||||||
</NuxtLink> to reconnect.
|
<NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here </NuxtLink>
|
||||||
</span>
|
to reconnect.
|
||||||
<span v-else-if="status === 'connecting'">
|
|
||||||
Connecting to websocket server...
|
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="status === 'connecting'"> Connecting to websocket server... </span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { connectionStatus } from '~/stores/SocketStore'
|
import type { connectionStatus } from '~/stores/SocketStore';
|
||||||
defineProps<{ 'status': connectionStatus }>()
|
defineProps<{ status: connectionStatus }>();
|
||||||
defineEmits<{ (e: "reconnect"): void }>()
|
defineEmits<{ (e: 'reconnect'): void }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,22 @@
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||||
|
/></span>
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon" v-if="reference">
|
<div class="card-header-icon" v-if="reference">
|
||||||
<button type="button" @click="showImport = !showImport">
|
<button type="button" @click="showImport = !showImport">
|
||||||
<span class="icon"><i class="fa-solid" :class="{
|
<span class="icon"
|
||||||
'fa-arrow-down': !showImport,
|
><i
|
||||||
'fa-arrow-up': showImport,
|
class="fa-solid"
|
||||||
}" /></span>
|
:class="{
|
||||||
|
'fa-arrow-down': !showImport,
|
||||||
|
'fa-arrow-up': showImport,
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,11 +37,22 @@
|
||||||
|
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="import_string"
|
||||||
|
v-model="import_string"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
:disabled="!import_string"
|
||||||
|
type="button"
|
||||||
|
@click="importItem"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
<span>Import</span>
|
<span>Import</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -65,7 +82,12 @@
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||||
<span>Field Description</span>
|
<span>Field Description</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" v-model="form.description" class="input" :disabled="addInProgress" />
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="form.description"
|
||||||
|
class="input"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
A short description of the field, it will be shown in the UI.
|
A short description of the field, it will be shown in the UI.
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -80,11 +102,17 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
|
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
|
||||||
<select v-model="form.kind" :disabled="addInProgress" class="is-capitalized">
|
<select v-model="form.kind" :disabled="addInProgress" class="is-capitalized">
|
||||||
<option v-for="kind in fieldTypes" :key="`kind-${kind}`" :value="kind" v-text="kind" />
|
<option
|
||||||
|
v-for="kind in fieldTypes"
|
||||||
|
:key="`kind-${kind}`"
|
||||||
|
:value="kind"
|
||||||
|
v-text="kind"
|
||||||
|
/>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
|
Field Type. String is a single line input, Text is a multi-line input, Bool is a
|
||||||
|
checkbox.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,24 +123,36 @@
|
||||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||||
<span>Associated yt-dlp option</span>
|
<span>Associated yt-dlp option</span>
|
||||||
</label>
|
</label>
|
||||||
<InputAutocomplete v-model="form.field" :options="ytDlpOptions" :disabled="addInProgress"
|
<InputAutocomplete
|
||||||
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
|
v-model="form.field"
|
||||||
|
:options="ytDlpOptions"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="Type or select a yt-dlp option"
|
||||||
|
:multiple="false"
|
||||||
|
:openOnFocus="true"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
||||||
|
<code>-w</code>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="column is-6 is-12-mobile">
|
<div class="column is-6 is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
||||||
<span>Field Order</span>
|
<span>Field Order</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="number" v-model.number="form.order" class="input" :disabled="addInProgress" />
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.order"
|
||||||
|
class="input"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
|
The order of the field, used to sort the fields in the UI. Lower numbers will
|
||||||
|
appear first.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,9 +165,11 @@
|
||||||
</label>
|
</label>
|
||||||
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
|
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r"
|
The icon of the field, must be from
|
||||||
target="_blank">
|
<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.
|
font-awesome</NuxtLink
|
||||||
|
>
|
||||||
|
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,15 +179,24 @@
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
<button
|
||||||
:class="{ 'is-loading': addInProgress }" form="dlFieldForm">
|
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 class="icon"><i class="fa-solid fa-save" /></span>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
<button
|
||||||
type="button">
|
class="button is-fullwidth is-danger"
|
||||||
|
@click="emitter('cancel')"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -159,161 +210,172 @@
|
||||||
</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 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 type { ImportedItem } from '~/types';
|
||||||
import { decode } from '~/utils'
|
import { decode } from '~/utils';
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void;
|
||||||
(e: 'submit', payload: { reference: number | null | undefined, item: DLField }): void
|
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reference?: number | null
|
reference?: number | null;
|
||||||
item: DLField
|
item: DLField;
|
||||||
addInProgress?: boolean
|
addInProgress?: boolean;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
|
|
||||||
const fieldTypes = ['string', 'text', 'bool'] as const
|
const fieldTypes = ['string', 'text', 'bool'] as const;
|
||||||
|
|
||||||
const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item)))
|
const form = reactive<DLField>(JSON.parse(JSON.stringify(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 import_string = ref('');
|
||||||
|
|
||||||
if (!form.extras) {
|
if (!form.extras) {
|
||||||
form.extras = {}
|
form.extras = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.kind) {
|
if (!form.kind) {
|
||||||
form.kind = 'string'
|
form.kind = 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.description) {
|
if (!form.description) {
|
||||||
form.description = ''
|
form.description = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.value) {
|
if (!form.value) {
|
||||||
form.value = ''
|
form.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.icon) {
|
if (!form.icon) {
|
||||||
form.icon = ''
|
form.icon = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.order) {
|
if (!form.order) {
|
||||||
form.order = 1
|
form.order = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
watch(
|
||||||
.filter(opt => !opt.ignored)
|
() => config.ytdlp_options,
|
||||||
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
|
(newOptions) =>
|
||||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
(ytDlpOptions.value = newOptions
|
||||||
{ immediate: true }
|
.filter((opt) => !opt.ignored)
|
||||||
)
|
.flatMap((opt) =>
|
||||||
|
opt.flags
|
||||||
|
.filter((flag) => flag.startsWith('--'))
|
||||||
|
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
|
)),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
const val = import_string.value.trim()
|
const val = import_string.value.trim();
|
||||||
if (!val) {
|
if (!val) {
|
||||||
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(val) as DLField & ImportedItem;
|
||||||
|
|
||||||
if (!item._type || item._type !== 'dl_field') {
|
if (!item._type || item._type !== 'dl_field') {
|
||||||
toast.error(`Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`)
|
toast.error(
|
||||||
return
|
`Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((form.name || form.field || form.description) && !(await box.confirm('Overwrite the current form fields?'))) {
|
if (
|
||||||
return
|
(form.name || form.field || form.description) &&
|
||||||
|
!(await box.confirm('Overwrite the current form fields?'))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
form.name = item.name
|
form.name = item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.field) {
|
if (item.field) {
|
||||||
form.field = item.field
|
form.field = item.field;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.description !== undefined) {
|
if (item.description !== undefined) {
|
||||||
form.description = item.description
|
form.description = item.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind) {
|
if (item.kind) {
|
||||||
form.kind = item.kind
|
form.kind = item.kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.icon !== undefined) {
|
if (item.icon !== undefined) {
|
||||||
form.icon = item.icon
|
form.icon = item.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.order !== undefined) {
|
if (item.order !== undefined) {
|
||||||
form.order = item.order
|
form.order = item.order;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.value !== undefined) {
|
if (item.value !== undefined) {
|
||||||
form.value = item.value
|
form.value = item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.extras) {
|
if (item.extras) {
|
||||||
form.extras = { ...item.extras }
|
form.extras = { ...item.extras };
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = '';
|
||||||
showImport.value = false
|
showImport.value = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Failed to parse import string. ${e.message}`)
|
toast.error(`Failed to parse import string. ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const checkInfo = (): void => {
|
const checkInfo = (): void => {
|
||||||
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description']
|
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description'];
|
||||||
|
|
||||||
for (const key of required) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.order || form.order < 1) {
|
if (!form.order || form.order < 1) {
|
||||||
toast.error('Order must be a positive number.')
|
toast.error('Order must be a positive number.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fieldTypes.includes(form.kind)) {
|
if (!fieldTypes.includes(form.kind)) {
|
||||||
toast.error(`Invalid field type: ${form.kind}`)
|
toast.error(`Invalid field type: ${form.kind}`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) {
|
if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) {
|
||||||
toast.error('Invalid field format, it must start with "--" and contain no spaces.')
|
toast.error('Invalid field format, it must start with "--" and contain no spaces.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: DLField = JSON.parse(JSON.stringify(form))
|
const copy: DLField = JSON.parse(JSON.stringify(form));
|
||||||
|
|
||||||
const entries = copy as unknown 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] = entries[key].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="modal is-active" @submit.prevent="updateItems">
|
<form class="modal is-active" @submit.prevent="updateItems">
|
||||||
<div class="modal-background" @click="cancel" />
|
<div class="modal-background" @click="cancel" />
|
||||||
<div class="modal-card" style="min-width:calc(100vw - 30vw);">
|
<div class="modal-card" style="min-width: calc(100vw - 30vw)">
|
||||||
<header class="modal-card-head">
|
<header class="modal-card-head">
|
||||||
<p class="modal-card-title">Manage Custom Fields</p>
|
<p class="modal-card-title">Manage Custom Fields</p>
|
||||||
<button type="button" class="delete" aria-label="close" @click="cancel" />
|
<button type="button" class="delete" aria-label="close" @click="cancel" />
|
||||||
|
|
@ -18,21 +18,33 @@
|
||||||
<div class="is-pulled-right">
|
<div class="is-pulled-right">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button type="button" class="button is-primary" @click="addNewField" :disabled="isLoading">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-primary"
|
||||||
|
@click="addNewField"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button type="button" class="button is-info" @click="loadContent()"
|
<button
|
||||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
|
type="button"
|
||||||
|
class="button is-info"
|
||||||
|
@click="loadContent()"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-hidden-mobile">
|
<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
|
<span class="subtitle"
|
||||||
form to automate some of the yt-dlp options.</span>
|
>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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -40,7 +52,11 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-12 is-12-mobile">
|
<div class="column is-12 is-12-mobile">
|
||||||
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger">
|
<NuxtLink
|
||||||
|
@click="deleteField(index)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="has-text-danger"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-trash" /></span>
|
<span class="icon"><i class="fas fa-trash" /></span>
|
||||||
<span>Delete Field</span>
|
<span>Delete Field</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -54,12 +70,17 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
|
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
|
||||||
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
|
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
|
||||||
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind"
|
<option
|
||||||
v-text="kind" />
|
v-for="kind in Object.values(FieldTypes)"
|
||||||
|
:key="`kind-${kind}`"
|
||||||
|
:value="kind"
|
||||||
|
v-text="kind"
|
||||||
|
/>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
|
Field Type. String is a single line input, Text is a multi-line input, Bool is
|
||||||
|
a checkbox.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,10 +102,17 @@
|
||||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||||
<span>Associated yt-dlp option</span>
|
<span>Associated yt-dlp option</span>
|
||||||
</label>
|
</label>
|
||||||
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
|
<InputAutocomplete
|
||||||
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
|
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">
|
<span class="help is-bold">
|
||||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
||||||
|
<code>-w</code>.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,7 +122,12 @@
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||||
<span>Field Description</span>
|
<span>Field Description</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" v-model="item.description" class="input" :disabled="isLoading" />
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="item.description"
|
||||||
|
class="input"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
A short description of the field, it will be shown in the UI.
|
A short description of the field, it will be shown in the UI.
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -109,7 +142,8 @@
|
||||||
</label>
|
</label>
|
||||||
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
|
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
|
The order of the field, used to sort the fields in the UI. Lower numbers will
|
||||||
|
appear first.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -122,9 +156,11 @@
|
||||||
</label>
|
</label>
|
||||||
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
|
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r"
|
The icon of the field, must be from
|
||||||
target="_blank">
|
<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.
|
font-awesome</NuxtLink
|
||||||
|
>
|
||||||
|
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -140,7 +176,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot p-5">
|
<footer class="modal-card-foot p-5">
|
||||||
<div class="field is-grouped" style="width:100%">
|
<div class="field is-grouped" style="width: 100%">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
|
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
|
||||||
<span class="icon"><i class="fas fa-save" /></span>
|
<span class="icon"><i class="fas fa-save" /></span>
|
||||||
|
|
@ -148,7 +184,12 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<button type="button" class="button is-fullwidth is-danger" @click="cancel" :disabled="isLoading">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-fullwidth is-danger"
|
||||||
|
@click="cancel"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-times" /></span>
|
<span class="icon"><i class="fas fa-times" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -160,132 +201,139 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue';
|
||||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
import type { DLField } from '~/types/dl_fields'
|
import type { DLField } from '~/types/dl_fields';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'cancel'): void }>()
|
const emitter = defineEmits<{ (e: 'cancel'): void }>();
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
const items = ref<DLField[]>([])
|
const items = ref<DLField[]>([]);
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const ytDlpOptions = ref<AutoCompleteOptions>([])
|
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
||||||
|
|
||||||
const FieldTypes = {
|
const FieldTypes = {
|
||||||
STRING: 'string',
|
STRING: 'string',
|
||||||
TEXT: 'text',
|
TEXT: 'text',
|
||||||
BOOL: 'bool'
|
BOOL: 'bool',
|
||||||
}
|
};
|
||||||
|
|
||||||
const cancel = () => emitter('cancel')
|
const cancel = () => emitter('cancel');
|
||||||
|
|
||||||
const loadContent = async () => {
|
const loadContent = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
const response = await request('/api/dl_fields')
|
const response = await request('/api/dl_fields');
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
if (0 === data.length) {
|
if (0 === data.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.value = data
|
items.value = data;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error('Failed to fetch page content.')
|
toast.error('Failed to fetch page content.');
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateItems = async () => {
|
const updateItems = async () => {
|
||||||
|
|
||||||
for (const item of items.value) {
|
for (const item of items.value) {
|
||||||
if (validateItem(item, items.value.indexOf(item) + 1)) {
|
if (validateItem(item, items.value.indexOf(item) + 1)) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
const resp = await request('/api/dl_fields', {
|
const resp = await request('/api/dl_fields', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(items.value)
|
body: JSON.stringify(items.value),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const error = await resp.json()
|
const error = await resp.json();
|
||||||
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`)
|
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Fields updated successfully.')
|
toast.success('Fields updated successfully.');
|
||||||
emitter('cancel')
|
emitter('cancel');
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const addNewField = () => items.value.push({
|
const addNewField = () =>
|
||||||
name: '',
|
items.value.push({
|
||||||
description: '',
|
name: '',
|
||||||
kind: 'string',
|
description: '',
|
||||||
field: '',
|
kind: 'string',
|
||||||
value: '',
|
field: '',
|
||||||
icon: '',
|
value: '',
|
||||||
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
|
icon: '',
|
||||||
extras: {}
|
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 deleteField = (index: number) => items.value.splice(index, 1);
|
||||||
|
|
||||||
const validateItem = (item: DLField, index: number): boolean => {
|
const validateItem = (item: DLField, index: number): boolean => {
|
||||||
const requiredFields = ['name', 'field', 'kind', 'description']
|
const requiredFields = ['name', 'field', 'kind', 'description'];
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
if (!item[field as keyof DLField]) {
|
if (!item[field as keyof DLField]) {
|
||||||
toast.error(`${item.name || index}: Field ${field} is required.`)
|
toast.error(`${item.name || index}: Field ${field} is required.`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.order || item.order < 1) {
|
if (!item.order || item.order < 1) {
|
||||||
toast.error(`${item.name || index}: Order must be a positive number.`)
|
toast.error(`${item.name || index}: Order must be a positive number.`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.values(FieldTypes).includes(item.kind)) {
|
if (!Object.values(FieldTypes).includes(item.kind)) {
|
||||||
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`)
|
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
||||||
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
toast.error(
|
||||||
return false
|
`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
// 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)))
|
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)));
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
watch(
|
||||||
.filter(opt => !opt.ignored)
|
() => config.ytdlp_options,
|
||||||
.flatMap(
|
(newOptions) =>
|
||||||
opt => opt.flags.filter(flag => flag.startsWith('--')
|
(ytDlpOptions.value = newOptions
|
||||||
).map(flag => ({ value: flag, description: opt.description || '' }))),
|
.filter((opt) => !opt.ignored)
|
||||||
{ immediate: true }
|
.flatMap((opt) =>
|
||||||
)
|
opt.flags
|
||||||
|
.filter((flag) => flag.startsWith('--'))
|
||||||
|
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
|
)),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
await loadContent()
|
await loadContent();
|
||||||
})
|
});
|
||||||
onBeforeUnmount(() => enableOpacity())
|
onBeforeUnmount(() => enableOpacity());
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,40 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="icon" class="icon"><i :class="icon" /></span>
|
<span v-if="icon" class="icon"><i :class="icon" /></span>
|
||||||
<span v-tooltip="field ? `yt-dlp option: ${field}` : null" :class="{ 'has-tooltip': field }">{{ label }}</span>
|
<span
|
||||||
|
v-tooltip="field ? `yt-dlp option: ${field}` : null"
|
||||||
|
:class="{ 'has-tooltip': field }"
|
||||||
|
>{{ label }}</span
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</label>
|
</label>
|
||||||
<div class="control is-expanded" v-if="'string' === type">
|
<div class="control is-expanded" v-if="'string' === type">
|
||||||
<input :id="`dlf-${id}`" :type="type" class="input" v-model="model" :placeholder="placeholder"
|
<input
|
||||||
:disabled="disabled" />
|
:id="`dlf-${id}`"
|
||||||
|
:type="type"
|
||||||
|
class="input"
|
||||||
|
v-model="model"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded" v-if="'text' === type">
|
<div class="control is-expanded" v-if="'text' === type">
|
||||||
<textarea class="textarea is-pre" :id="`dlf-${id}`" v-model="model" :placeholder="placeholder"
|
<textarea
|
||||||
:disabled="disabled"></textarea>
|
class="textarea is-pre"
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="model"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded" v-if="'bool' === type">
|
<div class="control is-expanded" v-if="'bool' === type">
|
||||||
<input type="checkbox" :id="`dlf-${id}`" v-model="model" class="switch is-success" :disabled="disabled" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="`dlf-${id}`"
|
||||||
|
v-model="model"
|
||||||
|
class="switch is-success"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
<label :for="`dlf-${id}`" class="label is-unselectable">
|
||||||
{{ model ? 'Yes' : 'No' }}
|
{{ model ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -39,14 +60,14 @@
|
||||||
import type { ModelRef } from 'vue';
|
import type { ModelRef } from 'vue';
|
||||||
import type { DLFieldType } from '~/types/dl_fields';
|
import type { DLFieldType } from '~/types/dl_fields';
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id: number|string,
|
id: number | string;
|
||||||
label: string,
|
label: string;
|
||||||
field?: string,
|
field?: string;
|
||||||
type: DLFieldType,
|
type: DLFieldType;
|
||||||
description?: string
|
description?: string;
|
||||||
icon?: string
|
icon?: string;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}>()
|
}>();
|
||||||
const model = defineModel() as ModelRef<string>
|
const model = defineModel() as ModelRef<string>;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
/* container fades */
|
/* container fades */
|
||||||
.dialog-enter-active,
|
.dialog-enter-active,
|
||||||
.dialog-leave-active {
|
.dialog-leave-active {
|
||||||
transition: opacity .18s ease;
|
transition: opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-enter-from,
|
.dialog-enter-from,
|
||||||
|
|
@ -13,24 +13,31 @@
|
||||||
/* animate the card itself */
|
/* animate the card itself */
|
||||||
.dialog-enter-active .modal-card,
|
.dialog-enter-active .modal-card,
|
||||||
.dialog-leave-active .modal-card {
|
.dialog-leave-active .modal-card {
|
||||||
transition: transform .18s ease, opacity .18s ease;
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-enter-from .modal-card {
|
.dialog-enter-from .modal-card {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
opacity: .98;
|
opacity: 0.98;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-leave-to .modal-card {
|
.dialog-leave-to .modal-card {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
opacity: .98;
|
opacity: 0.98;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<transition name="dialog" @after-enter="focusInput">
|
<transition name="dialog" @after-enter="focusInput">
|
||||||
<div id="app-dialog-host" v-if="state.current" class="modal is-active" @keydown.esc="onCancel">
|
<div
|
||||||
|
id="app-dialog-host"
|
||||||
|
v-if="state.current"
|
||||||
|
class="modal is-active"
|
||||||
|
@keydown.esc="onCancel"
|
||||||
|
>
|
||||||
<div class="modal-background" @click="onCancel" />
|
<div class="modal-background" @click="onCancel" />
|
||||||
<div class="modal-card" @keydown.enter.stop.prevent="onEnter">
|
<div class="modal-card" @keydown.enter.stop.prevent="onEnter">
|
||||||
<header class="modal-card-head p-4">
|
<header class="modal-card-head p-4">
|
||||||
|
|
@ -46,8 +53,14 @@
|
||||||
<!-- prompt input -->
|
<!-- prompt input -->
|
||||||
<div v-if="'prompt' === state.current?.type" class="field">
|
<div v-if="'prompt' === state.current?.type" class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input ref="inputEl" class="input" type="text" v-model="localInput"
|
<input
|
||||||
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop />
|
ref="inputEl"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
v-model="localInput"
|
||||||
|
:placeholder="(state.current?.opts as any)?.placeholder ?? ''"
|
||||||
|
@keyup.stop
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
|
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
|
|
@ -56,8 +69,14 @@
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML"
|
<div
|
||||||
class="content" v-html="(state.current?.opts as ConfirmOptions)?.rawHTML" />
|
v-else-if="
|
||||||
|
'confirm' === state.current?.type &&
|
||||||
|
(state.current?.opts as ConfirmOptions)?.rawHTML
|
||||||
|
"
|
||||||
|
class="content"
|
||||||
|
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
||||||
|
|
@ -70,12 +89,18 @@
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
|
<template
|
||||||
|
v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type"
|
||||||
|
>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button id="primaryButton" class="button" @click="onEnter"
|
<button
|
||||||
|
id="primaryButton"
|
||||||
|
class="button"
|
||||||
|
@click="onEnter"
|
||||||
:class="state.current?.opts.confirmColor ?? 'is-primary'"
|
:class="state.current?.opts.confirmColor ?? 'is-primary'"
|
||||||
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
|
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial"
|
||||||
|
>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-check" /></span>
|
<span class="icon"><i class="fas fa-check" /></span>
|
||||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
||||||
|
|
@ -100,58 +125,61 @@
|
||||||
</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 { 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('');
|
||||||
|
|
||||||
watch(() => state.current, (cur) => {
|
watch(
|
||||||
if (state.current) {
|
() => state.current,
|
||||||
disableOpacity()
|
(cur) => {
|
||||||
}
|
if (state.current) {
|
||||||
else {
|
disableOpacity();
|
||||||
enableOpacity()
|
} else {
|
||||||
}
|
enableOpacity();
|
||||||
|
}
|
||||||
|
|
||||||
localInput.value = 'prompt' === cur?.type ? (cur.opts as any).initial ?? '' : ''
|
localInput.value = 'prompt' === cur?.type ? ((cur.opts as any).initial ?? '') : '';
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const inputEl = ref<HTMLInputElement>()
|
const inputEl = ref<HTMLInputElement>();
|
||||||
const focusPrimary = () => {
|
const focusPrimary = () => {
|
||||||
const root = document.getElementById('app-dialog-host')
|
const root = document.getElementById('app-dialog-host');
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const btn = root.querySelector<HTMLButtonElement>('#primaryButton')
|
const btn = root.querySelector<HTMLButtonElement>('#primaryButton');
|
||||||
btn?.focus()
|
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?.focus({ preventScroll: true }));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(focusPrimary)
|
requestAnimationFrame(focusPrimary);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onCancel = () => cancel()
|
const onCancel = () => cancel();
|
||||||
const onEnter = () => confirm(localInput.value)
|
const onEnter = () => confirm(localInput.value);
|
||||||
|
|
||||||
const defaultTitle = computed(() => {
|
const defaultTitle = computed(() => {
|
||||||
if (!state.current) {
|
if (!state.current) {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
switch (state.current.type) {
|
switch (state.current.type) {
|
||||||
case 'alert':
|
case 'alert':
|
||||||
return 'Alert'
|
return 'Alert';
|
||||||
case 'confirm':
|
case 'confirm':
|
||||||
return 'Confirm'
|
return 'Confirm';
|
||||||
case 'prompt':
|
case 'prompt':
|
||||||
return 'Input required'
|
return 'Input required';
|
||||||
default:
|
default:
|
||||||
return 'Dialog'
|
return 'Dialog';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
||||||
<div class="dropdown-trigger">
|
<div class="dropdown-trigger">
|
||||||
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true" type="button"
|
<button
|
||||||
aria-controls="dropdown-menu" @click="toggle" :class="button_classes">
|
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="icon" v-if="icons"><i :class="icons" /></span>
|
||||||
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
|
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
|
||||||
<div class="is-pulled-right">
|
<div class="is-pulled-right">
|
||||||
|
|
@ -33,131 +39,131 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue';
|
||||||
|
|
||||||
const emitter = defineEmits(['open_state'])
|
const emitter = defineEmits(['open_state']);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Select'
|
default: 'Select',
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null,
|
||||||
},
|
},
|
||||||
button_classes: {
|
button_classes: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: '',
|
||||||
},
|
},
|
||||||
hide_label_on_mobile: {
|
hide_label_on_mobile: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false);
|
||||||
const dropUp = ref(false)
|
const dropUp = ref(false);
|
||||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown')
|
const dropdown = useTemplateRef<HTMLDivElement>('dropdown');
|
||||||
const menu = useTemplateRef<HTMLDivElement>('menu')
|
const menu = useTemplateRef<HTMLDivElement>('menu');
|
||||||
const menuStyle = ref<Record<string, string>>({})
|
const menuStyle = ref<Record<string, string>>({});
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile)
|
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile);
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
if (!dropdown.value || !isOpen.value) {
|
if (!dropdown.value || !isOpen.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
const triggerRect = dropdown.value.getBoundingClientRect();
|
||||||
const menuHeight = menu.value?.offsetHeight || 300
|
const menuHeight = menu.value?.offsetHeight || 300;
|
||||||
const spaceBelow = window.innerHeight - triggerRect.bottom
|
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
||||||
const spaceAbove = triggerRect.top
|
const spaceAbove = triggerRect.top;
|
||||||
|
|
||||||
// Determine if dropdown should appear above or below
|
// Determine if dropdown should appear above or below
|
||||||
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow
|
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow;
|
||||||
dropUp.value = shouldDropUp
|
dropUp.value = shouldDropUp;
|
||||||
|
|
||||||
// Calculate position
|
// Calculate position
|
||||||
const left = triggerRect.left
|
const left = triggerRect.left;
|
||||||
const width = triggerRect.width
|
const width = triggerRect.width;
|
||||||
|
|
||||||
if (shouldDropUp) {
|
if (shouldDropUp) {
|
||||||
// Position above the trigger
|
// Position above the trigger
|
||||||
const bottom = window.innerHeight - triggerRect.top
|
const bottom = window.innerHeight - triggerRect.top;
|
||||||
menuStyle.value = {
|
menuStyle.value = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
bottom: `${bottom}px`,
|
bottom: `${bottom}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
top: 'auto'
|
top: 'auto',
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
// Position below the trigger
|
// Position below the trigger
|
||||||
const top = triggerRect.bottom
|
const top = triggerRect.bottom;
|
||||||
menuStyle.value = {
|
menuStyle.value = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
bottom: 'auto'
|
bottom: 'auto',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
isOpen.value = !isOpen.value
|
isOpen.value = !isOpen.value;
|
||||||
|
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
await nextTick()
|
await nextTick();
|
||||||
updatePosition()
|
updatePosition();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handle_slot_click = (event: MouseEvent) => {
|
const handle_slot_click = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.dropdown-item')) {
|
if (target.closest('.dropdown-item')) {
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handle_event = (event: MouseEvent) => {
|
const handle_event = (event: MouseEvent) => {
|
||||||
if (!dropdown.value) {
|
if (!dropdown.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
|
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
updatePosition()
|
updatePosition();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
updatePosition()
|
updatePosition();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
watchEffect(() => emitter('open_state', isOpen.value))
|
watchEffect(() => emitter('open_state', isOpen.value));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handle_event)
|
document.addEventListener('click', handle_event);
|
||||||
window.addEventListener('scroll', handleScroll, true) // Use capture to catch all scroll events
|
window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize);
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', handle_event)
|
document.removeEventListener('click', handle_event);
|
||||||
window.removeEventListener('scroll', handleScroll, true)
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -192,7 +198,14 @@ onBeforeUnmount(() => {
|
||||||
.dropdown.dropdown-portal .dropdown-content {
|
.dropdown.dropdown-portal .dropdown-content {
|
||||||
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
||||||
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
|
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)));
|
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-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
|
||||||
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,31 +16,31 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
url: {
|
url: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const emitter = defineEmits(['closeModel'])
|
const emitter = defineEmits(['closeModel']);
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
const handle_event = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') {
|
if (e.key !== 'Escape') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
emitter('closeModel')
|
emitter('closeModel');
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', handle_event)
|
document.addEventListener('keydown', handle_event);
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,58 @@
|
||||||
<template>
|
<template>
|
||||||
<ModalText :isLoading="isLoading" :data="data" :externalModel="externalModel"
|
<ModalText
|
||||||
@closeModel="() => emitter('closeModel')" :code_classes="code_classes" />
|
:isLoading="isLoading"
|
||||||
|
:data="data"
|
||||||
|
:externalModel="externalModel"
|
||||||
|
@closeModel="() => emitter('closeModel')"
|
||||||
|
:code_classes="code_classes"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = 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;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const isLoading = ref<boolean>(true)
|
const isLoading = ref<boolean>(true);
|
||||||
const data = ref<any>({})
|
const data = ref<any>({});
|
||||||
|
|
||||||
onMounted(async (): Promise<void> => {
|
onMounted(async (): Promise<void> => {
|
||||||
let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info'
|
let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info';
|
||||||
|
|
||||||
if (!props.useUrl) {
|
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 || '')
|
params.append('url', props.link || '');
|
||||||
url += '?' + params.toString()
|
url += '?' + params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
const body = await response.text()
|
const body = await response.text();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.value = JSON.parse(body)
|
data.value = JSON.parse(body);
|
||||||
} catch {
|
} catch {
|
||||||
data.value = body
|
data.value = body;
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Error: ${e.message}`)
|
toast.error(`Error: ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,66 +9,68 @@ img {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<img :src="image">
|
<img :src="image" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const emitter = defineEmits(['closeModel'])
|
const emitter = defineEmits(['closeModel']);
|
||||||
|
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
const image = ref<string>('')
|
const image = ref<string>('');
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
link: {
|
link: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
const handle_event = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') {
|
if (e.key !== 'Escape') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
emitter('closeModel')
|
emitter('closeModel');
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
document.addEventListener('keydown', handle_event)
|
document.addEventListener('keydown', handle_event);
|
||||||
|
|
||||||
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link)
|
const url = props.link.startsWith('/')
|
||||||
|
? props.link
|
||||||
|
: '/api/thumbnail?url=' + encodePath(props.link);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
|
|
||||||
const imgRequest = await request(url)
|
const imgRequest = await request(url);
|
||||||
if (200 !== imgRequest.status) {
|
if (200 !== imgRequest.status) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
image.value = URL.createObjectURL(await imgRequest.blob())
|
image.value = URL.createObjectURL(await imgRequest.blob());
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Error: ${e.message}`)
|
toast.error(`Error: ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event);
|
||||||
if (image.value) {
|
if (image.value) {
|
||||||
URL.revokeObjectURL(image.value)
|
URL.revokeObjectURL(image.value);
|
||||||
}
|
}
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,40 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
<div
|
||||||
<div class="control" style="width:100%;">
|
class="dropdown"
|
||||||
<input v-model="model" @focus="onFocus" @blur="hideList" @keydown="handleKeydown" @input="onInput" class="input"
|
:class="{ 'is-active': showList && filteredOptions.length }"
|
||||||
:placeholder="placeholder" autocomplete="new-password" style="width:100%; position:relative; z-index:2;"
|
style="width: 100%"
|
||||||
:disabled="disabled" :id="id" />
|
>
|
||||||
|
<div class="control" style="width: 100%">
|
||||||
|
<input
|
||||||
|
v-model="model"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="hideList"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@input="onInput"
|
||||||
|
class="input"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="new-password"
|
||||||
|
style="width: 100%; position: relative; z-index: 2"
|
||||||
|
:disabled="disabled"
|
||||||
|
:id="id"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
<div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
|
||||||
<div class="dropdown-content" style="width:100%; max-height:10em; overflow-y:auto;">
|
<div class="dropdown-content" style="width: 100%; max-height: 10em; overflow-y: auto">
|
||||||
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option.value)"
|
<a
|
||||||
|
v-for="(option, idx) in filteredOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@mousedown.prevent="selectOption(option.value)"
|
||||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||||
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)">
|
style="display: flex; justify-content: space-between"
|
||||||
|
:ref="(el) => setDropdownItemRef(el, idx)"
|
||||||
|
>
|
||||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||||
<abbr class="has-text-grey-light is-text-overflow" :title="option.description" style="margin-left:1em;">
|
<abbr
|
||||||
|
class="has-text-grey-light is-text-overflow"
|
||||||
|
:title="option.description"
|
||||||
|
style="margin-left: 1em"
|
||||||
|
>
|
||||||
{{ option.description }}
|
{{ option.description }}
|
||||||
</abbr>
|
</abbr>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -21,36 +44,39 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed, nextTick } from 'vue'
|
import { ref, watch, computed, nextTick } from 'vue';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
options: AutoCompleteOptions
|
defineProps<{
|
||||||
placeholder?: string
|
options: AutoCompleteOptions;
|
||||||
disabled?: boolean
|
placeholder?: string;
|
||||||
id?: string
|
disabled?: boolean;
|
||||||
multiple?: boolean
|
id?: string;
|
||||||
openOnFocus?: boolean
|
multiple?: boolean;
|
||||||
allowShortFlags?: boolean
|
openOnFocus?: boolean;
|
||||||
}>(), {
|
allowShortFlags?: boolean;
|
||||||
placeholder: '',
|
}>(),
|
||||||
disabled: false,
|
{
|
||||||
id: '',
|
placeholder: '',
|
||||||
multiple: true,
|
disabled: false,
|
||||||
openOnFocus: false,
|
id: '',
|
||||||
allowShortFlags: false
|
multiple: true,
|
||||||
})
|
openOnFocus: false,
|
||||||
|
allowShortFlags: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const model = defineModel<string>()
|
const model = defineModel<string>();
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
|
||||||
highlightedIndex.value = showList.value ? 0 : -1
|
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)[]>([]);
|
||||||
|
|
||||||
// Extract the last non-space token and its bounds
|
// Extract the last non-space token and its bounds
|
||||||
const getLastToken = (value: string) => {
|
const getLastToken = (value: string) => {
|
||||||
|
|
@ -59,211 +85,216 @@ const getLastToken = (value: string) => {
|
||||||
return {
|
return {
|
||||||
token: value,
|
token: value,
|
||||||
start: 0,
|
start: 0,
|
||||||
end: value.length
|
end: value.length,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple enabled: extract last token for multi-flag support
|
// Multiple enabled: extract last token for multi-flag support
|
||||||
const m = (value || '').match(/(\S+)$/)
|
const m = (value || '').match(/(\S+)$/);
|
||||||
const token: string = m?.[1] ?? ''
|
const token: string = m?.[1] ?? '';
|
||||||
const start = m ? (m.index as number) : value.length
|
const start = m ? (m.index as number) : value.length;
|
||||||
const end = m ? start + token.length : value.length
|
const end = m ? start + token.length : value.length;
|
||||||
return { token, start, end }
|
return { token, start, end };
|
||||||
}
|
};
|
||||||
|
|
||||||
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)
|
const { token } = getLastToken(value);
|
||||||
|
|
||||||
// If openOnFocus is enabled and token is empty/just whitespace, show all options
|
// If openOnFocus is enabled and token is empty/just whitespace, show all options
|
||||||
if (props.openOnFocus && !token) {
|
if (props.openOnFocus && !token) {
|
||||||
return props.options
|
return props.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide suggestions when token has '='
|
// Hide suggestions when token has '='
|
||||||
if (!token || token.includes('=')) {
|
if (!token || token.includes('=')) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is a valid flag format
|
// 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 = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
||||||
|
|
||||||
if (!isLongFlag && !isShortFlag) {
|
if (!isLongFlag && !isShortFlag) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for exact match first - if found, only show that
|
// 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
const startsWithFlag = []
|
const startsWithFlag = [];
|
||||||
const includesFlag = []
|
const includesFlag = [];
|
||||||
const includesDesc = []
|
const includesDesc = [];
|
||||||
|
|
||||||
for (const opt of props.options) {
|
for (const opt of props.options) {
|
||||||
const flag = opt.value
|
const flag = opt.value;
|
||||||
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
|
// 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)) {
|
||||||
includesFlag.push(opt)
|
includesFlag.push(opt);
|
||||||
} else if (desc.includes(token.toLowerCase())) {
|
} else if (desc.includes(token.toLowerCase())) {
|
||||||
includesDesc.push(opt)
|
includesDesc.push(opt);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Long flags: case-insensitive matching
|
// Long flags: case-insensitive matching
|
||||||
const val = token.toLowerCase()
|
const val = token.toLowerCase();
|
||||||
const flagLower = flag.toLowerCase()
|
const flagLower = flag.toLowerCase();
|
||||||
|
|
||||||
if (flagLower.startsWith(val)) {
|
if (flagLower.startsWith(val)) {
|
||||||
startsWithFlag.push(opt)
|
startsWithFlag.push(opt);
|
||||||
} else if (flagLower.includes(val)) {
|
} else if (flagLower.includes(val)) {
|
||||||
includesFlag.push(opt)
|
includesFlag.push(opt);
|
||||||
} else if (desc.includes(val)) {
|
} else if (desc.includes(val)) {
|
||||||
includesDesc.push(opt)
|
includesDesc.push(opt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
||||||
})
|
});
|
||||||
|
|
||||||
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 } = getLastToken(value);
|
||||||
|
|
||||||
// If multiple is disabled, replace entire value
|
// If multiple is disabled, replace entire value
|
||||||
if (!props.multiple) {
|
if (!props.multiple) {
|
||||||
// Preserve any '=value' suffix already typed
|
// 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;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple enabled: replace only the last token
|
// Multiple enabled: replace only the last token
|
||||||
if (token) {
|
if (token) {
|
||||||
// Preserve any '=value' suffix already typed for this 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);
|
||||||
} else {
|
} else {
|
||||||
model.value = val
|
model.value = val;
|
||||||
}
|
}
|
||||||
showList.value = false
|
showList.value = false;
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1;
|
||||||
}
|
};
|
||||||
|
|
||||||
const hideList = () => {
|
const hideList = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showList.value = false
|
showList.value = false;
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1;
|
||||||
dropdownItemRefs.value = []
|
dropdownItemRefs.value = [];
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
if (!props.openOnFocus) {
|
if (!props.openOnFocus) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// When openOnFocus is enabled, show dropdown if there are options
|
// 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;
|
||||||
}
|
};
|
||||||
|
|
||||||
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
||||||
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null
|
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(filteredOptions, () => {
|
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')
|
const dropdown = document.querySelector('.dropdown-content');
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.scrollTop = 0
|
dropdown.scrollTop = 0;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const isFlagTrigger = computed(() => {
|
const isFlagTrigger = computed(() => {
|
||||||
const { token } = getLastToken(model.value || '')
|
const { token } = getLastToken(model.value || '');
|
||||||
|
|
||||||
// If openOnFocus is enabled and input is empty, allow trigger
|
// If openOnFocus is enabled and input is empty, allow trigger
|
||||||
if (props.openOnFocus && !token) {
|
if (props.openOnFocus && !token) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token || token.includes('=')) return false
|
if (!token || token.includes('=')) return false;
|
||||||
|
|
||||||
// Check if token is a valid flag format
|
// 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 = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
||||||
|
|
||||||
if (!isLongFlag && !isShortFlag) {
|
if (!isLongFlag && !isShortFlag) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow trigger even for exact matches so users can see descriptions
|
// Allow trigger even for exact matches so users can see descriptions
|
||||||
return true
|
return true;
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
// Escape closes dropdown and lets arrows navigate the input
|
// Escape closes dropdown and lets arrows navigate the input
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
showList.value = false
|
showList.value = false;
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!showList.value || !filteredOptions.value.length) {
|
if (!showList.value || !filteredOptions.value.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = 5
|
const pageSize = 5;
|
||||||
|
|
||||||
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 + pageSize, filteredOptions.value.length - 1)
|
highlightedIndex.value = Math.min(
|
||||||
nextTick(() => scrollHighlightedIntoView())
|
highlightedIndex.value + pageSize,
|
||||||
|
filteredOptions.value.length - 1,
|
||||||
|
);
|
||||||
|
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 = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
const selected =
|
||||||
filteredOptions.value[highlightedIndex.value] : undefined
|
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||||
|
? filteredOptions.value[highlightedIndex.value]
|
||||||
|
: undefined;
|
||||||
if (selected && isFlagTrigger.value) {
|
if (selected && isFlagTrigger.value) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
selectOption(selected.value)
|
selectOption(selected.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function scrollHighlightedIntoView() {
|
function scrollHighlightedIntoView() {
|
||||||
const el = dropdownItemRefs.value[highlightedIndex.value]
|
const el = dropdownItemRefs.value[highlightedIndex.value];
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
el.scrollIntoView({ block: 'nearest' })
|
el.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,73 +5,80 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useIntersectionObserver } from '@vueuse/core'
|
import { useIntersectionObserver } from '@vueuse/core';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
renderOnIdle?: boolean
|
renderOnIdle?: boolean;
|
||||||
unrender?: boolean
|
unrender?: boolean;
|
||||||
minHeight?: number
|
minHeight?: number;
|
||||||
unrenderDelay?: number
|
unrenderDelay?: number;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const shouldRender = ref(false)
|
const shouldRender = ref(false);
|
||||||
const targetEl = ref<HTMLElement | null>(null)
|
const targetEl = ref<HTMLElement | null>(null);
|
||||||
const fixedMinHeight = ref(0)
|
const fixedMinHeight = ref(0);
|
||||||
|
|
||||||
let unrenderTimer: ReturnType<typeof setTimeout> | null = null
|
let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let renderTimer: ReturnType<typeof setTimeout> | null = null
|
let renderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function onIdle(cb: () => void): void {
|
function onIdle(cb: () => void): void {
|
||||||
if ('requestIdleCallback' in window) {
|
if ('requestIdleCallback' in window) {
|
||||||
(window as any).requestIdleCallback(cb)
|
(window as any).requestIdleCallback(cb);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => nextTick(cb), 300)
|
setTimeout(() => nextTick(cb), 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stop } = useIntersectionObserver(targetEl, (entries) => {
|
const { stop } = useIntersectionObserver(
|
||||||
const entry = entries[0]
|
targetEl,
|
||||||
if (entry?.isIntersecting) {
|
(entries) => {
|
||||||
if (unrenderTimer) clearTimeout(unrenderTimer)
|
const entry = entries[0];
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
if (unrenderTimer) clearTimeout(unrenderTimer);
|
||||||
|
|
||||||
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)
|
renderTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
shouldRender.value = true;
|
||||||
|
},
|
||||||
|
props.unrender ? 200 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
shouldRender.value = true
|
shouldRender.value = true;
|
||||||
|
|
||||||
if (!props.unrender) {
|
if (!props.unrender) {
|
||||||
stop()
|
stop();
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (props.unrender) {
|
|
||||||
if (renderTimer) {
|
|
||||||
clearTimeout(renderTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
unrenderTimer = setTimeout(() => {
|
|
||||||
if (targetEl.value?.clientHeight) {
|
|
||||||
fixedMinHeight.value = targetEl.value.clientHeight
|
|
||||||
}
|
}
|
||||||
shouldRender.value = false
|
} else if (props.unrender) {
|
||||||
}, props.unrenderDelay ?? 6000)
|
if (renderTimer) {
|
||||||
}
|
clearTimeout(renderTimer);
|
||||||
}, { rootMargin: '600px', })
|
}
|
||||||
|
|
||||||
|
unrenderTimer = setTimeout(() => {
|
||||||
|
if (targetEl.value?.clientHeight) {
|
||||||
|
fixedMinHeight.value = targetEl.value.clientHeight;
|
||||||
|
}
|
||||||
|
shouldRender.value = false;
|
||||||
|
}, props.unrenderDelay ?? 6000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '600px' },
|
||||||
|
);
|
||||||
|
|
||||||
if (props.renderOnIdle) {
|
if (props.renderOnIdle) {
|
||||||
onIdle(() => {
|
onIdle(() => {
|
||||||
shouldRender.value = true
|
shouldRender.value = true;
|
||||||
if (!props.unrender) {
|
if (!props.unrender) {
|
||||||
stop()
|
stop();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (renderTimer) {
|
if (renderTimer) {
|
||||||
clearTimeout(renderTimer)
|
clearTimeout(renderTimer);
|
||||||
|
|
||||||
}
|
}
|
||||||
if (unrenderTimer) {
|
if (unrenderTimer) {
|
||||||
clearTimeout(unrenderTimer)
|
clearTimeout(unrenderTimer);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -34,39 +34,38 @@
|
||||||
border-left-color: #e5534b;
|
border-left-color: #e5534b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-note>.markdown-alert-title {
|
.markdown-alert-note > .markdown-alert-title {
|
||||||
color: #539bf5;
|
color: #539bf5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-tip>.markdown-alert-title {
|
.markdown-alert-tip > .markdown-alert-title {
|
||||||
color: #57ab5a;
|
color: #57ab5a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-important>.markdown-alert-title {
|
.markdown-alert-important > .markdown-alert-title {
|
||||||
color: #986ee2;
|
color: #986ee2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-warning>.markdown-alert-title {
|
.markdown-alert-warning > .markdown-alert-title {
|
||||||
color: #c69026;
|
color: #c69026;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-alert-caution>.markdown-alert-title {
|
.markdown-alert-caution > .markdown-alert-title {
|
||||||
color: #e5534b;
|
color: #e5534b;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
word-break: break-word !important
|
word-break: break-word !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="modal is-active">
|
<div class="modal is-active">
|
||||||
|
|
||||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||||
|
|
||||||
<div class="modal-content modal-content-max">
|
<div class="modal-content modal-content-max">
|
||||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
<i class="fas fa-circle-notch fa-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,120 +79,121 @@ code {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue'
|
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 Message from '~/components/Message.vue';
|
||||||
|
|
||||||
const props = defineProps<{ file: string }>()
|
const props = defineProps<{ file: string }>();
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg']
|
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg'];
|
||||||
|
|
||||||
const content = ref<string>('')
|
const content = ref<string>('');
|
||||||
const error = ref<string>('')
|
const error = ref<string>('');
|
||||||
const isLoading = ref<boolean>(true)
|
const isLoading = ref<boolean>(true);
|
||||||
|
|
||||||
const handleClick = async (e: MouseEvent) => {
|
const handleClick = async (e: MouseEvent) => {
|
||||||
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null
|
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const href = target.getAttribute('data-url')
|
const href = target.getAttribute('data-url');
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
await loader(href)
|
await loader(href);
|
||||||
}
|
};
|
||||||
|
|
||||||
const addListeners = (): void => {
|
const addListeners = (): void => {
|
||||||
removeListeners()
|
removeListeners();
|
||||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
||||||
const href = l.getAttribute('data-url')
|
const href = l.getAttribute('data-url');
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(l as HTMLElement).addEventListener('click', handleClick)
|
(l as HTMLElement).addEventListener('click', handleClick);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeListeners = (): void => {
|
const removeListeners = (): void => {
|
||||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
||||||
const href = l.getAttribute('data-url')
|
const href = l.getAttribute('data-url');
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(l as HTMLElement).removeEventListener('click', handleClick)
|
(l as HTMLElement).removeEventListener('click', handleClick);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const loader = async (file: string) => {
|
const loader = async (file: string) => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
const response = await fetch(`${file}?_=${Date.now()}`)
|
const response = await fetch(`${file}?_=${Date.now()}`);
|
||||||
if (true !== response.ok) {
|
if (true !== response.ok) {
|
||||||
const err = await response.json()
|
const err = await response.json();
|
||||||
error.value = err.error.message
|
error.value = err.error.message;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text()
|
const text = await response.text();
|
||||||
|
|
||||||
marked.use(gfmHeadingId())
|
marked.use(gfmHeadingId());
|
||||||
marked.use(baseUrl(window.origin))
|
marked.use(baseUrl(window.origin));
|
||||||
marked.use(markedAlert())
|
marked.use(markedAlert());
|
||||||
marked.use({
|
marked.use({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
postprocess: (text: string) => text.replace(
|
postprocess: (text: string) =>
|
||||||
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
|
text.replace(
|
||||||
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map((n: string) => n.trim()).join(' ')}"></i></span>`
|
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
|
||||||
)
|
(_, list) =>
|
||||||
|
`<span class="icon"><i class="fas ${list
|
||||||
|
.split('.')
|
||||||
|
.map((n: string) => n.trim())
|
||||||
|
.join(' ')}"></i></span>`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
walkTokens: (token: any) => {
|
walkTokens: (token: any) => {
|
||||||
if (token.type !== 'link') {
|
if (token.type !== 'link') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (token.href.startsWith('#')) {
|
if (token.href.startsWith('#')) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urls.some(l => token.href.includes(l))) {
|
if (urls.some((l) => token.href.includes(l))) {
|
||||||
const name = urls.find(l => token.href.includes(l)) || ''
|
const name = urls.find((l) => token.href.includes(l)) || '';
|
||||||
token._external = false
|
token._external = false;
|
||||||
token.href = `/${name}`
|
token.href = `/${name}`;
|
||||||
} else {
|
} else {
|
||||||
token._external = true
|
token._external = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
link(token: any) {
|
link(token: any) {
|
||||||
const text = this.parser.parseInline(token.tokens)
|
const text = this.parser.parseInline(token.tokens);
|
||||||
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 = ''
|
let local = '';
|
||||||
const name = urls.find(l => token.href.includes(l)) || ''
|
const name = urls.find((l) => token.href.includes(l)) || '';
|
||||||
if (name) {
|
if (name) {
|
||||||
local = ` data-url="/api/docs/${name}"`
|
local = ` data-url="/api/docs/${name}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`
|
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`;
|
||||||
},
|
},
|
||||||
table(token: any) {
|
table(token: any) {
|
||||||
// `token.header` and `token.rows` are available
|
// `token.header` and `token.rows` are available
|
||||||
|
|
@ -201,39 +201,42 @@ const loader = async (file: string) => {
|
||||||
// Then wrap with classed <table>
|
// Then wrap with classed <table>
|
||||||
|
|
||||||
// We need to generate the inner HTML parts first
|
// 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>`
|
const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>`;
|
||||||
|
|
||||||
const bodyHtml = (token.rows || []).map((row: any[]) => {
|
const bodyHtml = (token.rows || [])
|
||||||
const tr = row.map((cell: any) => {
|
.map((row: any[]) => {
|
||||||
return `<td>${this.parser.parseInline(cell.tokens)}</td>`
|
const tr = row
|
||||||
}).join('')
|
.map((cell: any) => {
|
||||||
return `<tr>${tr}</tr>`
|
return `<td>${this.parser.parseInline(cell.tokens)}</td>`;
|
||||||
}).join('')
|
})
|
||||||
|
.join('');
|
||||||
|
return `<tr>${tr}</tr>`;
|
||||||
|
})
|
||||||
|
.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 `<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>`;
|
||||||
},
|
},
|
||||||
image(token: any) {
|
image(token: any) {
|
||||||
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))
|
|
||||||
|
|
||||||
|
content.value = String(marked.parse(text));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
error.value = e.message
|
error.value = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => loader(props.file))
|
onMounted(async () => loader(props.file));
|
||||||
onUpdated(() => addListeners())
|
onUpdated(() => addListeners());
|
||||||
onBeforeUnmount(() => removeListeners())
|
onBeforeUnmount(() => removeListeners());
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="message">
|
<div class="message">
|
||||||
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
|
<div
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
class="is-clickable is-pulled-right is-unselectable"
|
||||||
|
v-if="useToggle"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
|
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-unselectable message-header" :class="{ 'is-clickable': useToggle, }" v-if="title || icon"
|
<div
|
||||||
@click="true === useToggle ? $emit('toggle', toggle) : null">
|
class="is-unselectable message-header"
|
||||||
|
:class="{ 'is-clickable': useToggle }"
|
||||||
|
v-if="title || icon"
|
||||||
|
@click="true === useToggle ? $emit('toggle', toggle) : null"
|
||||||
|
>
|
||||||
<template v-if="icon">
|
<template v-if="icon">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i :class="icon"></i></span>
|
<span class="icon"><i :class="icon"></i></span>
|
||||||
|
|
@ -17,7 +25,11 @@
|
||||||
<template v-else>{{ title }}</template>
|
<template v-else>{{ title }}</template>
|
||||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
|
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content message-body is-text-break" v-if="false === useToggle || toggle" :class="body_class">
|
<div
|
||||||
|
class="content message-body is-text-break"
|
||||||
|
v-if="false === useToggle || toggle"
|
||||||
|
:class="body_class"
|
||||||
|
>
|
||||||
<template v-if="message">{{ message }}</template>
|
<template v-if="message">{{ message }}</template>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,34 +37,37 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
withDefaults(
|
||||||
/** Title text for the notification */
|
defineProps<{
|
||||||
title?: string | null
|
/** Title text for the notification */
|
||||||
/** Icon class for the notification */
|
title?: string | null;
|
||||||
icon?: string | null
|
/** Icon class for the notification */
|
||||||
/** Main message content */
|
icon?: string | null;
|
||||||
message?: string | null
|
/** Main message content */
|
||||||
/** If true, show toggle button */
|
message?: string | null;
|
||||||
useToggle?: boolean
|
/** If true, show toggle button */
|
||||||
/** Current toggle state */
|
useToggle?: boolean;
|
||||||
toggle?: boolean
|
/** Current toggle state */
|
||||||
/** If true, show close button */
|
toggle?: boolean;
|
||||||
useClose?: boolean,
|
/** If true, show close button */
|
||||||
body_class?: string | null,
|
useClose?: boolean;
|
||||||
}>(), {
|
body_class?: string | null;
|
||||||
title: null,
|
}>(),
|
||||||
icon: null,
|
{
|
||||||
message: null,
|
title: null,
|
||||||
useToggle: false,
|
icon: null,
|
||||||
toggle: false,
|
message: null,
|
||||||
useClose: false,
|
useToggle: false,
|
||||||
body_class: null,
|
toggle: false,
|
||||||
})
|
useClose: false,
|
||||||
|
body_class: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
/** Emitted when the toggle button is clicked */
|
/** Emitted when the toggle button is clicked */
|
||||||
(e: 'toggle', value?: boolean): void
|
(e: 'toggle', value?: boolean): void;
|
||||||
/** Emitted when the close button is clicked */
|
/** Emitted when the close button is clicked */
|
||||||
(e: 'close'): void
|
(e: 'close'): void;
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits(['close'])
|
const emitter = defineEmits(['close']);
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
title: {
|
||||||
|
|
@ -32,22 +32,22 @@ defineProps({
|
||||||
default: '',
|
default: '',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
const handle_event = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') {
|
if (e.key !== 'Escape') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
emitter('close')
|
emitter('close');
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', handle_event)
|
document.addEventListener('keydown', handle_event);
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event);
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<style scoped>
|
<style scoped>
|
||||||
code {
|
code {
|
||||||
color: var(--bulma-code) !important
|
color: var(--bulma-code) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ code {
|
||||||
<div class="modal is-active" v-if="false === externalModel">
|
<div class="modal is-active" v-if="false === externalModel">
|
||||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||||
<div class="modal-content modal-content-max">
|
<div class="modal-content modal-content-max">
|
||||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
<i class="fas fa-circle-notch fa-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
@ -27,20 +27,32 @@ code {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
|
<button
|
||||||
|
class="modal-close is-large"
|
||||||
|
aria-label="close"
|
||||||
|
@click="emitter('closeModel')"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-content-max" style="height: 80vh;" v-else>
|
<div class="modal-content-max" style="height: 80vh" v-else>
|
||||||
<div class="content p-0 m-0" style="position: relative">
|
<div class="content p-0 m-0" style="position: relative">
|
||||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||||
<i class="fas fa-circle-notch fa-spin" />
|
<i class="fas fa-circle-notch fa-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" /></pre>
|
<pre
|
||||||
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;">
|
:class="[code_classes, custom_classes]"
|
||||||
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')">
|
><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>
|
<span class="icon"><i class="fas fa-text-width" /></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))">
|
<button
|
||||||
|
class="button is-info is-small"
|
||||||
|
@click="() => copyText(JSON.stringify(data, null, 2))"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-copy" /></span>
|
<span class="icon"><i class="fas fa-copy" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,40 +63,43 @@ code {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import { disableOpacity, enableOpacity } from '~/utils';
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||||
|
|
||||||
withDefaults(defineProps<{ externalModel?: boolean, data: any, code_classes?: string, isLoading?: boolean }>(), {
|
withDefaults(
|
||||||
code_classes: '',
|
defineProps<{ externalModel?: boolean; data: any; code_classes?: string; isLoading?: boolean }>(),
|
||||||
isLoading: false,
|
{
|
||||||
externalModel: false,
|
code_classes: '',
|
||||||
})
|
isLoading: false,
|
||||||
|
externalModel: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const custom_classes = useStorage<string>('modal_text_classes', '')
|
const custom_classes = useStorage<string>('modal_text_classes', '');
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent): void => {
|
const handle_event = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
emitter('closeModel')
|
emitter('closeModel');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async (): Promise<void> => {
|
onMounted(async (): Promise<void> => {
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
document.addEventListener('keydown', handle_event)
|
document.addEventListener('keydown', handle_event);
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event);
|
||||||
})
|
});
|
||||||
|
|
||||||
const toggleClass = (className: string) => {
|
const toggleClass = (className: string) => {
|
||||||
if (custom_classes.value.includes(className)) {
|
if (custom_classes.value.includes(className)) {
|
||||||
custom_classes.value = custom_classes.value.replace(className, '').trim()
|
custom_classes.value = custom_classes.value.replace(className, '').trim();
|
||||||
} else {
|
} else {
|
||||||
custom_classes.value += ` ${className}`
|
custom_classes.value += ` ${className}`;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Message from "~/components/Message.vue";
|
import Message from '~/components/Message.vue';
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload();
|
const reloadPage = () => window.location.reload();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,26 @@
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||||
|
/></span>
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header-icon" v-if="reference">
|
<div class="card-header-icon" v-if="reference">
|
||||||
<button type="button" @click="showImport = !showImport">
|
<button type="button" @click="showImport = !showImport">
|
||||||
<span class="icon"><i class="fa-solid" :class="{
|
<span class="icon"
|
||||||
'fa-arrow-down': !showImport,
|
><i
|
||||||
'fa-arrow-up': showImport,
|
class="fa-solid"
|
||||||
}" /></span>
|
:class="{
|
||||||
|
'fa-arrow-down': !showImport,
|
||||||
|
'fa-arrow-up': showImport,
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span>
|
<span>
|
||||||
<span v-if="showImport">Hide</span>
|
<span v-if="showImport">Hide</span>
|
||||||
<span v-else>Show</span>
|
<span v-else>Show</span>
|
||||||
|
|
@ -29,20 +34,28 @@
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
|
|
||||||
<div class="column is-12" v-if="showImport || !reference">
|
<div class="column is-12" v-if="showImport || !reference">
|
||||||
<label class="label is-inline" for="import_string">
|
<label class="label is-inline" for="import_string"> Import string </label>
|
||||||
Import string
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control has-icons-left is-expanded">
|
<div class="control has-icons-left is-expanded">
|
||||||
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="import_string"
|
||||||
|
v-model="import_string"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
:disabled="!import_string"
|
||||||
|
type="button"
|
||||||
|
@click="importItem"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
<span>Import</span>
|
<span>Import</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -56,37 +69,52 @@
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="name">
|
<label class="label is-inline" for="name"> Target name </label>
|
||||||
Target name
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required>
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>The notification target name, this is used to identify the target in the logs and
|
<span
|
||||||
notifications.</span>
|
>The notification target name, this is used to identify the target in the logs
|
||||||
|
and notifications.</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="url">
|
<label class="label is-inline" for="url"> Target URL </label>
|
||||||
Target URL
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
|
<input
|
||||||
required>
|
type="url"
|
||||||
|
class="input"
|
||||||
|
id="url"
|
||||||
|
v-model="form.request.url"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
The URL to send the notification to. It can be regular http/https endpoint. or <NuxtLink
|
The URL to send the notification to. It can be regular http/https endpoint. or
|
||||||
target="blank" href="https://github.com/caronc/apprise?tab=readme-ov-file#readme">Apprise
|
<NuxtLink
|
||||||
</NuxtLink> URL.
|
target="blank"
|
||||||
|
href="https://github.com/caronc/apprise?tab=readme-ov-file#readme"
|
||||||
|
>Apprise
|
||||||
|
</NuxtLink>
|
||||||
|
URL.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,13 +122,20 @@
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="method">
|
<label class="label is-inline" for="method"> Request method </label>
|
||||||
Request method
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
<select
|
||||||
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
|
id="method"
|
||||||
|
class="is-fullwidth"
|
||||||
|
v-model="form.request.method"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(rMethod, index) in requestMethods"
|
||||||
|
:key="`${index}-${rMethod}`"
|
||||||
|
:value="rMethod"
|
||||||
|
>
|
||||||
{{ rMethod }}
|
{{ rMethod }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -110,8 +145,8 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
The request method to use when sending the notification. This can be any of the standard HTTP
|
The request method to use when sending the notification. This can be any of
|
||||||
methods.
|
the standard HTTP methods.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,13 +154,20 @@
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="type">
|
<label class="label is-inline" for="type"> Request Type </label>
|
||||||
Request Type
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
<select
|
||||||
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
|
id="type"
|
||||||
|
class="is-fullwidth"
|
||||||
|
v-model="form.request.type"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(rType, index) in requestType"
|
||||||
|
:key="`${index}-${rType}`"
|
||||||
|
:value="rType"
|
||||||
|
>
|
||||||
{{ ucFirst(rType) }}
|
{{ ucFirst(rType) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -135,8 +177,8 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
The request type to use when sending the notification. This can be <code>JSON</code> or
|
The request type to use when sending the notification. This can be
|
||||||
<code>FORM</code> request.
|
<code>JSON</code> or <code>FORM</code> request.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -152,8 +194,18 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-multiple is-fullwidth">
|
<div class="select is-multiple is-fullwidth">
|
||||||
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
<select
|
||||||
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
|
id="on"
|
||||||
|
class="is-fullwidth"
|
||||||
|
v-model="form.on"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(aEvent, index) in allowedEvents"
|
||||||
|
:key="`${index}-${aEvent}`"
|
||||||
|
:value="aEvent"
|
||||||
|
>
|
||||||
{{ aEvent }}
|
{{ aEvent }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -163,9 +215,9 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
Subscribe to the events you want to listen for. When the event is triggered, the notification will
|
Subscribe to the events you want to listen for. When the event is triggered,
|
||||||
be sent to the target URL. If no events are selected, the notification will be sent for all
|
the notification will be sent to the target URL. If no events are selected,
|
||||||
events.
|
the notification will be sent for all events.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,14 +233,31 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-multiple is-fullwidth">
|
<div class="select is-multiple is-fullwidth">
|
||||||
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple>
|
<select
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
id="on"
|
||||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name">
|
class="is-fullwidth"
|
||||||
|
v-model="form.presets"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<optgroup
|
||||||
|
label="Custom presets"
|
||||||
|
v-if="config?.presets.filter((p) => !p?.default).length > 0"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="cPreset in filter_presets(false)"
|
||||||
|
:key="cPreset.id"
|
||||||
|
:value="cPreset.name"
|
||||||
|
>
|
||||||
{{ cPreset.name }}
|
{{ cPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name">
|
<option
|
||||||
|
v-for="dPreset in filter_presets(true)"
|
||||||
|
:key="dPreset.id"
|
||||||
|
:value="dPreset.name"
|
||||||
|
>
|
||||||
{{ dPreset.name }}
|
{{ dPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|
@ -199,9 +268,9 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
Select the presets you want to listen for. If you select presets, only events that reference those
|
Select the presets you want to listen for. If you select presets, only events
|
||||||
presets will trigger the notification. If no presets are selected, the notification will be sent
|
that reference those presets will trigger the notification. If no presets are
|
||||||
for all presets.
|
selected, the notification will be sent for all presets.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,8 +283,13 @@
|
||||||
Enabled
|
Enabled
|
||||||
</label>
|
</label>
|
||||||
<div class="control is-unselectable">
|
<div class="control is-unselectable">
|
||||||
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
<input
|
||||||
class="switch is-success" />
|
id="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="form.enabled"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
class="switch is-success"
|
||||||
|
/>
|
||||||
<label for="enabled" class="is-unselectable">
|
<label for="enabled" class="is-unselectable">
|
||||||
{{ form.enabled ? 'Yes' : 'No' }}
|
{{ form.enabled ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -229,19 +303,23 @@
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="data_key">
|
<label class="label is-inline" for="data_key"> Data field </label>
|
||||||
Data field
|
|
||||||
</label>
|
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" id="data_key" v-model="form.request.data_key"
|
<input
|
||||||
:disabled="addInProgress" required>
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="data_key"
|
||||||
|
v-model="form.request.data_key"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
The field name to use when sending the notification. This is used to identify the data in the
|
The field name to use when sending the notification. This is used to identify
|
||||||
request. The default is <code>data</code>.
|
the data in the request. The default is <code>data</code>.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,17 +328,27 @@
|
||||||
<div class="column is-12" v-if="!isAppriseTarget">
|
<div class="column is-12" v-if="!isAppriseTarget">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline is-unselectable">
|
<label class="label is-inline is-unselectable">
|
||||||
Optional Headers - <button type="button" class="has-text-link"
|
Optional Headers -
|
||||||
@click="form.request.headers.push({ key: '', value: '' });">Add Header
|
<button
|
||||||
|
type="button"
|
||||||
|
class="has-text-link"
|
||||||
|
@click="form.request.headers.push({ key: '', value: '' })"
|
||||||
|
>
|
||||||
|
Add Header
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
<template v-for="_, key in form.request.headers" :key="key">
|
<template v-for="(_, key) in form.request.headers" :key="key">
|
||||||
<div class="column is-5" v-if="form.request.headers[key]">
|
<div class="column is-5" v-if="form.request.headers[key]">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" v-model="form.request.headers[key].key"
|
<input
|
||||||
:disabled="addInProgress" required>
|
type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="form.request.headers[key].key"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -272,8 +360,13 @@
|
||||||
<div class="column is-6" v-if="form.request.headers[key]">
|
<div class="column is-6" v-if="form.request.headers[key]">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="text" class="input" v-model="form.request.headers[key].value"
|
<input
|
||||||
:disabled="addInProgress" required>
|
type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="form.request.headers[key].value"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -284,8 +377,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-1">
|
<div class="column is-1">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
|
<button
|
||||||
:disabled="addInProgress">
|
type="button"
|
||||||
|
class="button is-danger"
|
||||||
|
@click="form.request.headers.splice(key, 1)"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -304,15 +401,24 @@
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p class="card-footer-item">
|
<p class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
<button
|
||||||
:class="{ 'is-loading': addInProgress }" form="taskForm">
|
class="button is-fullwidth is-primary"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="submit"
|
||||||
|
:class="{ 'is-loading': addInProgress }"
|
||||||
|
form="taskForm"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="card-footer-item">
|
<p class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
<button
|
||||||
type="button">
|
class="button is-fullwidth is-danger"
|
||||||
|
@click="emitter('cancel')"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -326,17 +432,17 @@
|
||||||
</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 type { notification } from '~/types/notification';
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import type { ImportedItem } from '~/types'
|
import type { ImportedItem } from '~/types';
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit'])
|
const emitter = defineEmits(['cancel', 'submit']);
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const { isApprise } = useNotifications()
|
const { isApprise } = useNotifications();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
reference: {
|
reference: {
|
||||||
|
|
@ -357,135 +463,136 @@ const props = defineProps({
|
||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const form = reactive<notification>({ ...props.item })
|
const form = reactive<notification>({ ...props.item });
|
||||||
const requestMethods = ['POST', 'PUT']
|
const requestMethods = ['POST', 'PUT'];
|
||||||
const requestType = ['json', 'form']
|
const requestType = ['json', 'form'];
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage('showImport', false);
|
||||||
const import_string = ref('')
|
const import_string = ref('');
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!form.request.data_key) {
|
if (!form.request.data_key) {
|
||||||
form.request.data_key = 'data'
|
form.request.data_key = 'data';
|
||||||
}
|
}
|
||||||
if (form.enabled === undefined) {
|
if (form.enabled === undefined) {
|
||||||
form.enabled = true
|
form.enabled = true;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const checkInfo = async () => {
|
const checkInfo = async () => {
|
||||||
let required: string[]
|
let required: string[];
|
||||||
|
|
||||||
if (!isAppriseTarget.value) {
|
if (!isAppriseTarget.value) {
|
||||||
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key']
|
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key'];
|
||||||
} else {
|
} else {
|
||||||
required = ['name', 'request.url']
|
required = ['name', 'request.url'];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of required) {
|
for (const key of required) {
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
const [parent, child] = key.split('.') as [keyof typeof form, string]
|
const [parent, child] = key.split('.') as [keyof typeof form, string];
|
||||||
const parentObj = form[parent] as Record<string, any> | undefined
|
const parentObj = form[parent] as Record<string, any> | undefined;
|
||||||
|
|
||||||
if (!parentObj || !parentObj[child]) {
|
if (!parentObj || !parentObj[child]) {
|
||||||
toast.error(`The field ${parent}.${child} is required.`)
|
toast.error(`The field ${parent}.${child} is required.`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const value = (form as Record<string, any>)[key]
|
const value = (form as Record<string, any>)[key];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
toast.error(`The field ${key} is required.`)
|
toast.error(`The field ${key} is required.`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAppriseTarget.value) {
|
if (!isAppriseTarget.value) {
|
||||||
try {
|
try {
|
||||||
new URL(form.request.url)
|
new URL(form.request.url);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Invalid URL')
|
toast.error('Invalid URL');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = []
|
const headers = [];
|
||||||
for (const header of form.request.headers) {
|
for (const header of form.request.headers) {
|
||||||
if (!header.key || !header.value) {
|
if (!header.key || !header.value) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
|
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() });
|
||||||
}
|
}
|
||||||
form.request.headers = headers
|
form.request.headers = headers;
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) })
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
|
||||||
}
|
};
|
||||||
|
|
||||||
const importItem = async () => {
|
const importItem = async () => {
|
||||||
const val = import_string.value.trim()
|
const val = import_string.value.trim();
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as notification & ImportedItem
|
const item = decode(val) as notification & ImportedItem;
|
||||||
|
|
||||||
if ('notification' !== item._type) {
|
if ('notification' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
|
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`);
|
||||||
import_string.value = ''
|
import_string.value = '';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.name || form.request?.url) {
|
if (form.name || form.request?.url) {
|
||||||
if (false === (await box.confirm('Overwrite the current form fields?'))) {
|
if (false === (await box.confirm('Overwrite the current form fields?'))) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name) {
|
if (item.name) {
|
||||||
form.name = item.name
|
form.name = item.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.request) {
|
if (!form.request) {
|
||||||
form.request = {} as any
|
form.request = {} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.request) {
|
if (item.request) {
|
||||||
form.request = item.request
|
form.request = item.request;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.request?.data_key) {
|
if (item.request?.data_key) {
|
||||||
form.request.data_key = item.request.data_key
|
form.request.data_key = item.request.data_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.on) {
|
if (item.on) {
|
||||||
form.on = item.on
|
form.on = item.on;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.presets) {
|
if (item.presets) {
|
||||||
item.presets.forEach(p => {
|
item.presets.forEach((p) => {
|
||||||
if (!config.presets.find(cp => cp.name === p)) {
|
if (!config.presets.find((cp) => cp.name === p)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!form.presets.includes(p)) {
|
if (!form.presets.includes(p)) {
|
||||||
form.presets.push(p)
|
form.presets.push(p);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.enabled !== undefined) {
|
if (item.enabled !== undefined) {
|
||||||
form.enabled = item.enabled
|
form.enabled = item.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = '';
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Failed to import task. ${e.message}`)
|
toast.error(`Failed to import task. ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url))
|
const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url));
|
||||||
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag: boolean = true) =>
|
||||||
|
config.presets.filter((item) => item.default === flag);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
</span>
|
</span>
|
||||||
<span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span>
|
<span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-dropdown is-right" style="width: 400px;">
|
<div class="navbar-dropdown is-right" style="width: 400px">
|
||||||
<template v-if="store.notifications.length > 0">
|
<template v-if="store.notifications.length > 0">
|
||||||
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
||||||
<span class="has-text-grey"></span>
|
<span class="has-text-grey"></span>
|
||||||
|
|
@ -73,20 +73,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="navbar-divider">
|
<hr class="navbar-divider" />
|
||||||
</template>
|
</template>
|
||||||
<div class="notification-list">
|
<div class="notification-list">
|
||||||
<div v-for="n in store.notifications" :key="n.id" class="pr-1 pl-1 navbar-item is-flex is-align-items-start"
|
<div
|
||||||
:class="['notification-item', 'notification-' + n.level]">
|
v-for="n in store.notifications"
|
||||||
|
:key="n.id"
|
||||||
|
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">
|
<div class="is-flex-grow-1">
|
||||||
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"
|
<p
|
||||||
@click="toggleExpand(n.id)">
|
class="is-size-7 mb-1 notification-message"
|
||||||
|
:class="{ expanded: expandedId === n.id }"
|
||||||
|
@click="toggleExpand(n.id)"
|
||||||
|
>
|
||||||
{{ n.message }}
|
{{ n.message }}
|
||||||
</p>
|
</p>
|
||||||
<p class="is-size-7 has-text-grey">
|
<p class="is-size-7 has-text-grey">
|
||||||
<span :date-datetime="n.created" v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
|
<span
|
||||||
v-rtime="n.created" />
|
:date-datetime="n.created"
|
||||||
- <NuxtLink @click="copy_text(n.id, n.message)">
|
v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
|
||||||
|
v-rtime="n.created"
|
||||||
|
/>
|
||||||
|
-
|
||||||
|
<NuxtLink @click="copy_text(n.id, n.message)">
|
||||||
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
|
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
|
||||||
<span v-else>Copy</span>
|
<span v-else>Copy</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -118,20 +129,20 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment';
|
||||||
|
|
||||||
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 toggleExpand = (id: string) => expandedId.value = expandedId.value === id ? null : id
|
const toggleExpand = (id: string) => (expandedId.value = expandedId.value === id ? null : 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);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (copiedId.value === id) copiedId.value = null
|
if (copiedId.value === id) copiedId.value = null;
|
||||||
}, 2000)
|
}, 2000);
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,69 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
|
<button
|
||||||
:class="{'is-loading':isLoading}">
|
rel="first"
|
||||||
|
class="button"
|
||||||
|
v-if="page !== 1"
|
||||||
|
@click="changePage(1)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
|
<button
|
||||||
:disabled="isLoading" :class="{'is-loading':isLoading}">
|
rel="prev"
|
||||||
|
class="button"
|
||||||
|
v-if="page > 1 && page - 1 !== 1"
|
||||||
|
@click="changePage(page - 1)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading">
|
<select
|
||||||
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`"
|
id="pager_list"
|
||||||
:value="item.page" :disabled="0 === item.page">
|
v-model="currentPage"
|
||||||
|
@change="changePage(currentPage)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(item, index) in makePagination(page, last_page)"
|
||||||
|
:key="`pager-${index}`"
|
||||||
|
:value="item.page"
|
||||||
|
:disabled="0 === item.page"
|
||||||
|
>
|
||||||
{{ item.text }}
|
{{ item.text }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
|
<button
|
||||||
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
rel="next"
|
||||||
|
class="button"
|
||||||
|
v-if="page !== last_page && page + 1 !== last_page"
|
||||||
|
@click="changePage(page + 1)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
|
<button
|
||||||
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
rel="last"
|
||||||
|
class="button"
|
||||||
|
v-if="page !== last_page"
|
||||||
|
@click="changePage(last_page)"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,33 +71,33 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {makePagination} from '~/utils/index'
|
import { makePagination } from '~/utils/index';
|
||||||
|
|
||||||
const emitter = defineEmits(['navigate'])
|
const emitter = defineEmits(['navigate']);
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
page: {
|
page: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
last_page: {
|
last_page: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const changePage = p => {
|
const changePage = (p) => {
|
||||||
if (p < 1 || p > props.last_page) {
|
if (p < 1 || p > props.last_page) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
emitter('navigate', p)
|
emitter('navigate', p);
|
||||||
currentPage.value = p
|
currentPage.value = p;
|
||||||
}
|
};
|
||||||
|
|
||||||
const currentPage = ref(props.page)
|
const currentPage = ref(props.page);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="popover-wrapper" ref="triggerElm" :tabindex="triggerTabIndex" @mouseenter="handleHoverEnter"
|
<div
|
||||||
@mouseleave="handleHoverLeave" @focusin="handleFocusIn" @focusout="handleFocusOut" @click="handleClick">
|
class="popover-wrapper"
|
||||||
|
ref="triggerElm"
|
||||||
|
:tabindex="triggerTabIndex"
|
||||||
|
@mouseenter="handleHoverEnter"
|
||||||
|
@mouseleave="handleHoverLeave"
|
||||||
|
@focusin="handleFocusIn"
|
||||||
|
@focusout="handleFocusOut"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
<div class="popover-trigger">
|
<div class="popover-trigger">
|
||||||
<slot name="trigger">
|
<slot name="trigger">
|
||||||
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
||||||
|
|
@ -13,9 +21,20 @@
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="isOpen" class="popover-portal" ref="portal">
|
<div v-if="isOpen" class="popover-portal" ref="portal">
|
||||||
<div ref="popover" class="popover-card box" :class="placementClass" role="tooltip" :style="portalStyle"
|
<div
|
||||||
@mouseenter="handleHoverEnter" @mouseleave="handleHoverLeave">
|
ref="popover"
|
||||||
<button type="button" class="button is-bold is-fullwidth is-hidden-tablet" @click="closePopover">
|
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 class="icon"><i class="fas fa-times" /></span>
|
||||||
<span>Close</span>
|
<span>Close</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -38,10 +57,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PopoverProps } from '~/types/popover'
|
import type { PopoverProps } from '~/types/popover';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch, useTemplateRef } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
useTemplateRef,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>()
|
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
|
|
@ -54,211 +82,211 @@ const props = withDefaults(defineProps<PopoverProps>(), {
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
maxWidth: 340,
|
maxWidth: 340,
|
||||||
maxHeight: 360,
|
maxHeight: 360,
|
||||||
showDelay: 0
|
showDelay: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots();
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false);
|
||||||
const triggerRef = useTemplateRef<HTMLElement>('triggerElm')
|
const triggerRef = useTemplateRef<HTMLElement>('triggerElm');
|
||||||
const popover = useTemplateRef<HTMLDivElement>('popover')
|
const popover = useTemplateRef<HTMLDivElement>('popover');
|
||||||
const portalStyle = ref<Record<string, string>>({})
|
const portalStyle = ref<Record<string, string>>({});
|
||||||
const hoverTimeout = ref<number | null>(null)
|
const hoverTimeout = ref<number | null>(null);
|
||||||
const openTimeout = ref<number | null>(null)
|
const openTimeout = ref<number | null>(null);
|
||||||
|
|
||||||
const placementClass = computed(() => `is-${props.placement}`)
|
const placementClass = computed(() => `is-${props.placement}`);
|
||||||
const triggerTabIndex = computed(() => props.trigger === 'hover' ? -1 : 0)
|
const triggerTabIndex = computed(() => (props.trigger === 'hover' ? -1 : 0));
|
||||||
|
|
||||||
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title))
|
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title));
|
||||||
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default))
|
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default));
|
||||||
|
|
||||||
const clearHoverTimeout = () => {
|
const clearHoverTimeout = () => {
|
||||||
if (null !== hoverTimeout.value) {
|
if (null !== hoverTimeout.value) {
|
||||||
window.clearTimeout(hoverTimeout.value)
|
window.clearTimeout(hoverTimeout.value);
|
||||||
hoverTimeout.value = null
|
hoverTimeout.value = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const clearOpenTimeout = () => {
|
const clearOpenTimeout = () => {
|
||||||
if (null !== openTimeout.value) {
|
if (null !== openTimeout.value) {
|
||||||
window.clearTimeout(openTimeout.value)
|
window.clearTimeout(openTimeout.value);
|
||||||
openTimeout.value = null
|
openTimeout.value = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
clearHoverTimeout()
|
clearHoverTimeout();
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
hoverTimeout.value = window.setTimeout(() => {
|
hoverTimeout.value = window.setTimeout(() => {
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
}, 120)
|
}, 120);
|
||||||
}
|
};
|
||||||
|
|
||||||
const openPopoverImmediate = async () => {
|
const openPopoverImmediate = async () => {
|
||||||
if (props.disabled || isOpen.value) {
|
if (props.disabled || isOpen.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpen.value = true
|
isOpen.value = true;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
updatePosition()
|
updatePosition();
|
||||||
}
|
};
|
||||||
|
|
||||||
const openPopover = () => {
|
const openPopover = () => {
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
if (props.showDelay && 0 < props.showDelay) {
|
if (props.showDelay && 0 < props.showDelay) {
|
||||||
openTimeout.value = window.setTimeout(() => {
|
openTimeout.value = window.setTimeout(() => {
|
||||||
openTimeout.value = null
|
openTimeout.value = null;
|
||||||
void openPopoverImmediate()
|
void openPopoverImmediate();
|
||||||
}, props.showDelay)
|
}, props.showDelay);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void openPopoverImmediate()
|
void openPopoverImmediate();
|
||||||
}
|
};
|
||||||
|
|
||||||
const closePopover = () => {
|
const closePopover = () => {
|
||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpen.value = false
|
isOpen.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const togglePopover = async () => {
|
const togglePopover = async () => {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
closePopover()
|
closePopover();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openPopover()
|
openPopover();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleHoverEnter = () => {
|
const handleHoverEnter = () => {
|
||||||
if ('hover' !== props.trigger || props.disabled) {
|
if ('hover' !== props.trigger || props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHoverTimeout()
|
clearHoverTimeout();
|
||||||
openPopover()
|
openPopover();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleHoverLeave = () => {
|
const handleHoverLeave = () => {
|
||||||
if ('hover' !== props.trigger) {
|
if ('hover' !== props.trigger) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
scheduleClose()
|
scheduleClose();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleFocusIn = () => {
|
const handleFocusIn = () => {
|
||||||
if ('focus' !== props.trigger || props.disabled) {
|
if ('focus' !== props.trigger || props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openPopover()
|
openPopover();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleFocusOut = () => {
|
const handleFocusOut = () => {
|
||||||
if ('focus' !== props.trigger) {
|
if ('focus' !== props.trigger) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
scheduleClose()
|
scheduleClose();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
if ('click' !== props.trigger || props.disabled) {
|
if ('click' !== props.trigger || props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation();
|
||||||
void togglePopover()
|
void togglePopover();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDocumentClick = (event: MouseEvent) => {
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target as Node | null
|
const target = event.target as Node | null;
|
||||||
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target))
|
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target));
|
||||||
const isInsidePopover = Boolean(popover.value && popover.value.contains(target))
|
const isInsidePopover = Boolean(popover.value && popover.value.contains(target));
|
||||||
|
|
||||||
if (!isInsideTrigger && !isInsidePopover) {
|
if (!isInsideTrigger && !isInsidePopover) {
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
closePopover()
|
closePopover();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if ('Escape' === event.key && isOpen.value) {
|
if ('Escape' === event.key && isOpen.value) {
|
||||||
closePopover()
|
closePopover();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
if (!triggerRef.value || !popover.value) {
|
if (!triggerRef.value || !popover.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight
|
const viewportHeight = window.innerHeight;
|
||||||
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96)
|
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96);
|
||||||
|
|
||||||
popover.value.style.minWidth = `${props.minWidth}px`
|
popover.value.style.minWidth = `${props.minWidth}px`;
|
||||||
popover.value.style.maxWidth = `${constrainedMaxWidth}px`
|
popover.value.style.maxWidth = `${constrainedMaxWidth}px`;
|
||||||
popover.value.style.maxHeight = `${props.maxHeight}px`
|
popover.value.style.maxHeight = `${props.maxHeight}px`;
|
||||||
popover.value.style.overflowY = 'auto'
|
popover.value.style.overflowY = 'auto';
|
||||||
|
|
||||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||||
const popoverRect = popover.value.getBoundingClientRect()
|
const popoverRect = popover.value.getBoundingClientRect();
|
||||||
const offset = props.offset
|
const offset = props.offset;
|
||||||
|
|
||||||
let effectivePlacement = props.placement
|
let effectivePlacement = props.placement;
|
||||||
|
|
||||||
if ('top' === props.placement) {
|
if ('top' === props.placement) {
|
||||||
const spaceAbove = triggerRect.top - offset
|
const spaceAbove = triggerRect.top - offset;
|
||||||
if (spaceAbove < popoverRect.height) {
|
if (spaceAbove < popoverRect.height) {
|
||||||
effectivePlacement = 'bottom'
|
effectivePlacement = 'bottom';
|
||||||
}
|
}
|
||||||
} else if ('bottom' === props.placement) {
|
} else if ('bottom' === props.placement) {
|
||||||
const spaceBelow = viewportHeight - triggerRect.bottom - offset
|
const spaceBelow = viewportHeight - triggerRect.bottom - offset;
|
||||||
if (spaceBelow < popoverRect.height) {
|
if (spaceBelow < popoverRect.height) {
|
||||||
effectivePlacement = 'top'
|
effectivePlacement = 'top';
|
||||||
}
|
}
|
||||||
} else if ('left' === props.placement) {
|
} else if ('left' === props.placement) {
|
||||||
const spaceLeft = triggerRect.left - offset
|
const spaceLeft = triggerRect.left - offset;
|
||||||
if (spaceLeft < popoverRect.width) {
|
if (spaceLeft < popoverRect.width) {
|
||||||
effectivePlacement = 'right'
|
effectivePlacement = 'right';
|
||||||
}
|
}
|
||||||
} else if ('right' === props.placement) {
|
} else if ('right' === props.placement) {
|
||||||
const spaceRight = viewportWidth - triggerRect.right - offset
|
const spaceRight = viewportWidth - triggerRect.right - offset;
|
||||||
if (spaceRight < popoverRect.width) {
|
if (spaceRight < popoverRect.width) {
|
||||||
effectivePlacement = 'left'
|
effectivePlacement = 'left';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let top = triggerRect.bottom + offset
|
let top = triggerRect.bottom + offset;
|
||||||
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2
|
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
||||||
|
|
||||||
if ('top' === effectivePlacement) {
|
if ('top' === effectivePlacement) {
|
||||||
top = triggerRect.top - popoverRect.height - offset
|
top = triggerRect.top - popoverRect.height - offset;
|
||||||
} else if ('left' === effectivePlacement) {
|
} else if ('left' === effectivePlacement) {
|
||||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
||||||
left = triggerRect.left - popoverRect.width - offset
|
left = triggerRect.left - popoverRect.width - offset;
|
||||||
} else if ('right' === effectivePlacement) {
|
} else if ('right' === effectivePlacement) {
|
||||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
||||||
left = triggerRect.right + offset
|
left = triggerRect.right + offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||||
const maxLeft = viewportWidth - popoverRect.width - 8
|
const maxLeft = viewportWidth - popoverRect.width - 8;
|
||||||
const maxTop = viewportHeight - popoverRect.height - 8
|
const maxTop = viewportHeight - popoverRect.height - 8;
|
||||||
|
|
||||||
const clampedTop = clamp(Math.round(top), 8, maxTop)
|
const clampedTop = clamp(Math.round(top), 8, maxTop);
|
||||||
const clampedLeft = clamp(Math.round(left), 8, maxLeft)
|
const clampedLeft = clamp(Math.round(left), 8, maxLeft);
|
||||||
|
|
||||||
portalStyle.value = {
|
portalStyle.value = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|
@ -267,37 +295,37 @@ const updatePosition = () => {
|
||||||
minWidth: `${props.minWidth}px`,
|
minWidth: `${props.minWidth}px`,
|
||||||
maxWidth: `${constrainedMaxWidth}px`,
|
maxWidth: `${constrainedMaxWidth}px`,
|
||||||
maxHeight: `${props.maxHeight}px`,
|
maxHeight: `${props.maxHeight}px`,
|
||||||
overflowY: 'auto'
|
overflowY: 'auto',
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(isOpen, async (value) => {
|
watch(isOpen, async (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
await nextTick()
|
await nextTick();
|
||||||
updatePosition()
|
updatePosition();
|
||||||
await nextTick()
|
await nextTick();
|
||||||
emit('shown')
|
emit('shown');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('hidden')
|
emit('hidden');
|
||||||
})
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleDocumentClick, true)
|
document.addEventListener('click', handleDocumentClick, true);
|
||||||
document.addEventListener('keydown', handleEscape)
|
document.addEventListener('keydown', handleEscape);
|
||||||
window.addEventListener('resize', updatePosition)
|
window.addEventListener('resize', updatePosition);
|
||||||
window.addEventListener('scroll', updatePosition, true)
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', handleDocumentClick, true)
|
document.removeEventListener('click', handleDocumentClick, true);
|
||||||
document.removeEventListener('keydown', handleEscape)
|
document.removeEventListener('keydown', handleEscape);
|
||||||
window.removeEventListener('resize', updatePosition)
|
window.removeEventListener('resize', updatePosition);
|
||||||
window.removeEventListener('scroll', updatePosition, true)
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
clearHoverTimeout()
|
clearHoverTimeout();
|
||||||
clearOpenTimeout()
|
clearOpenTimeout();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -315,7 +343,7 @@ onBeforeUnmount(() => {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-trigger>* {
|
.popover-trigger > * {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +360,11 @@ onBeforeUnmount(() => {
|
||||||
background-color: var(--bulma-scheme-main, #fff);
|
background-color: var(--bulma-scheme-main, #fff);
|
||||||
color: var(--bulma-text, #363636);
|
color: var(--bulma-text, #363636);
|
||||||
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
|
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));
|
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);
|
border-radius: var(--bulma-radius-large, 0.5rem);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,22 @@
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||||
|
/></span>
|
||||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon" v-if="reference">
|
<div class="card-header-icon" v-if="reference">
|
||||||
<button type="button" @click="showImport = !showImport">
|
<button type="button" @click="showImport = !showImport">
|
||||||
<span class="icon"><i class="fa-solid" :class="{
|
<span class="icon"
|
||||||
'fa-arrow-down': !showImport,
|
><i
|
||||||
'fa-arrow-up': showImport,
|
class="fa-solid"
|
||||||
}" /></span>
|
:class="{
|
||||||
|
'fa-arrow-down': !showImport,
|
||||||
|
'fa-arrow-up': showImport,
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,15 +38,30 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select class="is-fullwidth" v-model="selected_preset" @change="import_existing_preset">
|
<select
|
||||||
|
class="is-fullwidth"
|
||||||
|
v-model="selected_preset"
|
||||||
|
@change="import_existing_preset"
|
||||||
|
>
|
||||||
<option value="" disabled>Select a preset</option>
|
<option value="" disabled>Select a preset</option>
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup
|
||||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
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) }}
|
{{ prettyName(item.name) }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
<option
|
||||||
|
v-for="item in filter_presets(true)"
|
||||||
|
:key="item.name"
|
||||||
|
:value="item.name"
|
||||||
|
>
|
||||||
{{ prettyName(item.name) }}
|
{{ prettyName(item.name) }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|
@ -49,8 +70,12 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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
|
<span
|
||||||
overwrite the current form data.</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -64,12 +89,21 @@
|
||||||
<div class="field-body">
|
<div class="field-body">
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" id="import_string" v-model="import_string"
|
<input
|
||||||
autocomplete="off">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="import_string"
|
||||||
|
v-model="import_string"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-primary" :disabled="!import_string" type="button"
|
<button
|
||||||
@click="importItem">
|
class="button is-primary"
|
||||||
|
:disabled="!import_string"
|
||||||
|
type="button"
|
||||||
|
@click="importItem"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
<span>Import</span>
|
<span>Import</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -78,12 +112,15 @@
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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:
|
<span
|
||||||
This will overwrite the current form data.</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
|
|
@ -93,7 +130,13 @@
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -109,8 +152,15 @@
|
||||||
Priority
|
Priority
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="number" class="input" id="priority" v-model.number="form.priority"
|
<input
|
||||||
:disabled="addInProgress" min="0" placeholder="0">
|
type="number"
|
||||||
|
class="input"
|
||||||
|
id="priority"
|
||||||
|
v-model.number="form.priority"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help">
|
<span class="help">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -131,8 +181,15 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
|
<input
|
||||||
v-model="form.folder" :disabled="addInProgress" list="folders">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="folder"
|
||||||
|
placeholder="Leave empty to use default download path"
|
||||||
|
v-model="form.folder"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
list="folders"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
|
|
@ -148,8 +205,14 @@
|
||||||
Output template
|
Output template
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" class="input" id="output_template" :disabled="addInProgress"
|
<input
|
||||||
placeholder="Leave empty to use default template." v-model="form.template">
|
type="text"
|
||||||
|
class="input"
|
||||||
|
id="output_template"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="Leave empty to use default template."
|
||||||
|
v-model="form.template"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -164,15 +227,22 @@
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Command options for yt-dlp</span>
|
<span>Command options for yt-dlp</span>
|
||||||
</label>
|
</label>
|
||||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
<TextareaAutocomplete
|
||||||
:disabled="addInProgress" />
|
id="cli_options"
|
||||||
|
v-model="form.cli"
|
||||||
|
:options="ytDlpOpt"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
/>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
|
||||||
supported <NuxtLink target="_blank"
|
options are supported
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
<NuxtLink
|
||||||
are ignored</NuxtLink>. Use with caution.
|
target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
|
||||||
|
>some are ignored</NuxtLink
|
||||||
|
>. Use with caution.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,21 +250,39 @@
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">
|
<label
|
||||||
|
class="label is-inline"
|
||||||
|
for="cookies"
|
||||||
|
v-tooltip="'Netscape HTTP Cookie format.'"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||||
Cookies - <NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()">Upload file</NuxtLink>
|
Cookies -
|
||||||
|
<NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()"
|
||||||
|
>Upload file</NuxtLink
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<TextDropzone ref="cookiesDropzoneRef" id="cookies" v-model="form.cookies" :disabled="addInProgress"
|
<TextDropzone
|
||||||
|
ref="cookiesDropzoneRef"
|
||||||
|
id="cookies"
|
||||||
|
v-model="form.cookies"
|
||||||
|
: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>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<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"
|
<span
|
||||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
|
>Use this cookies if non are given with the URL. Use the
|
||||||
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
|
<NuxtLink
|
||||||
Cookie format.
|
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>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,8 +295,13 @@
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress"
|
<textarea
|
||||||
placeholder="Extras instructions for users to follow" />
|
class="textarea"
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
placeholder="Extras instructions for users to follow"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -221,15 +314,24 @@
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
<button
|
||||||
:class="{ 'is-loading': addInProgress }" form="presetForm">
|
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>
|
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||||
<span>Save</span>
|
<span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
<button
|
||||||
type="button">
|
class="button is-fullwidth is-danger"
|
||||||
|
@click="emitter('cancel')"
|
||||||
|
:disabled="addInProgress"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -250,191 +352,199 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||||
import TextDropzone from '~/components/TextDropzone.vue'
|
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, prettyName } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(event: 'cancel'): void
|
(event: 'cancel'): void;
|
||||||
(event: 'submit', payload: { reference: number | null, preset: Preset }): void
|
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reference?: number | null
|
reference?: number | null;
|
||||||
preset: Partial<Preset>
|
preset: Partial<Preset>;
|
||||||
addInProgress?: boolean
|
addInProgress?: boolean;
|
||||||
presets?: Preset[]
|
presets?: Preset[];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)))
|
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)));
|
||||||
const import_string = ref<string>('')
|
const import_string = ref<string>('');
|
||||||
const showImport = useStorage<boolean>('showImport', false)
|
const showImport = useStorage<boolean>('showImport', false);
|
||||||
const selected_preset = ref<string>('')
|
const selected_preset = ref<string>('');
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(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) {
|
if (form.priority === undefined) {
|
||||||
form.priority = 0
|
form.priority = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(
|
||||||
.filter(opt => !opt.ignored)
|
() => config.ytdlp_options,
|
||||||
.flatMap(opt => opt.flags
|
(newOptions) =>
|
||||||
.filter(flag => flag.startsWith('--'))
|
(ytDlpOpt.value = newOptions
|
||||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
.filter((opt) => !opt.ignored)
|
||||||
{ immediate: true }
|
.flatMap((opt) =>
|
||||||
)
|
opt.flags
|
||||||
|
.filter((flag) => flag.startsWith('--'))
|
||||||
|
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
|
)),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
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]) {
|
||||||
toast.error(`The ${key} field is required.`)
|
toast.error(`The ${key} field is required.`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedName = normalizePresetName(String(form.name))
|
const normalizedName = normalizePresetName(String(form.name));
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
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) {
|
||||||
form.folder = form.folder.trim()
|
form.folder = form.folder.trim();
|
||||||
await nextTick()
|
await nextTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.cli && '' !== form.cli) {
|
if (form.cli && '' !== form.cli) {
|
||||||
const options = await convertOptions(form.cli)
|
const options = await convertOptions(form.cli);
|
||||||
if (null === options) {
|
if (null === options) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
form.cli = form.cli.trim()
|
form.cli = form.cli.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: Preset = JSON.parse(JSON.stringify(form))
|
const copy: Preset = JSON.parse(JSON.stringify(form));
|
||||||
let usedName = false
|
let usedName = false;
|
||||||
const name = normalizedName
|
const name = normalizedName;
|
||||||
|
|
||||||
props.presets?.forEach(p => {
|
props.presets?.forEach((p) => {
|
||||||
if (p.id === props.reference) {
|
if (p.id === props.reference) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (p.name === name) {
|
if (p.name === name) {
|
||||||
usedName = true
|
usedName = true;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
if (usedName) {
|
if (usedName) {
|
||||||
toast.error('The preset name is already in use.')
|
toast.error('The preset name is already in use.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in copy) {
|
for (const key in copy) {
|
||||||
const val = copy[key as keyof Preset]
|
const val = copy[key as keyof Preset];
|
||||||
if ('string' === typeof val) {
|
if ('string' === typeof val) {
|
||||||
(copy as any)[key] = val.trim()
|
(copy as any)[key] = val.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> => {
|
const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await convertCliOptions(args)
|
const response = await convertCliOptions(args);
|
||||||
|
|
||||||
if (response.output_template) {
|
if (response.output_template) {
|
||||||
form.template = response.output_template
|
form.template = response.output_template;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.download_path) {
|
if (response.download_path) {
|
||||||
form.folder = response.download_path
|
form.folder = response.download_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.opts as Record<string, any>
|
return response.opts as Record<string, any>;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message)
|
toast.error(e.message);
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const importItem = async (): Promise<void> => {
|
const importItem = async (): Promise<void> => {
|
||||||
const val = import_string.value.trim()
|
const val = import_string.value.trim();
|
||||||
if (!val) {
|
if (!val) {
|
||||||
toast.error('The import string is required.')
|
toast.error('The import string is required.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as Preset & ImportedItem
|
const item = decode(val) as Preset & ImportedItem;
|
||||||
|
|
||||||
if (!item?._type || 'preset' !== item._type) {
|
if (!item?._type || 'preset' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
toast.error(
|
||||||
return
|
`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ''
|
import_string.value = '';
|
||||||
showImport.value = false
|
showImport.value = false;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Failed to parse. ${e.message}`)
|
toast.error(`Failed to parse. ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const filter_presets = (flag = true): Preset[] => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag = true): Preset[] =>
|
||||||
|
config.presets.filter((item) => item.default === flag);
|
||||||
|
|
||||||
const import_existing_preset = async (): Promise<void> => {
|
const import_existing_preset = async (): Promise<void> => {
|
||||||
if (!selected_preset.value) {
|
if (!selected_preset.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preset = config.presets.find(p => p.name === selected_preset.value)
|
const preset = config.presets.find((p) => p.name === selected_preset.value);
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
toast.error('Preset not found.')
|
toast.error('Preset not found.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.cli = preset.cli || ''
|
form.cli = preset.cli || '';
|
||||||
form.folder = preset.folder || ''
|
form.folder = preset.folder || '';
|
||||||
form.template = preset.template || ''
|
form.template = preset.template || '';
|
||||||
form.cookies = preset.cookies || ''
|
form.cookies = preset.cookies || '';
|
||||||
form.description = preset.description || ''
|
form.description = preset.description || '';
|
||||||
form.priority = preset.priority ?? 0
|
form.priority = preset.priority ?? 0;
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
selected_preset.value = ''
|
selected_preset.value = '';
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end" v-if="!socket.isConnected">
|
<div
|
||||||
|
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
|
||||||
|
v-if="!socket.isConnected"
|
||||||
|
>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button type="button" class="button is-info" @click="refreshQueue" :class="{ 'is-loading': isRefreshing }"
|
<button
|
||||||
v-tooltip.bottom="'Refresh queue data'">
|
type="button"
|
||||||
|
class="button is-info"
|
||||||
|
@click="refreshQueue"
|
||||||
|
:class="{ 'is-loading': isRefreshing }"
|
||||||
|
v-tooltip.bottom="'Refresh queue data'"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-refresh" />
|
<i class="fa-solid fa-refresh" />
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -15,35 +23,62 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
|
<div
|
||||||
v-if="filteredItems.length > 0">
|
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
|
||||||
|
v-if="filteredItems.length > 0"
|
||||||
|
>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
|
<button
|
||||||
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }">
|
type="button"
|
||||||
|
class="button"
|
||||||
|
@click="masterSelectAll = !masterSelectAll"
|
||||||
|
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
|
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!masterSelectAll">Select</span>
|
<span v-if="!masterSelectAll">Select</span>
|
||||||
<span v-else>Unselect</span>
|
<span v-else>Unselect</span>
|
||||||
<span v-if="selectedElms.length > 0">
|
<span v-if="selectedElms.length > 0">
|
||||||
(<u class="has-text-danger">{{ selectedElms.length }}</u>)
|
(<u class="has-text-danger">{{ selectedElms.length }}</u
|
||||||
|
>)
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-2-tablet is-5-mobile">
|
<div class="column is-2-tablet is-5-mobile">
|
||||||
<Dropdown label="Actions" icons="fa-solid fa-list">
|
<Dropdown label="Actions" icons="fa-solid fa-list">
|
||||||
<a v-if="hasManualStart" class="dropdown-item has-text-success" @click="hasSelected ? startItems() : null"
|
<a
|
||||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
v-if="hasManualStart"
|
||||||
|
class="dropdown-item has-text-success"
|
||||||
|
@click="hasSelected ? startItems() : null"
|
||||||
|
:style="{
|
||||||
|
opacity: !hasSelected ? 0.5 : 1,
|
||||||
|
cursor: !hasSelected ? 'not-allowed' : 'pointer',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
|
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
|
||||||
<span>Start</span>
|
<span>Start</span>
|
||||||
</a>
|
</a>
|
||||||
<a v-if="hasPausable" class="dropdown-item has-text-warning" @click="hasSelected ? pauseSelected() : null"
|
<a
|
||||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
v-if="hasPausable"
|
||||||
|
class="dropdown-item has-text-warning"
|
||||||
|
@click="hasSelected ? pauseSelected() : null"
|
||||||
|
:style="{
|
||||||
|
opacity: !hasSelected ? 0.5 : 1,
|
||||||
|
cursor: !hasSelected ? 'not-allowed' : 'pointer',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-pause" /></span>
|
<span class="icon"><i class="fa-solid fa-pause" /></span>
|
||||||
<span>Pause</span>
|
<span>Pause</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item has-text-warning" @click="hasSelected ? cancelSelected() : null"
|
<a
|
||||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
class="dropdown-item has-text-warning"
|
||||||
|
@click="hasSelected ? cancelSelected() : null"
|
||||||
|
:style="{
|
||||||
|
opacity: !hasSelected ? 0.5 : 1,
|
||||||
|
cursor: !hasSelected ? 'not-allowed' : 'pointer',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -54,16 +89,23 @@
|
||||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||||
<div class="column is-12" v-if="filteredItems.length > 0">
|
<div class="column is-12" v-if="filteredItems.length > 0">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 1300px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 1300px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
|
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
|
||||||
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
|
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
|
||||||
<span class="icon-text is-block">
|
<span class="icon-text is-block">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-regular"
|
<i
|
||||||
:class="{ 'fa-square-check': !masterSelectAll, 'fa-square': masterSelectAll }" />
|
class="fa-regular"
|
||||||
|
:class="{
|
||||||
|
'fa-square-check': !masterSelectAll,
|
||||||
|
'fa-square': masterSelectAll,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -79,13 +121,20 @@
|
||||||
<tr v-for="item in filteredItems" :key="item._id">
|
<tr v-for="item in filteredItems" :key="item._id">
|
||||||
<td class="has-text-centered is-vcentered">
|
<td class="has-text-centered is-vcentered">
|
||||||
<label class="checkbox is-block">
|
<label class="checkbox is-block">
|
||||||
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
|
<input
|
||||||
:value="item._id">
|
class="completed-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedElms"
|
||||||
|
:id="'checkbox-' + item._id"
|
||||||
|
:value="item._id"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || show_popover">
|
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || show_popover">
|
||||||
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span>
|
<span class="tag" v-if="item.downloaded_bytes">{{
|
||||||
|
formatBytes(item.downloaded_bytes)
|
||||||
|
}}</span>
|
||||||
<Popover :showDelay="400" :maxWidth="450" v-if="show_popover">
|
<Popover :showDelay="400" :maxWidth="450" v-if="show_popover">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span>
|
<span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span>
|
||||||
|
|
@ -103,9 +152,16 @@
|
||||||
<b>Path:</b> {{ getPath(config.app.download_path, item) }}
|
<b>Path:</b> {{ getPath(config.app.download_path, item) }}
|
||||||
</p>
|
</p>
|
||||||
<hr
|
<hr
|
||||||
v-if="(showThumbnails && getImage(config.app.download_path, item, false)) || item.description" />
|
v-if="
|
||||||
<img v-if="showThumbnails && getImage(config.app.download_path, item, false)"
|
(showThumbnails && getImage(config.app.download_path, item, false)) ||
|
||||||
:src="getImage(config.app.download_path, item, false)" class="card-image mt-2 mb-2" />
|
item.description
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="showThumbnails && getImage(config.app.download_path, item, false)"
|
||||||
|
:src="getImage(config.app.download_path, item, false)"
|
||||||
|
class="card-image mt-2 mb-2"
|
||||||
|
/>
|
||||||
<p v-if="item.description">{{ item.description }}</p>
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -122,25 +178,33 @@
|
||||||
<td>
|
<td>
|
||||||
<div class="progress-bar is-unselectable">
|
<div class="progress-bar is-unselectable">
|
||||||
<div class="progress-percentage" v-html="updateProgress(item)" />
|
<div class="progress-percentage" v-html="updateProgress(item)" />
|
||||||
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
|
<div
|
||||||
|
class="progress"
|
||||||
|
:style="{ width: percentPipe(item.percent as number) + '%' }"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||||
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
|
<span
|
||||||
v-rtime="item.datetime" />
|
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
|
||||||
|
:data-datetime="item.datetime"
|
||||||
|
v-rtime="item.datetime"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
|
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
|
||||||
<template v-if="isEmbedable(item.url)">
|
<template v-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger"
|
<NuxtLink
|
||||||
@click="embed_url = getEmbedable(item.url) as string">
|
class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item);">
|
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
||||||
<span>Cancel Download</span>
|
<span>Cancel Download</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -163,7 +227,10 @@
|
||||||
|
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
|
|
||||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
|
<NuxtLink
|
||||||
|
class="dropdown-item"
|
||||||
|
@click="emitter('getInfo', item.url, item.preset, item.cli)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp Information</span>
|
<span>yt-dlp Information</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -182,12 +249,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-else>
|
<div class="columns is-multiline" v-else>
|
||||||
<LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6"
|
<LateLoader
|
||||||
v-for="item in filteredItems" :key="item._id">
|
:unrender="true"
|
||||||
|
:min-height="showThumbnails ? 475 : 265"
|
||||||
|
class="column is-6"
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item._id"
|
||||||
|
>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<NuxtLink target="_blank" v-tooltip="item.title" :href="item.url">{{ item.title }}</NuxtLink>
|
<NuxtLink target="_blank" v-tooltip="item.title" :href="item.url">{{
|
||||||
|
item.title
|
||||||
|
}}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
|
@ -214,14 +288,22 @@
|
||||||
</Popover>
|
</Popover>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
||||||
<span class="icon"><i class="fa-solid"
|
<span class="icon"
|
||||||
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span>
|
><i
|
||||||
|
class="fa-solid"
|
||||||
|
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }"
|
||||||
|
/></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="checkbox is-block">
|
<label class="checkbox is-block">
|
||||||
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
|
<input
|
||||||
:value="item._id">
|
class="completed-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="selectedElms"
|
||||||
|
:id="'checkbox-' + item._id"
|
||||||
|
:value="item._id"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -229,16 +311,27 @@
|
||||||
</header>
|
</header>
|
||||||
<div v-if="showThumbnails" class="card-image">
|
<div v-if="showThumbnails" class="card-image">
|
||||||
<figure :class="['image', thumbnail_ratio]">
|
<figure :class="['image', thumbnail_ratio]">
|
||||||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
<span
|
||||||
class="play-overlay">
|
v-if="isEmbedable(item.url)"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string"
|
||||||
|
class="play-overlay"
|
||||||
|
>
|
||||||
<div class="play-icon embed-icon"></div>
|
<div class="play-icon embed-icon"></div>
|
||||||
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
<img
|
||||||
:src="getImage(config.app.download_path, item)" />
|
@load="pImg"
|
||||||
|
@error="onImgError"
|
||||||
|
v-if="getImage(config.app.download_path, item)"
|
||||||
|
:src="getImage(config.app.download_path, item)"
|
||||||
|
/>
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
<img
|
||||||
:src="getImage(config.app.download_path, item)" />
|
@load="pImg"
|
||||||
|
@error="onImgError"
|
||||||
|
v-if="getImage(config.app.download_path, item)"
|
||||||
|
:src="getImage(config.app.download_path, item)"
|
||||||
|
/>
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</template>
|
</template>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
@ -248,7 +341,10 @@
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="progress-bar is-unselectable">
|
<div class="progress-bar is-unselectable">
|
||||||
<div class="progress-percentage" v-html="updateProgress(item)" />
|
<div class="progress-percentage" v-html="updateProgress(item)" />
|
||||||
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
|
<div
|
||||||
|
class="progress"
|
||||||
|
:style="{ width: percentPipe(item.percent as number) + '%' }"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
|
|
@ -259,20 +355,27 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{ item.preset }}</span>
|
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{
|
||||||
|
item.preset
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
|
<span
|
||||||
v-rtime="item.datetime" />
|
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
|
||||||
|
:data-datetime="item.datetime"
|
||||||
|
v-rtime="item.datetime"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-unselectable" v-if="item.downloaded_bytes">
|
<div
|
||||||
|
class="column is-half-mobile has-text-centered is-unselectable"
|
||||||
|
v-if="item.downloaded_bytes"
|
||||||
|
>
|
||||||
{{ formatBytes(item.downloaded_bytes) }}
|
{{ formatBytes(item.downloaded_bytes) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
<div class="column is-half-mobile">
|
<div class="column is-half-mobile">
|
||||||
<button class="button is-warning is-fullwidth" @click="confirmCancel(item);">
|
<button class="button is-warning is-fullwidth" @click="confirmCancel(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
<span class="icon"><i class="fa-solid fa-eject" /></span>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -284,7 +387,10 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
|
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
|
||||||
<button class="button is-warning is-background-warning-85 is-fullwidth" @click="pauseItem(item)">
|
<button
|
||||||
|
class="button is-warning is-background-warning-85 is-fullwidth"
|
||||||
|
@click="pauseItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-pause" /></span>
|
<span class="icon"><i class="fa-solid fa-pause" /></span>
|
||||||
<span>Pause</span>
|
<span>Pause</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -292,14 +398,20 @@
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||||
<template v-if="isEmbedable(item.url)">
|
<template v-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url) as string">
|
<NuxtLink
|
||||||
|
class="dropdown-item has-text-danger"
|
||||||
|
@click="embed_url = getEmbedable(item.url) as string"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-play" /></span>
|
<span class="icon"><i class="fa-solid fa-play" /></span>
|
||||||
<span>Play video</span>
|
<span>Play video</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
|
<NuxtLink
|
||||||
|
class="dropdown-item"
|
||||||
|
@click="emitter('getInfo', item.url, item.preset, item.cli)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>yt-dlp Information</span>
|
<span>yt-dlp Information</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -318,14 +430,24 @@
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="filteredItems.length < 1">
|
<div class="columns is-multiline" v-if="filteredItems.length < 1">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message class="is-warning" title="Filter results" icon="fas fa-search" :useClose="true"
|
<Message
|
||||||
@close="() => emitter('clear_search')" v-if="query">
|
class="is-warning"
|
||||||
<span class="is-block">No results found for: <code>{{ query }}</code>.</span>
|
title="Filter results"
|
||||||
<hr>
|
icon="fas fa-search"
|
||||||
|
:useClose="true"
|
||||||
|
@close="() => emitter('clear_search')"
|
||||||
|
v-if="query"
|
||||||
|
>
|
||||||
|
<span class="is-block"
|
||||||
|
>No results found for: <code>{{ query }}</code
|
||||||
|
>.</span
|
||||||
|
>
|
||||||
|
<hr />
|
||||||
<p>
|
<p>
|
||||||
You can search using any value shown in the item’s <code><span class="icon"><i
|
You can search using any value shown in the item’s
|
||||||
class="fa-solid fa-info-circle" /></span> Local Information</code>. You can also do a targeted search
|
<code
|
||||||
using <code><u>key</u>:value</code>.
|
><span class="icon"><i class="fa-solid fa-info-circle" /></span> Local Information</code
|
||||||
|
>. You can also do a targeted search using <code><u>key</u>:value</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h5>Examples:</h5>
|
<h5>Examples:</h5>
|
||||||
|
|
@ -336,7 +458,13 @@
|
||||||
<li><code>source_name:task_name</code> - items added by the specified task.</li>
|
<li><code>source_name:task_name</code> - items added by the specified task.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-else class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false">
|
<Message
|
||||||
|
v-else
|
||||||
|
class="is-info"
|
||||||
|
title="No items"
|
||||||
|
icon="fas fa-exclamation-triangle"
|
||||||
|
:useClose="false"
|
||||||
|
>
|
||||||
<p>Download queue is empty.</p>
|
<p>Download queue is empty.</p>
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,371 +480,376 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment';
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { StoreItem } from '~/types/store'
|
import type { StoreItem } from '~/types/store';
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm';
|
||||||
import { deepIncludes } from '~/utils'
|
import { deepIncludes } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
(e: 'getInfo', url: string, preset: string, cli: string): void;
|
||||||
(e: 'getItemInfo', id: string): void
|
(e: 'getItemInfo', id: string): void;
|
||||||
(e: 'clear_search'): void
|
(e: 'clear_search'): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
thumbnails?: boolean
|
thumbnails?: boolean;
|
||||||
query?: string
|
query?: string;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore();
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
const hideThumbnail = useStorage('hideThumbnailQueue', false)
|
const hideThumbnail = useStorage('hideThumbnailQueue', false);
|
||||||
const display_style = useStorage('display_style', 'grid')
|
const display_style = useStorage('display_style', 'grid');
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage('random_bg', true);
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage('random_bg_opacity', 0.95);
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||||
const show_popover = useStorage<boolean>('show_popover', true)
|
const show_popover = useStorage<boolean>('show_popover', true);
|
||||||
|
|
||||||
const selectedElms = ref<string[]>([])
|
const selectedElms = ref<string[]>([]);
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false);
|
||||||
const embed_url = ref('')
|
const embed_url = ref('');
|
||||||
const isRefreshing = ref(false)
|
const isRefreshing = ref(false);
|
||||||
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null)
|
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
|
||||||
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true)
|
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
|
||||||
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000)
|
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
||||||
|
|
||||||
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value)
|
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value);
|
||||||
|
|
||||||
const refreshQueue = async () => {
|
const refreshQueue = async () => {
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true;
|
||||||
try {
|
try {
|
||||||
await stateStore.loadQueue()
|
await stateStore.loadQueue();
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to refresh queue')
|
toast.error('Failed to refresh queue');
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const startAutoRefresh = () => {
|
const startAutoRefresh = () => {
|
||||||
if (autoRefreshInterval.value) {
|
if (autoRefreshInterval.value) {
|
||||||
clearInterval(autoRefreshInterval.value)
|
clearInterval(autoRefreshInterval.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!autoRefreshEnabled.value || socket.isConnected) {
|
if (!autoRefreshEnabled.value || socket.isConnected) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoRefreshInterval.value = setInterval(async () => {
|
autoRefreshInterval.value = setInterval(async () => {
|
||||||
if (!socket.isConnected && autoRefreshEnabled.value) {
|
if (!socket.isConnected && autoRefreshEnabled.value) {
|
||||||
await refreshQueue()
|
await refreshQueue();
|
||||||
}
|
}
|
||||||
}, autoRefreshDelay.value)
|
}, autoRefreshDelay.value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const stopAutoRefresh = () => {
|
const stopAutoRefresh = () => {
|
||||||
if (autoRefreshInterval.value) {
|
if (autoRefreshInterval.value) {
|
||||||
clearInterval(autoRefreshInterval.value)
|
clearInterval(autoRefreshInterval.value);
|
||||||
autoRefreshInterval.value = null
|
autoRefreshInterval.value = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => socket.isConnected, (connected) => {
|
watch(
|
||||||
if (connected) {
|
() => socket.isConnected,
|
||||||
stopAutoRefresh()
|
(connected) => {
|
||||||
} else if (autoRefreshEnabled.value) {
|
if (connected) {
|
||||||
startAutoRefresh()
|
stopAutoRefresh();
|
||||||
}
|
} else if (autoRefreshEnabled.value) {
|
||||||
})
|
startAutoRefresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(autoRefreshEnabled, (enabled) => {
|
watch(autoRefreshEnabled, (enabled) => {
|
||||||
if (enabled && !socket.isConnected) {
|
if (enabled && !socket.isConnected) {
|
||||||
startAutoRefresh()
|
startAutoRefresh();
|
||||||
} else {
|
} else {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!socket.isConnected && autoRefreshEnabled.value) {
|
if (!socket.isConnected && autoRefreshEnabled.value) {
|
||||||
startAutoRefresh()
|
startAutoRefresh();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => stopAutoRefresh())
|
onBeforeUnmount(() => stopAutoRefresh());
|
||||||
|
|
||||||
watch(masterSelectAll, (value) => {
|
watch(masterSelectAll, (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id)
|
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id);
|
||||||
} else {
|
} else {
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const filteredItems = computed<StoreItem[]>(() => {
|
const filteredItems = computed<StoreItem[]>(() => {
|
||||||
const q = props.query?.toLowerCase()
|
const q = props.query?.toLowerCase();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return Object.values(stateStore.queue)
|
return Object.values(stateStore.queue);
|
||||||
}
|
}
|
||||||
return Object.values(stateStore.queue).filter((i: StoreItem) => deepIncludes(i, q, new WeakSet()))
|
return Object.values(stateStore.queue).filter((i: StoreItem) =>
|
||||||
})
|
deepIncludes(i, q, new WeakSet()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const hasSelected = computed(() => 0 < selectedElms.value.length)
|
const hasSelected = computed(() => 0 < selectedElms.value.length);
|
||||||
const hasManualStart = computed(() => {
|
const hasManualStart = computed(() => {
|
||||||
if (0 > stateStore.count('queue')) {
|
if (0 > stateStore.count('queue')) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
for (const key in stateStore.queue) {
|
for (const key in stateStore.queue) {
|
||||||
const item = stateStore.queue[key] as StoreItem
|
const item = stateStore.queue[key] as StoreItem;
|
||||||
if (!item.status && false === item.auto_start) {
|
if (!item.status && false === item.auto_start) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
|
|
||||||
const hasPausable = computed(() => {
|
const hasPausable = computed(() => {
|
||||||
if (0 > stateStore.count('queue')) {
|
if (0 > stateStore.count('queue')) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
for (const key in stateStore.queue) {
|
for (const key in stateStore.queue) {
|
||||||
const item = stateStore.queue[key] as StoreItem
|
const item = stateStore.queue[key] as StoreItem;
|
||||||
if (!item.status && true === item.auto_start) {
|
if (!item.status && true === item.auto_start) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
|
|
||||||
const setIcon = (item: StoreItem): string => {
|
const setIcon = (item: StoreItem): string => {
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'fa-hourglass-half'
|
return 'fa-hourglass-half';
|
||||||
}
|
}
|
||||||
if ('downloading' === item.status && item.is_live) {
|
if ('downloading' === item.status && item.is_live) {
|
||||||
return 'fa-globe fa-spin'
|
return 'fa-globe fa-spin';
|
||||||
}
|
}
|
||||||
if ('downloading' === item.status) {
|
if ('downloading' === item.status) {
|
||||||
return 'fa-download'
|
return 'fa-download';
|
||||||
}
|
}
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
return 'fa-cog fa-spin'
|
return 'fa-cog fa-spin';
|
||||||
}
|
}
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'fa-pause-circle'
|
return 'fa-pause-circle';
|
||||||
}
|
}
|
||||||
if (!item.status) {
|
if (!item.status) {
|
||||||
return 'fa-question'
|
return 'fa-question';
|
||||||
}
|
}
|
||||||
return 'fa-spinner fa-spin'
|
return 'fa-spinner fa-spin';
|
||||||
}
|
};
|
||||||
|
|
||||||
const setStatus = (item: StoreItem): string => {
|
const setStatus = (item: StoreItem): string => {
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'Pending'
|
return 'Pending';
|
||||||
}
|
}
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'Paused'
|
return 'Paused';
|
||||||
}
|
}
|
||||||
if ('downloading' === item.status && item.is_live) {
|
if ('downloading' === item.status && item.is_live) {
|
||||||
return 'Streaming'
|
return 'Streaming';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('started' === item.status) {
|
if ('started' === item.status) {
|
||||||
return 'Starting'
|
return 'Starting';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
|
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..';
|
||||||
}
|
}
|
||||||
if (!item.status) {
|
if (!item.status) {
|
||||||
return 'Unknown...'
|
return 'Unknown...';
|
||||||
}
|
}
|
||||||
return ucFirst(item.status)
|
return ucFirst(item.status);
|
||||||
}
|
};
|
||||||
|
|
||||||
const setIconColor = (item: StoreItem): string => {
|
const setIconColor = (item: StoreItem): string => {
|
||||||
if (['downloading', 'started'].includes(item.status as string)) {
|
if (['downloading', 'started'].includes(item.status as string)) {
|
||||||
return 'has-text-success'
|
return 'has-text-success';
|
||||||
}
|
}
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
return 'has-text-info'
|
return 'has-text-info';
|
||||||
}
|
}
|
||||||
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
||||||
return 'has-text-warning'
|
return 'has-text-warning';
|
||||||
}
|
}
|
||||||
return ''
|
return '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const ETAPipe = (value: number | null): string => {
|
const ETAPipe = (value: number | null): string => {
|
||||||
if (null === value || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return 'Live'
|
return 'Live';
|
||||||
}
|
}
|
||||||
if (value < 60) {
|
if (value < 60) {
|
||||||
return `${Math.round(value)}s`
|
return `${Math.round(value)}s`;
|
||||||
}
|
}
|
||||||
if (value < 3600) {
|
if (value < 3600) {
|
||||||
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`
|
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
|
||||||
}
|
}
|
||||||
const hours = Math.floor(value / 3600)
|
const hours = Math.floor(value / 3600);
|
||||||
const minutes = value % 3600
|
const minutes = value % 3600;
|
||||||
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
|
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const speedPipe = (value: number | null): string => {
|
const speedPipe = (value: number | null): string => {
|
||||||
if (null === value || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return '0KB/s'
|
return '0KB/s';
|
||||||
}
|
}
|
||||||
const k = 1024
|
const k = 1024;
|
||||||
const dm = 2
|
const dm = 2;
|
||||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
|
||||||
const i = Math.floor(Math.log(value) / Math.log(k))
|
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||||
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
}
|
};
|
||||||
|
|
||||||
const percentPipe = (value: number | null): string => {
|
const percentPipe = (value: number | null): string => {
|
||||||
if (null === value || 0 === value) {
|
if (null === value || 0 === value) {
|
||||||
return '00.00'
|
return '00.00';
|
||||||
}
|
}
|
||||||
return parseFloat(String(value)).toFixed(2)
|
return parseFloat(String(value)).toFixed(2);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateProgress = (item: StoreItem): string => {
|
const updateProgress = (item: StoreItem): string => {
|
||||||
let string = ''
|
let string = '';
|
||||||
if (!item.auto_start) {
|
if (!item.auto_start) {
|
||||||
return 'Manual start'
|
return 'Manual start';
|
||||||
}
|
}
|
||||||
if (null === item.status && true === config.paused) {
|
if (null === item.status && true === config.paused) {
|
||||||
return 'Global Pause'
|
return 'Global Pause';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('started' === item.status) {
|
if ('started' === item.status) {
|
||||||
return 'Starting'
|
return 'Starting';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('postprocessing' === item.status) {
|
if ('postprocessing' === item.status) {
|
||||||
if (item.postprocessor) {
|
if (item.postprocessor) {
|
||||||
return `<i class="fa fa-cog fa-spin"></i> PP: ${item.postprocessor}`
|
return `<i class="fa fa-cog fa-spin"></i> PP: ${item.postprocessor}`;
|
||||||
}
|
}
|
||||||
return 'Post-processors are running.'
|
return 'Post-processors are running.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing';
|
||||||
}
|
}
|
||||||
if (null != item.status) {
|
if (null != item.status) {
|
||||||
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'
|
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
|
||||||
}
|
}
|
||||||
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'
|
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
|
||||||
if (null != item.status && item.eta) {
|
if (null != item.status && item.eta) {
|
||||||
string += ' - ' + ETAPipe(item.eta)
|
string += ' - ' + ETAPipe(item.eta);
|
||||||
}
|
}
|
||||||
return string
|
return string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const confirmCancel = async (item: StoreItem) => {
|
const confirmCancel = async (item: StoreItem) => {
|
||||||
if (true !== (await box.confirm(`Cancel '${item.title}'?`))) {
|
if (true !== (await box.confirm(`Cancel '${item.title}'?`))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
cancelItems(item._id)
|
cancelItems(item._id);
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const cancelSelected = async () => {
|
const cancelSelected = async () => {
|
||||||
if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) {
|
if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
cancelItems(selectedElms.value)
|
cancelItems(selectedElms.value);
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
masterSelectAll.value = false
|
masterSelectAll.value = false;
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const cancelItems = (item: string | string[]) => {
|
const cancelItems = (item: string | string[]) => {
|
||||||
const items: string[] = []
|
const items: string[] = [];
|
||||||
if ('object' === typeof item) {
|
if ('object' === typeof item) {
|
||||||
for (const key in item) {
|
for (const key in item) {
|
||||||
items.push((item as any)[key])
|
items.push((item as any)[key]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items.push(item)
|
items.push(item);
|
||||||
}
|
}
|
||||||
if (0 > items.length) {
|
if (0 > items.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
stateStore.cancelItems(items)
|
stateStore.cancelItems(items);
|
||||||
}
|
};
|
||||||
|
|
||||||
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id])
|
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id]);
|
||||||
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id])
|
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id]);
|
||||||
|
|
||||||
const startItems = async () => {
|
const startItems = async () => {
|
||||||
if (1 > selectedElms.value.length) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const filtered: string[] = []
|
const filtered: string[] = [];
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach((id) => {
|
||||||
const item = stateStore.get('queue', id) as StoreItem
|
const item = stateStore.get('queue', id) as StoreItem;
|
||||||
if (item && !item.auto_start && !item.status) {
|
if (item && !item.auto_start && !item.status) {
|
||||||
filtered.push(id)
|
filtered.push(id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
if (1 > filtered.length) {
|
if (1 > filtered.length) {
|
||||||
toast.error('No eligible items to start.')
|
toast.error('No eligible items to start.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
|
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
await stateStore.startItems(filtered)
|
await stateStore.startItems(filtered);
|
||||||
}
|
};
|
||||||
|
|
||||||
const pauseSelected = async () => {
|
const pauseSelected = async () => {
|
||||||
if (1 > selectedElms.value.length) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const filtered: string[] = []
|
const filtered: string[] = [];
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach((id) => {
|
||||||
const item = stateStore.get('queue', id) as StoreItem
|
const item = stateStore.get('queue', id) as StoreItem;
|
||||||
if (item && item.auto_start && !item.status) {
|
if (item && item.auto_start && !item.status) {
|
||||||
filtered.push(id)
|
filtered.push(id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
if (1 > filtered.length) {
|
if (1 > filtered.length) {
|
||||||
toast.error('No eligible items to pause.')
|
toast.error('No eligible items to pause.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
|
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
await stateStore.pauseItems(filtered)
|
await stateStore.pauseItems(filtered);
|
||||||
}
|
};
|
||||||
|
|
||||||
const pImg = (e: Event) => {
|
const pImg = (e: Event) => {
|
||||||
const target = e.target as HTMLImageElement
|
const target = e.target as HTMLImageElement;
|
||||||
if (target.naturalHeight > target.naturalWidth) {
|
if (target.naturalHeight > target.naturalWidth) {
|
||||||
target.classList.add('image-portrait')
|
target.classList.add('image-portrait');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const onImgError = (e: Event) => {
|
const onImgError = (e: Event) => {
|
||||||
const target = e.target as HTMLImageElement
|
const target = e.target as HTMLImageElement;
|
||||||
if (target.src.endsWith('/images/placeholder.png')) {
|
if (target.src.endsWith('/images/placeholder.png')) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
target.src = '/images/placeholder.png'
|
target.src = '/images/placeholder.png';
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(embed_url, v => {
|
watch(embed_url, (v) => {
|
||||||
if (!bg_enable.value) {
|
if (!bg_enable.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="modal" :class="{ 'is-active': isOpen }">
|
<div class="modal" :class="{ 'is-active': isOpen }">
|
||||||
<div class="modal-background" @click="emitter('close')" />
|
<div class="modal-background" @click="emitter('close')" />
|
||||||
<div class="modal-card"
|
<div
|
||||||
:class="{ 'slide-from-right': direction === 'right', 'slide-from-left': direction === 'left' }">
|
class="modal-card"
|
||||||
|
:class="{
|
||||||
|
'slide-from-right': direction === 'right',
|
||||||
|
'slide-from-left': direction === 'left',
|
||||||
|
}"
|
||||||
|
>
|
||||||
<header class="modal-card-head is-rounded-less">
|
<header class="modal-card-head is-rounded-less">
|
||||||
<p class="modal-card-title">WebUI Settings</p>
|
<p class="modal-card-title">WebUI Settings</p>
|
||||||
<button class="delete" @click="emitter('close')" aria-label="close" />
|
<button class="delete" @click="emitter('close')" aria-label="close" />
|
||||||
|
|
@ -12,7 +17,12 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Page View</label>
|
<label class="label">Page View</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="view_mode" type="checkbox" class="switch is-success" v-model="simpleMode">
|
<input
|
||||||
|
id="view_mode"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-success"
|
||||||
|
v-model="simpleMode"
|
||||||
|
/>
|
||||||
<label for="view_mode" class="is-unselectable">
|
<label for="view_mode" class="is-unselectable">
|
||||||
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -36,17 +46,17 @@
|
||||||
<label class="label">Color scheme</label>
|
<label class="label">Color scheme</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label for="auto" class="radio">
|
<label for="auto" class="radio">
|
||||||
<input id="auto" type="radio" v-model="selectedTheme" value="auto">
|
<input id="auto" type="radio" v-model="selectedTheme" value="auto" />
|
||||||
<span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span>
|
<span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span>
|
||||||
<span>Auto</span>
|
<span>Auto</span>
|
||||||
</label>
|
</label>
|
||||||
<label for="light" class="radio">
|
<label for="light" class="radio">
|
||||||
<input id="light" type="radio" v-model="selectedTheme" value="light">
|
<input id="light" type="radio" v-model="selectedTheme" value="light" />
|
||||||
<span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span>
|
<span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span>
|
||||||
<span>Light</span>
|
<span>Light</span>
|
||||||
</label>
|
</label>
|
||||||
<label for="dark" class="radio">
|
<label for="dark" class="radio">
|
||||||
<input id="dark" type="radio" v-model="selectedTheme" value="dark">
|
<input id="dark" type="radio" v-model="selectedTheme" value="dark" />
|
||||||
<span class="icon"><i class="fa-solid fa-moon" /></span>
|
<span class="icon"><i class="fa-solid fa-moon" /></span>
|
||||||
<span>Dark</span>
|
<span>Dark</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -56,7 +66,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Show Background</label>
|
<label class="label">Show Background</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
|
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable" />
|
||||||
<label for="random_bg" class="is-unselectable">
|
<label for="random_bg" class="is-unselectable">
|
||||||
{{ bg_enable ? 'Yes' : 'No' }}
|
{{ bg_enable ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -65,9 +75,16 @@
|
||||||
|
|
||||||
<div class="field" v-if="bg_enable">
|
<div class="field" v-if="bg_enable">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button @click="$emit('reload_bg')" class="button is-link is-light is-fullwidth" :disabled="isLoading">
|
<button
|
||||||
<span class="icon"><i class="fa"
|
@click="$emit('reload_bg')"
|
||||||
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
|
class="button is-link is-light is-fullwidth"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<span class="icon"
|
||||||
|
><i
|
||||||
|
class="fa"
|
||||||
|
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }"
|
||||||
|
/></span>
|
||||||
<span>Reload Background</span>
|
<span>Reload Background</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,7 +99,14 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input class="input" type="range" v-model="bg_opacity" min="0.50" max="1.00" step="0.05">
|
<input
|
||||||
|
class="input"
|
||||||
|
type="range"
|
||||||
|
v-model="bg_opacity"
|
||||||
|
min="0.50"
|
||||||
|
max="1.00"
|
||||||
|
step="0.05"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,7 +125,11 @@
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="separator">
|
<select v-model="separator">
|
||||||
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
|
<option
|
||||||
|
v-for="(sep, index) in separators"
|
||||||
|
:key="`sep-${index}`"
|
||||||
|
:value="sep.value"
|
||||||
|
>
|
||||||
{{ sep.name }} ({{ sep.value }})
|
{{ sep.name }} ({{ sep.value }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -112,7 +140,12 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Show Thumbnails</label>
|
<label class="label">Show Thumbnails</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
|
<input
|
||||||
|
id="show_thumbnail"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-success"
|
||||||
|
v-model="show_thumbnail"
|
||||||
|
/>
|
||||||
<label for="show_thumbnail" class="is-unselectable">
|
<label for="show_thumbnail" class="is-unselectable">
|
||||||
{{ show_thumbnail ? 'Yes' : 'No' }}
|
{{ show_thumbnail ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -127,11 +160,11 @@
|
||||||
<label class="label">Aspect Ratio</label>
|
<label class="label">Aspect Ratio</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label for="ratio_16by9" class="radio">
|
<label for="ratio_16by9" class="radio">
|
||||||
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9">
|
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9" />
|
||||||
<span> 16:9</span>
|
<span> 16:9</span>
|
||||||
</label>
|
</label>
|
||||||
<label for="ratio_3by1" class="radio">
|
<label for="ratio_3by1" class="radio">
|
||||||
<input id="ratio_3by1" type="radio" v-model="thumbnail_ratio" value="is-3by1">
|
<input id="ratio_3by1" type="radio" v-model="thumbnail_ratio" value="is-3by1" />
|
||||||
<span> 3:1</span>
|
<span> 3:1</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,7 +176,12 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Popover</label>
|
<label class="label">Popover</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="show_popover" type="checkbox" class="switch is-success" v-model="show_popover">
|
<input
|
||||||
|
id="show_popover"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-success"
|
||||||
|
v-model="show_popover"
|
||||||
|
/>
|
||||||
<label for="show_popover" class="is-unselectable">
|
<label for="show_popover" class="is-unselectable">
|
||||||
{{ show_popover ? 'Yes' : 'No' }}
|
{{ show_popover ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -166,7 +204,12 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Auto-refresh queue when disconnected</label>
|
<label class="label">Auto-refresh queue when disconnected</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="queue_auto_refresh" type="checkbox" class="switch is-success" v-model="queue_auto_refresh">
|
<input
|
||||||
|
id="queue_auto_refresh"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-success"
|
||||||
|
v-model="queue_auto_refresh"
|
||||||
|
/>
|
||||||
<label for="queue_auto_refresh" class="is-unselectable">
|
<label for="queue_auto_refresh" class="is-unselectable">
|
||||||
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
|
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -186,7 +229,14 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input class="input" type="range" v-model.number="queue_auto_refresh_delay" min="5000" max="60000" step="5000">
|
<input
|
||||||
|
class="input"
|
||||||
|
type="range"
|
||||||
|
v-model.number="queue_auto_refresh_delay"
|
||||||
|
min="5000"
|
||||||
|
max="60000"
|
||||||
|
step="5000"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
|
|
@ -207,7 +257,12 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Show notifications</label>
|
<label class="label">Show notifications</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
|
<input
|
||||||
|
id="allow_toasts"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-success"
|
||||||
|
v-model="allow_toasts"
|
||||||
|
/>
|
||||||
<label for="allow_toasts" class="is-unselectable">
|
<label for="allow_toasts" class="is-unselectable">
|
||||||
{{ allow_toasts ? 'Yes' : 'No' }}
|
{{ allow_toasts ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -254,7 +309,12 @@
|
||||||
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
||||||
<label class="label">Dismiss notification on click</label>
|
<label class="label">Dismiss notification on click</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click">
|
<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">
|
<label for="dismiss_on_click" class="is-unselectable">
|
||||||
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -267,83 +327,91 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css';
|
||||||
import { watch, onMounted, onBeforeUnmount, ref } 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 { 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 } from '~/composables/useNotification';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
isOpen?: boolean
|
defineProps<{
|
||||||
direction?: 'left' | 'right'
|
isOpen?: boolean;
|
||||||
isLoading?: boolean
|
direction?: 'left' | 'right';
|
||||||
}>(), {
|
isLoading?: boolean;
|
||||||
isOpen: false,
|
}>(),
|
||||||
direction: 'right',
|
{
|
||||||
isLoading: false
|
isOpen: false,
|
||||||
})
|
direction: 'right',
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>()
|
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const notification = useNotification()
|
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 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<POSITION>('toast_position', 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);
|
||||||
const show_popover = useStorage<boolean>('show_popover', true)
|
const show_popover = useStorage<boolean>('show_popover', true);
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||||
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true)
|
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 handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if ('Escape' === e.key && props.isOpen) {
|
if ('Escape' === e.key && props.isOpen) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
emitter('close')
|
emitter('close');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isSecureContext.value = window.isSecureContext
|
isSecureContext.value = window.isSecureContext;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
if ('browser' === toast_target.value && !isSecureContext.value) {
|
if ('browser' === toast_target.value && !isSecureContext.value) {
|
||||||
toast_target.value = 'toast'
|
toast_target.value = 'toast';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown);
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown))
|
onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown));
|
||||||
|
|
||||||
const onNotificationTargetChange = async (): Promise<void> => {
|
const onNotificationTargetChange = async (): Promise<void> => {
|
||||||
if ('browser' === toast_target.value) {
|
if ('browser' === toast_target.value) {
|
||||||
const permission = await notification.requestBrowserPermission()
|
const permission = await notification.requestBrowserPermission();
|
||||||
if ('granted' !== permission) {
|
if ('granted' !== permission) {
|
||||||
toast_target.value = 'toast'
|
toast_target.value = 'toast';
|
||||||
notification.warning('Browser notification permission denied. Reverting to toast notifications.')
|
notification.warning(
|
||||||
|
'Browser notification permission denied. Reverting to toast notifications.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => props.isOpen, isOpen => {
|
watch(
|
||||||
if (isOpen) {
|
() => props.isOpen,
|
||||||
document.body.classList.add('settings-panel-open')
|
(isOpen) => {
|
||||||
} else {
|
if (isOpen) {
|
||||||
document.body.classList.remove('settings-panel-open')
|
document.body.classList.add('settings-panel-open');
|
||||||
}
|
} else {
|
||||||
})
|
document.body.classList.remove('settings-panel-open');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -386,7 +454,6 @@ watch(() => props.isOpen, isOpen => {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
.modal-card.slide-from-right,
|
.modal-card.slide-from-right,
|
||||||
.modal-card.slide-from-left {
|
.modal-card.slide-from-left {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
|
||||||
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,6 +1,6 @@
|
||||||
<style scoped>
|
<style scoped>
|
||||||
code {
|
code {
|
||||||
color: var(--bulma-code) !important
|
color: var(--bulma-code) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -16,14 +16,19 @@ code {
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input id="url" v-model="url" type="url" class="input" :class="{ 'is-danger': urlError }"
|
<input
|
||||||
placeholder="https://..." required />
|
id="url"
|
||||||
|
v-model="url"
|
||||||
|
type="url"
|
||||||
|
class="input"
|
||||||
|
:class="{ 'is-danger': urlError }"
|
||||||
|
placeholder="https://..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="urlError" class="help is-danger">{{ urlError }}</p>
|
<p v-if="urlError" class="help is-danger">{{ urlError }}</p>
|
||||||
<p v-else class="help is-bold">
|
<p v-else class="help is-bold">Enter the URL of the resource you want to inspect.</p>
|
||||||
Enter the URL of the resource you want to inspect.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="preset">
|
<label class="label" for="preset">
|
||||||
|
|
@ -35,13 +40,24 @@ code {
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth" v-model="preset">
|
<select id="preset" class="is-fullwidth" v-model="preset">
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup
|
||||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
label="Custom presets"
|
||||||
|
v-if="config?.presets.filter((p) => !p?.default).length > 0"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="cPreset in filter_presets(false)"
|
||||||
|
:key="cPreset.name"
|
||||||
|
:value="cPreset.name"
|
||||||
|
>
|
||||||
{{ cPreset.name }}
|
{{ cPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
|
<option
|
||||||
|
v-for="dPreset in filter_presets(true)"
|
||||||
|
:key="dPreset.name"
|
||||||
|
:value="dPreset.name"
|
||||||
|
>
|
||||||
{{ dPreset.name }}
|
{{ dPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|
@ -50,8 +66,8 @@ code {
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="help is-bold">
|
<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
|
Select a preset to apply its settings during inspection. In real scenario, the preset will
|
||||||
selected when creating the task.
|
be based on what is selected when creating the task.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -62,12 +78,18 @@ code {
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input id="handler" v-model="handler" type="text" class="input" placeholder="Handler class name" />
|
<input
|
||||||
|
id="handler"
|
||||||
|
v-model="handler"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Handler class name"
|
||||||
|
/>
|
||||||
<span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span>
|
<span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="help is-bold">
|
<p class="help is-bold">
|
||||||
In real scenario, the system auto-detects the appropriate handler based on the URL. This field is for testing
|
In real scenario, the system auto-detects the appropriate handler based on the URL. This
|
||||||
purposes only.
|
field is for testing purposes only.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-grouped is-grouped-right">
|
<div class="field is-grouped is-grouped-right">
|
||||||
|
|
@ -96,8 +118,12 @@ code {
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<div v-if="response" class="mt-4">
|
<div v-if="response" class="mt-4">
|
||||||
<Message v-if="response.error" class="is-danger" title="Error"
|
<Message
|
||||||
icon="fas fa-exclamation-triangle">
|
v-if="response.error"
|
||||||
|
class="is-danger"
|
||||||
|
title="Error"
|
||||||
|
icon="fas fa-exclamation-triangle"
|
||||||
|
>
|
||||||
<p>{{ response.error }}</p>
|
<p>{{ response.error }}</p>
|
||||||
<p v-if="response.message">{{ response.message }}</p>
|
<p v-if="response.message">{{ response.message }}</p>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
@ -110,76 +136,92 @@ code {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { 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';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
url?: string
|
url?: string;
|
||||||
preset?: string
|
preset?: string;
|
||||||
handler?: string
|
handler?: string;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const url = ref<string>(props.url ?? '')
|
const url = ref<string>(props.url ?? '');
|
||||||
const preset = ref<string>(props.preset || config.app.default_preset || '')
|
const preset = ref<string>(props.preset || config.app.default_preset || '');
|
||||||
const handler = ref<string>(props.handler ?? '')
|
const handler = ref<string>(props.handler ?? '');
|
||||||
const loading = ref<boolean>(false)
|
const loading = ref<boolean>(false);
|
||||||
const response = ref<TaskInspectResponse | null>(null)
|
const response = ref<TaskInspectResponse | null>(null);
|
||||||
const urlError = ref<string>('')
|
const urlError = ref<string>('');
|
||||||
|
|
||||||
// Watch for prop changes and update fields
|
// Watch for prop changes and update fields
|
||||||
watch(() => props.url, val => { if (val !== undefined) url.value = val })
|
watch(
|
||||||
watch(() => props.preset, val => { if (val !== undefined) preset.value = val })
|
() => props.url,
|
||||||
watch(() => props.handler, val => { if (val !== undefined) handler.value = val })
|
(val) => {
|
||||||
|
if (val !== undefined) url.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.preset,
|
||||||
|
(val) => {
|
||||||
|
if (val !== undefined) preset.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.handler,
|
||||||
|
(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 u = new URL(val);
|
||||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
urlError.value = ''
|
urlError.value = '';
|
||||||
response.value = null
|
response.value = null;
|
||||||
|
|
||||||
if (!url.value || !validateUrl(url.value)) {
|
if (!url.value || !validateUrl(url.value)) {
|
||||||
urlError.value = 'Please enter a valid URL.'
|
urlError.value = 'Please enter a valid URL.';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
|
|
||||||
const payload: TaskInspectRequest = {
|
const payload: TaskInspectRequest = {
|
||||||
url: url.value.trim(),
|
url: url.value.trim(),
|
||||||
preset: preset.value.trim() || undefined,
|
preset: preset.value.trim() || undefined,
|
||||||
handler: handler.value.trim() || undefined,
|
handler: handler.value.trim() || undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await request('/api/tasks/inspect', {
|
const res = await request('/api/tasks/inspect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
response.value = await res.json()
|
response.value = await res.json();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
response.value = { error: err?.message || 'Unknown error' }
|
response.value = { error: err?.message || 'Unknown error' };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
url.value = props.url || ''
|
url.value = props.url || '';
|
||||||
preset.value = props.preset || config.app.default_preset || ''
|
preset.value = props.preset || config.app.default_preset || '';
|
||||||
handler.value = props.handler || ''
|
handler.value = props.handler || '';
|
||||||
response.value = null
|
response.value = null;
|
||||||
urlError.value = ''
|
urlError.value = '';
|
||||||
}
|
};
|
||||||
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
|
const filter_presets = (flag: boolean = true) =>
|
||||||
|
config.presets.filter((item) => item.default === flag);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,53 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="file-dropzone is-relative" :class="{ 'is-dragging': isDragging }" @drop.prevent="handleDrop"
|
<div
|
||||||
@dragover.prevent="handleDragOver" @dragenter.prevent="handleDragEnter" @dragleave.prevent="handleDragLeave"
|
class="file-dropzone is-relative"
|
||||||
@click="handleClick">
|
:class="{ 'is-dragging': isDragging }"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
class="control textarea is-fullwidth"
|
||||||
|
:class="{ 'is-focused': isDragging }"
|
||||||
|
:value="model"
|
||||||
|
@input="handleInput"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:id="id"
|
||||||
|
/>
|
||||||
|
|
||||||
<textarea ref="textareaRef" class="control textarea is-fullwidth" :class="{ 'is-focused': isDragging }"
|
<div
|
||||||
:value="model" @input="handleInput" :disabled="disabled" :placeholder="placeholder" :id="id" />
|
v-if="isDragging"
|
||||||
|
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center"
|
||||||
<div v-if="isDragging"
|
>
|
||||||
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center">
|
|
||||||
<div class="has-text-centered has-text-dark">
|
<div class="has-text-centered has-text-dark">
|
||||||
<span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span>
|
<span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span>
|
||||||
<p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p>
|
<p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInputRef" type="file" :accept="accept" @change="handleFileSelect" style="display: none" />
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
id?: string
|
id?: string;
|
||||||
accept?: string
|
accept?: string;
|
||||||
maxSize?: number
|
maxSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
@ -34,202 +55,202 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
id: '',
|
id: '',
|
||||||
accept: '',
|
accept: '',
|
||||||
maxSize: 2 * 1024 * 1024
|
maxSize: 2 * 1024 * 1024,
|
||||||
})
|
});
|
||||||
|
|
||||||
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<HTMLTextAreaElement | 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 handleInput = (event: Event): void => {
|
||||||
const target = event.target as HTMLTextAreaElement
|
const target = event.target as HTMLTextAreaElement;
|
||||||
model.value = target.value
|
model.value = target.value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent): void => {
|
const handleDragEnter = (event: DragEvent): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dragCounter.value++
|
dragCounter.value++;
|
||||||
if (event.dataTransfer?.types.includes('Files')) {
|
if (event.dataTransfer?.types.includes('Files')) {
|
||||||
isDragging.value = true
|
isDragging.value = true;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragLeave = (_event: DragEvent): void => {
|
const handleDragLeave = (_event: DragEvent): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dragCounter.value--
|
dragCounter.value--;
|
||||||
if (0 === dragCounter.value) {
|
if (0 === dragCounter.value) {
|
||||||
isDragging.value = false
|
isDragging.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent): void => {
|
const handleDragOver = (event: DragEvent): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.dropEffect = 'copy'
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent): Promise<void> => {
|
const handleDrop = async (event: DragEvent): Promise<void> => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isDragging.value = false
|
isDragging.value = false;
|
||||||
dragCounter.value = 0
|
dragCounter.value = 0;
|
||||||
|
|
||||||
const files = event.dataTransfer?.files
|
const files = event.dataTransfer?.files;
|
||||||
if (!files || 0 === files.length) {
|
if (!files || 0 === files.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = files[0]
|
const file = files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
await processFile(file)
|
await processFile(file);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent): void => {
|
const handleClick = (event: MouseEvent): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection();
|
||||||
if (selection && selection.toString().length > 0) {
|
if (selection && selection.toString().length > 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event && event.target === textareaRef.value) {
|
if (event && event.target === textareaRef.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleFileSelect = async (event: Event): Promise<void> => {
|
const handleFileSelect = async (event: Event): Promise<void> => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement;
|
||||||
const files = target.files
|
const files = target.files;
|
||||||
|
|
||||||
if (!files || 0 === files.length) {
|
if (!files || 0 === files.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = files[0]
|
const file = files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
await processFile(file)
|
await processFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInputRef.value) {
|
if (fileInputRef.value) {
|
||||||
fileInputRef.value.value = ''
|
fileInputRef.value.value = '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const processFile = async (file: File): Promise<void> => {
|
const processFile = async (file: File): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
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.`
|
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`;
|
||||||
emit('error', errorMsg)
|
emit('error', errorMsg);
|
||||||
console.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.'
|
const errorMsg = 'File appears to be binary. Please provide a text file.';
|
||||||
emit('error', errorMsg)
|
emit('error', errorMsg);
|
||||||
console.error(errorMsg)
|
console.error(errorMsg);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await readFileAsText(file)
|
const text = await readFileAsText(file);
|
||||||
model.value = text
|
model.value = text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Failed to read file'
|
const errorMsg = error instanceof Error ? error.message : 'Failed to read file';
|
||||||
emit('error', errorMsg)
|
emit('error', errorMsg);
|
||||||
console.error('Failed to read file:', error)
|
console.error('Failed to read file:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const checkIfBinary = async (file: File): Promise<boolean> => {
|
const checkIfBinary = async (file: File): Promise<boolean> => {
|
||||||
const chunkSize = 8192
|
const chunkSize = 8192;
|
||||||
const blob = file.slice(0, chunkSize)
|
const blob = file.slice(0, chunkSize);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||||
if (!e.target?.result) {
|
if (!e.target?.result) {
|
||||||
resolve(false)
|
resolve(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (let i = 0; i < bytes.length; i++) {
|
||||||
const byte = bytes[i]
|
const byte = bytes[i];
|
||||||
|
|
||||||
if (undefined === byte) {
|
if (undefined === byte) {
|
||||||
continue
|
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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(nullBytes > 0 || (nonPrintable / bytes.length) > 0.3)
|
resolve(nullBytes > 0 || nonPrintable / bytes.length > 0.3);
|
||||||
}
|
};
|
||||||
|
|
||||||
reader.onerror = () => reject(new Error('Failed to read file for binary check'))
|
reader.onerror = () => reject(new Error('Failed to read file for binary check'));
|
||||||
reader.readAsArrayBuffer(blob)
|
reader.readAsArrayBuffer(blob);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const readFileAsText = (file: File): Promise<string> => {
|
const readFileAsText = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
|
|
||||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||||
if (e.target?.result) {
|
if (e.target?.result) {
|
||||||
resolve(e.target.result as string)
|
resolve(e.target.result as string);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Failed to read file'))
|
reject(new Error('Failed to read file'));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
fileInputRef.value?.click()
|
fileInputRef.value?.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
defineExpose({ triggerFileSelect })
|
defineExpose({ triggerFileSelect });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,43 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
<div
|
||||||
<div class="control" style="width:100%;">
|
class="dropdown"
|
||||||
<textarea :id="id" ref="textareaRef" v-model="localValue" @input="onInput" @focus="onFocus" @blur="hideList"
|
:class="{ 'is-active': showList && filteredOptions.length }"
|
||||||
@keydown="onKeydown" @keyup="updateCaret" @click="updateCaret" @mouseup="updateCaret"
|
style="width: 100%"
|
||||||
class="control is-fullwidth textarea" :placeholder="placeholder" autocomplete="off"
|
>
|
||||||
style="width:100%; position:relative; z-index:2;" rows="4" :disabled="disabled" />
|
<div class="control" style="width: 100%">
|
||||||
|
<textarea
|
||||||
|
:id="id"
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="localValue"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="hideList"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
@keyup="updateCaret"
|
||||||
|
@click="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>
|
||||||
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
<div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
|
||||||
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;">
|
<div class="dropdown-content" style="width: 100%; max-height: 11em; overflow-y: auto">
|
||||||
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="appendFlag(option.value)"
|
<a
|
||||||
|
v-for="(option, idx) in filteredOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@mousedown.prevent="appendFlag(option.value)"
|
||||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||||
style="display:flex; justify-content:space-between;" ref="dropdownItems">
|
style="display: flex; justify-content: space-between"
|
||||||
|
ref="dropdownItems"
|
||||||
|
>
|
||||||
<span class="has-text-weight-bold">{{ option.value }}</span>
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||||
<span class="has-text-grey-light is-text-overflow" style="margin-left:1em;">{{ option.description }}</span>
|
<span class="has-text-grey-light is-text-overflow" style="margin-left: 1em">{{
|
||||||
|
option.description
|
||||||
|
}}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -20,193 +45,204 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
options: AutoCompleteOptions
|
options: AutoCompleteOptions;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
id?: string
|
id?: string;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
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<HTMLTextAreaElement | 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);
|
||||||
const dropdownItems = ref<HTMLElement[]>([])
|
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
|
// 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);
|
||||||
const leftMatch = left.match(/(\S+)$/)
|
const leftMatch = left.match(/(\S+)$/);
|
||||||
const rightMatch = right.match(/^(\S+)/)
|
const rightMatch = right.match(/^(\S+)/);
|
||||||
const leftToken: string = (leftMatch?.[1] ?? '') as string
|
const leftToken: string = (leftMatch?.[1] ?? '') as string;
|
||||||
const rightToken: string = (rightMatch?.[1] ?? '') as string
|
const rightToken: string = (rightMatch?.[1] ?? '') as string;
|
||||||
const token = leftToken + rightToken
|
const token = leftToken + rightToken;
|
||||||
const start = leftMatch ? caret - leftToken.length : caret
|
const start = leftMatch ? caret - leftToken.length : caret;
|
||||||
const end = rightMatch ? caret + rightToken.length : caret
|
const end = rightMatch ? caret + rightToken.length : caret;
|
||||||
return { token, start, end }
|
return { token, start, end };
|
||||||
}
|
};
|
||||||
|
|
||||||
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
|
// 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 `--`
|
// 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
|
// 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 [];
|
||||||
}
|
}
|
||||||
const val = token.toLowerCase()
|
const val = token.toLowerCase();
|
||||||
const startsWithFlag = []
|
const startsWithFlag = [];
|
||||||
const includesFlag = []
|
const includesFlag = [];
|
||||||
const includesDesc = []
|
const includesDesc = [];
|
||||||
for (const opt of props.options) {
|
for (const opt of props.options) {
|
||||||
const flag = opt.value.toLowerCase()
|
const flag = opt.value.toLowerCase();
|
||||||
const desc = opt.description.toLowerCase()
|
const desc = opt.description.toLowerCase();
|
||||||
if (flag.startsWith(val)) {
|
if (flag.startsWith(val)) {
|
||||||
startsWithFlag.push(opt)
|
startsWithFlag.push(opt);
|
||||||
} else if (flag.includes(val)) {
|
} else if (flag.includes(val)) {
|
||||||
includesFlag.push(opt)
|
includesFlag.push(opt);
|
||||||
} else if (desc.includes(val)) {
|
} else if (desc.includes(val)) {
|
||||||
includesDesc.push(opt)
|
includesDesc.push(opt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
||||||
})
|
});
|
||||||
|
|
||||||
const appendFlag = (flag: string) => {
|
const appendFlag = (flag: string) => {
|
||||||
const value = localValue.value
|
const value = localValue.value;
|
||||||
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
|
// 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.value.selectionStart = textareaRef.value.selectionEnd = pos
|
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
||||||
caretIndex.value = pos
|
caretIndex.value = pos;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
// No token at caret: append at caret position
|
// 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.value.selectionStart = textareaRef.value.selectionEnd = pos
|
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
||||||
caretIndex.value = pos
|
caretIndex.value = pos;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
showList.value = false
|
showList.value = false;
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1;
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateCaret = () => {
|
const updateCaret = () => {
|
||||||
caretIndex.value = textareaRef.value ? (textareaRef.value.selectionStart ?? localValue.value.length) : localValue.value.length
|
caretIndex.value = textareaRef.value
|
||||||
}
|
? (textareaRef.value.selectionStart ?? localValue.value.length)
|
||||||
|
: localValue.value.length;
|
||||||
|
};
|
||||||
|
|
||||||
const onFocus = () => {
|
const onFocus = () => {
|
||||||
updateCaret()
|
updateCaret();
|
||||||
showList.value = true
|
showList.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
updateCaret()
|
updateCaret();
|
||||||
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||||
const hasEqual = token.includes('=')
|
const hasEqual = token.includes('=');
|
||||||
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
const isFlagTrigger = token.startsWith('--') && !hasEqual;
|
||||||
showList.value = isFlagTrigger && filteredOptions.value.length > 0
|
showList.value = isFlagTrigger && filteredOptions.value.length > 0;
|
||||||
highlightedIndex.value = showList.value ? 0 : -1
|
highlightedIndex.value = showList.value ? 0 : -1;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Reset scroll position when filtered options change
|
// 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')
|
const dropdown = document.querySelector('.dropdown-content');
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.scrollTop = 0
|
dropdown.scrollTop = 0;
|
||||||
}
|
}
|
||||||
const items = document.querySelectorAll('.dropdown-item')
|
const items = document.querySelectorAll('.dropdown-item');
|
||||||
dropdownItems.value = Array.from(items) as HTMLElement[]
|
dropdownItems.value = Array.from(items) as HTMLElement[];
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100)
|
const hideList = () =>
|
||||||
|
setTimeout(() => {
|
||||||
|
showList.value = false;
|
||||||
|
highlightedIndex.value = -1;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
const onKeydown = (e: KeyboardEvent) => {
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
// Track caret and allow ESC to immediately close suggestions and restore normal keys
|
// 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;
|
||||||
highlightedIndex.value = -1
|
highlightedIndex.value = -1;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!showList.value || !filteredOptions.value.length) {
|
if (!showList.value || !filteredOptions.value.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageSize = 5
|
const pageSize = 5;
|
||||||
|
|
||||||
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]
|
const el = dropdownItems.value[highlightedIndex.value];
|
||||||
if (el) el.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]
|
const el = dropdownItems.value[highlightedIndex.value];
|
||||||
if (el) el.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 + pageSize, filteredOptions.value.length - 1)
|
highlightedIndex.value = Math.min(
|
||||||
|
highlightedIndex.value + pageSize,
|
||||||
|
filteredOptions.value.length - 1,
|
||||||
|
);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = dropdownItems.value[highlightedIndex.value]
|
const el = dropdownItems.value[highlightedIndex.value];
|
||||||
if (el) el.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]
|
const el = dropdownItems.value[highlightedIndex.value];
|
||||||
if (el) el.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('=');
|
||||||
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
const isFlagTrigger = token.startsWith('--') && !hasEqual;
|
||||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
const selected =
|
||||||
filteredOptions.value[highlightedIndex.value] : undefined
|
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||||
|
? filteredOptions.value[highlightedIndex.value]
|
||||||
|
: undefined;
|
||||||
// Only autocomplete if there's a partial word being typed
|
// 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)
|
// dropdownItems is updated via a single top-level watch(filteredOptions)
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -83,25 +83,51 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="infoLoaded">
|
<div v-if="infoLoaded">
|
||||||
<div style="position: relative;">
|
<div style="position: relative">
|
||||||
<video class="player" ref="video" :poster="uri(thumbnail)" playsinline controls crossorigin="anonymous"
|
<video
|
||||||
preload="auto" autoplay>
|
class="player"
|
||||||
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
|
ref="video"
|
||||||
:type="source.type" />
|
:poster="uri(thumbnail)"
|
||||||
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
|
playsinline
|
||||||
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" />
|
controls
|
||||||
|
crossorigin="anonymous"
|
||||||
|
preload="auto"
|
||||||
|
autoplay
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
v-for="source in sources"
|
||||||
|
:key="source.src"
|
||||||
|
:src="source.src"
|
||||||
|
@error="source.onerror"
|
||||||
|
:type="source.type"
|
||||||
|
/>
|
||||||
|
<track
|
||||||
|
v-for="(track, i) in tracks"
|
||||||
|
:key="track.file"
|
||||||
|
:kind="track.kind"
|
||||||
|
:label="track.label"
|
||||||
|
:srclang="track.lang"
|
||||||
|
:src="track.file"
|
||||||
|
:default="notFirefox && 0 === i"
|
||||||
|
/>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<div class="is-flex is-justify-content-space-between">
|
||||||
<div>
|
<div>
|
||||||
<span v-if="infoLoaded && !usingHls && hasVideoStream" class="is-hidden-mobile has-text-info is-pointer"
|
<span
|
||||||
@click.prevent="forceSwitchToHls">
|
v-if="infoLoaded && !usingHls && hasVideoStream"
|
||||||
|
class="is-hidden-mobile has-text-info is-pointer"
|
||||||
|
@click.prevent="forceSwitchToHls"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-arrows-rotate" /></span>
|
<span class="icon"><i class="fa-solid fa-arrows-rotate" /></span>
|
||||||
<span>Trouble playing? switch to HLS stream.</span>
|
<span>Trouble playing? switch to HLS stream.</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="is-hidden-mobile has-text-grey-lighter is-pointer" @click="showHelp = !showHelp">
|
<span
|
||||||
|
class="is-hidden-mobile has-text-grey-lighter is-pointer"
|
||||||
|
@click="showHelp = !showHelp"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-question" /></span>
|
<span class="icon"><i class="fa-solid fa-question" /></span>
|
||||||
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
|
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -109,7 +135,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
|
<div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
|
||||||
<h2 style="margin-bottom: 1.5rem;">Keyboard Shortcuts</h2>
|
<h2 style="margin-bottom: 1.5rem">Keyboard Shortcuts</h2>
|
||||||
|
|
||||||
<div class="shortcuts-grid">
|
<div class="shortcuts-grid">
|
||||||
<div class="shortcut-section">
|
<div class="shortcut-section">
|
||||||
|
|
@ -238,11 +264,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-close-hint">Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close</div>
|
<div class="help-close-hint">
|
||||||
|
Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: center;" v-else>
|
<div style="text-align: center" v-else>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<i class="fa-solid fa-spinner fa-spin fa-4x" />
|
<i class="fa-solid fa-spinner fa-spin fa-4x" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,311 +278,325 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue';
|
||||||
import Hls from 'hls.js'
|
import Hls from 'hls.js';
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils';
|
||||||
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
|
||||||
|
|
||||||
import type { StoreItem } from '~/types/store'
|
import type { StoreItem } from '~/types/store';
|
||||||
import type { file_info, video_source_element, video_track_element } from '~/types/video'
|
import type { file_info, video_source_element, video_track_element } from '~/types/video';
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
|
|
||||||
const props = defineProps<{ item: StoreItem }>()
|
const props = defineProps<{ item: StoreItem }>();
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'closeModel'): void,
|
(e: 'closeModel'): void;
|
||||||
(e: 'error', message: string): void,
|
(e: 'error', message: string): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const video = useTemplateRef<HTMLVideoElement>('video')
|
const video = useTemplateRef<HTMLVideoElement>('video');
|
||||||
const tracks = ref<Array<video_track_element>>([])
|
const tracks = ref<Array<video_track_element>>([]);
|
||||||
const sources = ref<Array<video_source_element>>([])
|
const sources = ref<Array<video_source_element>>([]);
|
||||||
|
|
||||||
const thumbnail = ref('/images/placeholder.png')
|
const thumbnail = ref('/images/placeholder.png');
|
||||||
const artist = ref('')
|
const artist = ref('');
|
||||||
const title = ref('')
|
const title = ref('');
|
||||||
const isAudio = ref(false)
|
const isAudio = ref(false);
|
||||||
const hasVideoStream = ref(false)
|
const hasVideoStream = ref(false);
|
||||||
const videoWidth = ref<number | undefined>(undefined)
|
const videoWidth = ref<number | undefined>(undefined);
|
||||||
const videoHeight = ref<number | undefined>(undefined)
|
const videoHeight = ref<number | undefined>(undefined);
|
||||||
const volume = useStorage('player_volume', 1)
|
const volume = useStorage('player_volume', 1);
|
||||||
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
|
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox');
|
||||||
const infoLoaded = ref(false)
|
const infoLoaded = ref(false);
|
||||||
const destroyed = ref(false)
|
const destroyed = ref(false);
|
||||||
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
|
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
|
||||||
const havePoster = ref(false)
|
const havePoster = ref(false);
|
||||||
const showHelp = ref(false)
|
const showHelp = ref(false);
|
||||||
const usingHls = ref(false)
|
const usingHls = ref(false);
|
||||||
|
|
||||||
let unbindMediaSessionListeners: null | (() => void) = null
|
let unbindMediaSessionListeners: null | (() => void) = null;
|
||||||
let hls: Hls | null = null
|
let hls: Hls | null = null;
|
||||||
let cleanupKeyboardShortcuts: null | (() => void) = null
|
let cleanupKeyboardShortcuts: null | (() => void) = null;
|
||||||
|
|
||||||
const handle_event = (e: KeyboardEvent) => {
|
const handle_event = (e: KeyboardEvent) => {
|
||||||
if ('Escape' !== e.key) {
|
if ('Escape' !== e.key) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
emitter('closeModel')
|
emitter('closeModel');
|
||||||
}
|
};
|
||||||
|
|
||||||
const bindMediaSessionListeners = (el: HTMLVideoElement) => {
|
const bindMediaSessionListeners = (el: HTMLVideoElement) => {
|
||||||
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||||
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||||
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||||
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||||
const onPause = async (e: Event) => {
|
const onPause = async (e: Event) => {
|
||||||
const target = (e.currentTarget as HTMLVideoElement) ?? null
|
const target = (e.currentTarget as HTMLVideoElement) ?? null;
|
||||||
if (!target || destroyed.value) {
|
if (!target || destroyed.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const dataUrl = await captureFrame(target)
|
const dataUrl = await captureFrame(target);
|
||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
thumbnail.value = dataUrl
|
thumbnail.value = dataUrl;
|
||||||
havePoster.value = true
|
havePoster.value = true;
|
||||||
applyMediaSessionMetadata()
|
applyMediaSessionMetadata();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
el.addEventListener('loadedmetadata', onLoadedMetadata)
|
el.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
el.addEventListener('timeupdate', onTimeUpdate)
|
el.addEventListener('timeupdate', onTimeUpdate);
|
||||||
el.addEventListener('ratechange', onRateChange)
|
el.addEventListener('ratechange', onRateChange);
|
||||||
el.addEventListener('seeked', onSeeked)
|
el.addEventListener('seeked', onSeeked);
|
||||||
el.addEventListener('pause', onPause)
|
el.addEventListener('pause', onPause);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('loadedmetadata', onLoadedMetadata)
|
el.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
el.removeEventListener('timeupdate', onTimeUpdate)
|
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
el.removeEventListener('ratechange', onRateChange)
|
el.removeEventListener('ratechange', onRateChange);
|
||||||
el.removeEventListener('seeked', onSeeked)
|
el.removeEventListener('seeked', onSeeked);
|
||||||
el.removeEventListener('pause', onPause)
|
el.removeEventListener('pause', onPause);
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateMediaSessionPosition = (target: EventTarget | null) => {
|
const updateMediaSessionPosition = (target: EventTarget | null) => {
|
||||||
if (false === ('mediaSession' in navigator)) {
|
if (false === 'mediaSession' in navigator) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const el = (target as HTMLVideoElement) ?? null
|
const el = (target as HTMLVideoElement) ?? null;
|
||||||
if (!el || destroyed.value) {
|
if (!el || destroyed.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const d = el.duration
|
const d = el.duration;
|
||||||
if (false === Number.isFinite(d) || 0 >= d) {
|
if (false === Number.isFinite(d) || 0 >= d) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
navigator.mediaSession.setPositionState({
|
navigator.mediaSession.setPositionState({
|
||||||
duration: d,
|
duration: d,
|
||||||
playbackRate: el.playbackRate,
|
playbackRate: el.playbackRate,
|
||||||
position: el.currentTime,
|
position: el.currentTime,
|
||||||
})
|
});
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
};
|
||||||
|
|
||||||
const volume_change_handler = () => {
|
const volume_change_handler = () => {
|
||||||
const el = video.value
|
const el = video.value;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
volume.value = el.volume
|
volume.value = el.volume;
|
||||||
updateMediaSessionPosition(el)
|
updateMediaSessionPosition(el);
|
||||||
}
|
};
|
||||||
|
|
||||||
const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
|
const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
|
||||||
if (!el || destroyed.value) {
|
if (!el || destroyed.value) {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = el.videoWidth
|
const w = el.videoWidth;
|
||||||
const h = el.videoHeight
|
const h = el.videoHeight;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ('OffscreenCanvas' in window) {
|
if ('OffscreenCanvas' in window) {
|
||||||
const c = new (window as any).OffscreenCanvas(w, h)
|
const c = new (window as any).OffscreenCanvas(w, h);
|
||||||
const ctx = c.getContext('2d')
|
const ctx = c.getContext('2d');
|
||||||
if (!ctx) { return '' }
|
if (!ctx) {
|
||||||
ctx.drawImage(el, 0, 0, w, h)
|
return '';
|
||||||
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 })
|
}
|
||||||
return await new Promise<string>(r => {
|
ctx.drawImage(el, 0, 0, w, h);
|
||||||
const fr = new FileReader()
|
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 });
|
||||||
fr.onload = () => r(String(fr.result))
|
return await new Promise<string>((r) => {
|
||||||
fr.readAsDataURL(blob)
|
const fr = new FileReader();
|
||||||
})
|
fr.onload = () => r(String(fr.result));
|
||||||
|
fr.readAsDataURL(blob);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const c = document.createElement('canvas')
|
const c = document.createElement('canvas');
|
||||||
c.width = w
|
c.width = w;
|
||||||
c.height = h
|
c.height = h;
|
||||||
const ctx = c.getContext('2d')
|
const ctx = c.getContext('2d');
|
||||||
if (!ctx) { return '' }
|
if (!ctx) {
|
||||||
ctx.drawImage(el, 0, 0, w, h)
|
return '';
|
||||||
return c.toDataURL('image/jpeg', 0.86)
|
}
|
||||||
|
ctx.drawImage(el, 0, 0, w, h);
|
||||||
|
return c.toDataURL('image/jpeg', 0.86);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
|
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
|
||||||
if (!el || destroyed.value) {
|
if (!el || destroyed.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (havePoster.value) {
|
if (havePoster.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataUrl = await captureFrame(el)
|
const dataUrl = await captureFrame(el);
|
||||||
if (!dataUrl) {
|
if (!dataUrl) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnail.value = dataUrl
|
thumbnail.value = dataUrl;
|
||||||
havePoster.value = true
|
havePoster.value = true;
|
||||||
applyMediaSessionMetadata()
|
applyMediaSessionMetadata();
|
||||||
}
|
};
|
||||||
|
|
||||||
const restoreDefaultTextTrack = async () => {
|
const restoreDefaultTextTrack = async () => {
|
||||||
const el = video.value
|
const el = video.value;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tracksList = el.textTracks
|
const tracksList = el.textTracks;
|
||||||
if (!tracksList || 0 === tracksList.length) {
|
if (!tracksList || 0 === tracksList.length) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first track has cues - if not, we need to reload tracks
|
// Check if first track has cues - if not, we need to reload tracks
|
||||||
const firstTrack = tracksList[0] as TextTrack | undefined
|
const firstTrack = tracksList[0] as TextTrack | undefined;
|
||||||
const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0)
|
const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0);
|
||||||
|
|
||||||
if (needsReload) {
|
if (needsReload) {
|
||||||
const trackElements = el.querySelectorAll('track')
|
const trackElements = el.querySelectorAll('track');
|
||||||
|
|
||||||
trackElements.forEach((trackEl, idx) => {
|
trackElements.forEach((trackEl, idx) => {
|
||||||
const parent = trackEl.parentNode
|
const parent = trackEl.parentNode;
|
||||||
const clone = trackEl.cloneNode(true) as HTMLTrackElement
|
const clone = trackEl.cloneNode(true) as HTMLTrackElement;
|
||||||
trackEl.remove()
|
trackEl.remove();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.appendChild(clone)
|
parent.appendChild(clone);
|
||||||
// Set mode after cues load
|
// Set mode after cues load
|
||||||
clone.addEventListener('load', () => {
|
clone.addEventListener(
|
||||||
const trackObj = clone.track
|
'load',
|
||||||
if (trackObj) {
|
() => {
|
||||||
trackObj.mode = idx === 0 ? 'showing' : 'disabled'
|
const trackObj = clone.track;
|
||||||
}
|
if (trackObj) {
|
||||||
}, { once: true })
|
trackObj.mode = idx === 0 ? 'showing' : 'disabled';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, idx * 10)
|
}, idx * 10);
|
||||||
})
|
});
|
||||||
|
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < tracksList.length; i += 1) {
|
for (let i = 0; i < tracksList.length; i += 1) {
|
||||||
const track = tracksList[i] as TextTrack | undefined
|
const track = tracksList[i] as TextTrack | undefined;
|
||||||
if (track) {
|
if (track) {
|
||||||
track.mode = 'disabled'
|
track.mode = 'disabled';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50))
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
for (let i = 0; i < tracksList.length; i += 1) {
|
for (let i = 0; i < tracksList.length; i += 1) {
|
||||||
const track = tracksList[i] as TextTrack | undefined
|
const track = tracksList[i] as TextTrack | undefined;
|
||||||
if (!track) {
|
if (!track) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
const newMode = 0 === i ? 'showing' : 'disabled'
|
const newMode = 0 === i ? 'showing' : 'disabled';
|
||||||
track.mode = newMode
|
track.mode = newMode;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to restore subtitle track state', error)
|
console.warn('Failed to restore subtitle track state', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
const req = await request(makeDownload(config, props.item, 'api/file/info'))
|
const req = await request(makeDownload(config, props.item, 'api/file/info'));
|
||||||
const response: file_info = await req.json()
|
const response: file_info = await req.json();
|
||||||
|
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
emitter('error', response?.error || 'Failed to fetch video info. Unknown error')
|
emitter('error', response?.error || 'Failed to fetch video info. Unknown error');
|
||||||
emitter('closeModel')
|
emitter('closeModel');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
if (props.item.extras?.thumbnail) {
|
if (props.item.extras?.thumbnail) {
|
||||||
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail);
|
||||||
havePoster.value = true
|
havePoster.value = true;
|
||||||
} else {
|
} else {
|
||||||
if (response.sidecar?.image?.[0]?.file) {
|
if (response.sidecar?.image?.[0]?.file) {
|
||||||
thumbnail.value = makeDownload(config, { 'filename': response.sidecar.image[0].file })
|
thumbnail.value = makeDownload(config, { filename: response.sidecar.image[0].file });
|
||||||
havePoster.value = true
|
havePoster.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasVideoStream.value = Array.isArray(response.ffprobe?.video)
|
hasVideoStream.value =
|
||||||
&& response.ffprobe.video.some(s => 'video' === s.codec_type)
|
Array.isArray(response.ffprobe?.video) &&
|
||||||
|
response.ffprobe.video.some((s) => 'video' === s.codec_type);
|
||||||
|
|
||||||
// Extract video dimensions to prevent layout reflow
|
// Extract video dimensions to prevent layout reflow
|
||||||
if (hasVideoStream.value && response.ffprobe?.video) {
|
if (hasVideoStream.value && response.ffprobe?.video) {
|
||||||
const videoStream = response.ffprobe.video.find(s => 'video' === s.codec_type)
|
const videoStream = response.ffprobe.video.find((s) => 'video' === s.codec_type);
|
||||||
if (videoStream?.width && videoStream?.height) {
|
if (videoStream?.width && videoStream?.height) {
|
||||||
videoWidth.value = videoStream.width
|
videoWidth.value = videoStream.width;
|
||||||
videoHeight.value = videoStream.height
|
videoHeight.value = videoStream.height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
|
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
|
||||||
isAudio.value = true
|
isAudio.value = true;
|
||||||
} else {
|
} else {
|
||||||
if (false === hasVideoStream.value) {
|
if (false === hasVideoStream.value) {
|
||||||
isAudio.value = true
|
isAudio.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isApple) {
|
if (isApple) {
|
||||||
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
|
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4');
|
||||||
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8', allowedCodec ? false : true)
|
const src = makeDownload(
|
||||||
|
config,
|
||||||
|
props.item,
|
||||||
|
allowedCodec ? 'api/download' : 'm3u8',
|
||||||
|
allowedCodec ? false : true,
|
||||||
|
);
|
||||||
sources.value.push({
|
sources.value.push({
|
||||||
src,
|
src,
|
||||||
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
||||||
onerror: (err: Event) => src_error(err),
|
onerror: (err: Event) => src_error(err),
|
||||||
})
|
});
|
||||||
usingHls.value = !allowedCodec
|
usingHls.value = !allowedCodec;
|
||||||
} else {
|
} else {
|
||||||
const src = makeDownload(config, props.item, 'api/download', false)
|
const src = makeDownload(config, props.item, 'api/download', false);
|
||||||
sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err), })
|
sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err) });
|
||||||
usingHls.value = false
|
usingHls.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.item.extras?.channel) {
|
if (props.item.extras?.channel) {
|
||||||
artist.value = props.item.extras.channel
|
artist.value = props.item.extras.channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!artist.value && props.item.extras?.uploader) {
|
if (!artist.value && props.item.extras?.uploader) {
|
||||||
artist.value = props.item.extras.uploader
|
artist.value = props.item.extras.uploader;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.item?.title) {
|
if (props.item?.title) {
|
||||||
title.value = props.item.title
|
title.value = props.item.title;
|
||||||
} else {
|
} else {
|
||||||
if (response?.title) {
|
if (response?.title) {
|
||||||
title.value = response.title
|
title.value = response.title;
|
||||||
} else {
|
} else {
|
||||||
if (response.ffprobe?.metadata?.tags?.title) {
|
if (response.ffprobe?.metadata?.tags?.title) {
|
||||||
title.value = response.ffprobe.metadata.tags.title
|
title.value = response.ffprobe.metadata.tags.title;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -564,146 +606,145 @@ onMounted(async () => {
|
||||||
kind: 'captions',
|
kind: 'captions',
|
||||||
label: cap.name,
|
label: cap.name,
|
||||||
lang: cap.lang,
|
lang: cap.lang,
|
||||||
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
|
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
if (isApple) {
|
if (isApple) {
|
||||||
document.documentElement.style.setProperty('--webkit-text-track-display', 'block')
|
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
|
||||||
}
|
}
|
||||||
|
|
||||||
infoLoaded.value = true
|
infoLoaded.value = true;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
prepareVideoPlayer()
|
prepareVideoPlayer();
|
||||||
|
|
||||||
if (video.value) {
|
if (video.value) {
|
||||||
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
|
unbindMediaSessionListeners = bindMediaSessionListeners(video.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyboardShortcutsResult = useKeyboardShortcuts({
|
const keyboardShortcutsResult = useKeyboardShortcuts({
|
||||||
videoElement: video,
|
videoElement: video,
|
||||||
enabled: ref(true),
|
enabled: ref(true),
|
||||||
closePlayer: () => emitter('closeModel'),
|
closePlayer: () => emitter('closeModel'),
|
||||||
})
|
});
|
||||||
|
|
||||||
cleanupKeyboardShortcuts = keyboardShortcutsResult.attach()
|
cleanupKeyboardShortcuts = keyboardShortcutsResult.attach();
|
||||||
|
|
||||||
watch(keyboardShortcutsResult.showHelp, newVal => showHelp.value = newVal)
|
watch(keyboardShortcutsResult.showHelp, (newVal) => (showHelp.value = newVal));
|
||||||
|
|
||||||
document.addEventListener('keydown', handle_event)
|
document.addEventListener('keydown', handle_event);
|
||||||
})
|
});
|
||||||
|
|
||||||
const applyMediaSessionMetadata = () => {
|
const applyMediaSessionMetadata = () => {
|
||||||
if (false === ('mediaSession' in navigator)) {
|
if (false === 'mediaSession' in navigator) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const meta: MediaMetadataInit = { title: title.value }
|
const meta: MediaMetadataInit = { title: title.value };
|
||||||
if (artist.value) {
|
if (artist.value) {
|
||||||
meta.artist = artist.value
|
meta.artist = artist.value;
|
||||||
}
|
}
|
||||||
if (thumbnail.value) {
|
if (thumbnail.value) {
|
||||||
meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
|
meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata(meta)
|
navigator.mediaSession.metadata = new MediaMetadata(meta);
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
};
|
||||||
|
|
||||||
onUpdated(() => prepareVideoPlayer())
|
onUpdated(() => prepareVideoPlayer());
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy()
|
hls.destroy();
|
||||||
hls = null
|
hls = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
usingHls.value = false
|
usingHls.value = false;
|
||||||
|
|
||||||
document.removeEventListener('keydown', handle_event)
|
document.removeEventListener('keydown', handle_event);
|
||||||
|
|
||||||
if (cleanupKeyboardShortcuts) {
|
if (cleanupKeyboardShortcuts) {
|
||||||
cleanupKeyboardShortcuts()
|
cleanupKeyboardShortcuts();
|
||||||
cleanupKeyboardShortcuts = null
|
cleanupKeyboardShortcuts = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unbindMediaSessionListeners) {
|
if (unbindMediaSessionListeners) {
|
||||||
unbindMediaSessionListeners()
|
unbindMediaSessionListeners();
|
||||||
unbindMediaSessionListeners = null
|
unbindMediaSessionListeners = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title.value) {
|
if (title.value) {
|
||||||
window.document.title = 'YTPTube'
|
window.document.title = 'YTPTube';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!video.value) {
|
if (!video.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyed.value = true
|
destroyed.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
video.value.pause()
|
video.value.pause();
|
||||||
video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src'))
|
video.value.querySelectorAll('source').forEach((source) => source.removeAttribute('src'));
|
||||||
video.value.removeEventListener('volumechange', volume_change_handler)
|
video.value.removeEventListener('volumechange', volume_change_handler);
|
||||||
video.value.load()
|
video.value.load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
catch (e) {
|
});
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const prepareVideoPlayer = () => {
|
const prepareVideoPlayer = () => {
|
||||||
if (!infoLoaded.value) {
|
if (!infoLoaded.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMediaSessionMetadata()
|
applyMediaSessionMetadata();
|
||||||
|
|
||||||
if (title.value) {
|
if (title.value) {
|
||||||
window.document.title = `YTPTube - Playing: ${title.value}`
|
window.document.title = `YTPTube - Playing: ${title.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!video.value) {
|
if (!video.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
video.value.volume = volume.value
|
video.value.volume = volume.value;
|
||||||
video.value.addEventListener('volumechange', volume_change_handler)
|
video.value.addEventListener('volumechange', volume_change_handler);
|
||||||
restoreDefaultTextTrack()
|
restoreDefaultTextTrack();
|
||||||
|
|
||||||
if (hasVideoStream.value) {
|
if (hasVideoStream.value) {
|
||||||
if ('requestVideoFrameCallback' in video.value) {
|
if ('requestVideoFrameCallback' in video.value) {
|
||||||
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
|
(video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
|
||||||
} else {
|
} else {
|
||||||
const tryOnce = () => captureFirstFramePoster(video.value!);
|
const tryOnce = () => captureFirstFramePoster(video.value!);
|
||||||
; (video.value as any).addEventListener('loadeddata', tryOnce, { once: true })
|
(video.value as any).addEventListener('loadeddata', tryOnce, { once: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const src_error = async (e: any) => {
|
const src_error = async (e: any) => {
|
||||||
if (hls) {
|
if (hls) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
if (destroyed.value) {
|
if (destroyed.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (video.value && (notFirefox && video.value.paused)) {
|
if (video.value && notFirefox && video.value.paused) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('Source failed to load, attempting HLS fallback via hls.js...', e)
|
console.warn('Source failed to load, attempting HLS fallback via hls.js...', e);
|
||||||
attach_hls(makeDownload(config, props.item, 'm3u8', true))
|
attach_hls(makeDownload(config, props.item, 'm3u8', true));
|
||||||
}
|
};
|
||||||
|
|
||||||
const attach_hls = (link: string) => {
|
const attach_hls = (link: string) => {
|
||||||
if (!video.value) {
|
if (!video.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hls = new Hls({
|
hls = new Hls({
|
||||||
|
|
@ -712,45 +753,45 @@ const attach_hls = (link: string) => {
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
backBufferLength: 120,
|
backBufferLength: 120,
|
||||||
fragLoadingTimeOut: 200000,
|
fragLoadingTimeOut: 200000,
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata())
|
hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata());
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
await restoreDefaultTextTrack()
|
await restoreDefaultTextTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
|
hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
|
||||||
await new Promise(resolve => setTimeout(resolve, 200))
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
await restoreDefaultTextTrack()
|
await restoreDefaultTextTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.LEVEL_LOADED, () => {
|
hls.on(Hls.Events.LEVEL_LOADED, () => {
|
||||||
if (video.value) {
|
if (video.value) {
|
||||||
if ('requestVideoFrameCallback' in video.value) {
|
if ('requestVideoFrameCallback' in video.value) {
|
||||||
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
|
(video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
|
||||||
} else {
|
} else {
|
||||||
const once = () => captureFirstFramePoster(video.value!)
|
const once = () => captureFirstFramePoster(video.value!);
|
||||||
; (video.value as any).addEventListener('loadeddata', once, { once: true })
|
(video.value as any).addEventListener('loadeddata', once, { once: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
hls.loadSource(link)
|
hls.loadSource(link);
|
||||||
hls.attachMedia(video.value)
|
hls.attachMedia(video.value);
|
||||||
usingHls.value = true
|
usingHls.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const forceSwitchToHls = () => {
|
const forceSwitchToHls = () => {
|
||||||
if (usingHls.value) {
|
if (usingHls.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasVideoStream.value) {
|
if (!hasVideoStream.value) {
|
||||||
useNotification().error('Cannot switch to HLS: stream has no video track.')
|
useNotification().error('Cannot switch to HLS: stream has no video track.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
attach_hls(makeDownload(config, props.item, 'm3u8', true))
|
attach_hls(makeDownload(config, props.item, 'm3u8', true));
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
<!-- ui/app/pages/options.vue -->
|
<!-- ui/app/pages/options.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="p-1 container" style="border-radius: 0; padding:0">
|
<div class="p-1 container" style="border-radius: 0; padding: 0">
|
||||||
|
|
||||||
<div class="box m-2">
|
<div class="box m-2">
|
||||||
<div class="columns is-multiline is-vcentered">
|
<div class="columns is-multiline is-vcentered">
|
||||||
<div class="column is-12 is-6-desktop">
|
<div class="column is-12 is-6-desktop">
|
||||||
<label class="label is-small">Search</label>
|
<label class="label is-small">Search</label>
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input v-model.trim="filters.query" type="text" class="input" placeholder="Filter by flag or description..."
|
<input
|
||||||
autocomplete="off" />
|
v-model.trim="filters.query"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Filter by flag or description..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
<span class="icon is-left"><i class="fa-solid fa-magnifying-glass" /></span>
|
<span class="icon is-left"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,12 +62,27 @@
|
||||||
<div class="column is-12 is-6-desktop">
|
<div class="column is-12 is-6-desktop">
|
||||||
<label class="label is-small">Flags</label>
|
<label class="label is-small">Flags</label>
|
||||||
<div class="buttons are-small">
|
<div class="buttons are-small">
|
||||||
<button class="button" :class="{ 'is-link': filters.flagKind === 'any' }"
|
<button
|
||||||
@click="filters.flagKind = 'any'">Any</button>
|
class="button"
|
||||||
<button class="button" :class="{ 'is-link': filters.flagKind === 'short' }"
|
:class="{ 'is-link': filters.flagKind === 'any' }"
|
||||||
@click="filters.flagKind = 'short'">Short Only (-x)</button>
|
@click="filters.flagKind = 'any'"
|
||||||
<button class="button" :class="{ 'is-link': filters.flagKind === 'long' }"
|
>
|
||||||
@click="filters.flagKind = 'long'">Long Only (--xyz)</button>
|
Any
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:class="{ 'is-link': filters.flagKind === 'short' }"
|
||||||
|
@click="filters.flagKind = 'short'"
|
||||||
|
>
|
||||||
|
Short Only (-x)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:class="{ 'is-link': filters.flagKind === 'long' }"
|
||||||
|
@click="filters.flagKind = 'long'"
|
||||||
|
>
|
||||||
|
Long Only (--xyz)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,7 +97,10 @@
|
||||||
<h2 class="title is-6 mb-3">
|
<h2 class="title is-6 mb-3">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-regular fa-folder-open" /></span>
|
<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
|
||||||
|
>{{ group.name }}
|
||||||
|
<small class="has-text-grey">({{ group.items.length }})</small></span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
|
|
@ -93,8 +115,10 @@
|
||||||
<template v-for="opt in group.items" :key="opt.flags.join('|')">
|
<template v-for="opt in group.items" :key="opt.flags.join('|')">
|
||||||
<tr v-if="!opt.ignored">
|
<tr v-if="!opt.ignored">
|
||||||
<td>
|
<td>
|
||||||
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
<i
|
||||||
@click="copyFlag(opt.flags)" />
|
class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
||||||
|
@click="copyFlag(opt.flags)"
|
||||||
|
/>
|
||||||
<div class="is-flex is-align-items-center">
|
<div class="is-flex is-align-items-center">
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
||||||
|
|
@ -102,7 +126,9 @@
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="opt.description && 0 !== opt.description.length">{{ opt.description }}</span>
|
<span v-if="opt.description && 0 !== opt.description.length">{{
|
||||||
|
opt.description
|
||||||
|
}}</span>
|
||||||
<span v-else class="has-text-grey">—</span>
|
<span v-else class="has-text-grey">—</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -127,8 +153,10 @@
|
||||||
<template v-for="opt in visible" :key="opt.flags.join('|')">
|
<template v-for="opt in visible" :key="opt.flags.join('|')">
|
||||||
<tr v-if="!opt.ignored">
|
<tr v-if="!opt.ignored">
|
||||||
<td>
|
<td>
|
||||||
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
<i
|
||||||
@click="copyFlag(opt.flags)" />
|
class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
||||||
|
@click="copyFlag(opt.flags)"
|
||||||
|
/>
|
||||||
<div class="is-flex is-align-items-center">
|
<div class="is-flex is-align-items-center">
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
||||||
|
|
@ -139,7 +167,9 @@
|
||||||
{{ opt.group || 'root' }}
|
{{ opt.group || 'root' }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span v-if="opt.description && 0 !== opt.description.length">{{ opt.description }}</span>
|
<span v-if="opt.description && 0 !== opt.description.length">{{
|
||||||
|
opt.description
|
||||||
|
}}</span>
|
||||||
<span v-else class="has-text-grey">—</span>
|
<span v-else class="has-text-grey">—</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -152,127 +182,129 @@
|
||||||
</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';
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false);
|
||||||
const options = ref<YTDLPOption[]>([])
|
const options = ref<YTDLPOption[]>([]);
|
||||||
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped')
|
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped');
|
||||||
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag')
|
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag');
|
||||||
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc')
|
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc');
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
query: '',
|
query: '',
|
||||||
group: '',
|
group: '',
|
||||||
flagKind: 'any' as 'any' | 'short' | 'long',
|
flagKind: 'any' as 'any' | 'short' | 'long',
|
||||||
})
|
});
|
||||||
|
|
||||||
const reload = async (): Promise<void> => {
|
const reload = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
const resp = await request('/api/yt-dlp/options')
|
const resp = await request('/api/yt-dlp/options');
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
options.value = data as YTDLPOption[]
|
options.value = data as YTDLPOption[];
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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((f) => f.startsWith('--'));
|
||||||
if (!longFlag) {
|
if (!longFlag) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
copyText(longFlag)
|
copyText(longFlag);
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => await reload())
|
onMounted(async () => await reload());
|
||||||
|
|
||||||
const groupNames = computed<string[]>(() => {
|
const groupNames = computed<string[]>(() => {
|
||||||
const s = new Set<string>()
|
const s = new Set<string>();
|
||||||
for (const o of options.value) {
|
for (const o of options.value) {
|
||||||
s.add(o.group || 'root')
|
s.add(o.group || 'root');
|
||||||
}
|
}
|
||||||
return Array.from(s).sort((a, b) => a.localeCompare(b))
|
return Array.from(s).sort((a, b) => a.localeCompare(b));
|
||||||
})
|
});
|
||||||
|
|
||||||
const filtered = computed<YTDLPOption[]>(() => {
|
const filtered = computed<YTDLPOption[]>(() => {
|
||||||
const q = filters.query.toLowerCase()
|
const q = filters.query.toLowerCase();
|
||||||
const g = filters.group
|
const g = filters.group;
|
||||||
|
|
||||||
return options.value.filter((o) => {
|
return options.value.filter((o) => {
|
||||||
if (g && (o.group || 'root') !== g) {
|
if (g && (o.group || 'root') !== g) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('short' === filters.flagKind && !o.flags.some((f) => /^-\w(,|$)|^-\w$/.test(f))) {
|
if ('short' === filters.flagKind && !o.flags.some((f) => /^-\w(,|$)|^-\w$/.test(f))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('long' === filters.flagKind && !o.flags.some((f) => /^--[a-zA-Z0-9][\w-]*/.test(f))) {
|
if ('long' === filters.flagKind && !o.flags.some((f) => /^--[a-zA-Z0-9][\w-]*/.test(f))) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (0 !== q.length) {
|
if (0 !== q.length) {
|
||||||
const hay = [o.flags.join(' '), o.description || '', o.group || 'root'].join(' ').toLowerCase()
|
const hay = [o.flags.join(' '), o.description || '', o.group || 'root']
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
if (-1 === hay.indexOf(q)) {
|
if (-1 === hay.indexOf(q)) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const sorted = computed<YTDLPOption[]>(() => {
|
const sorted = computed<YTDLPOption[]>(() => {
|
||||||
const dir = 'asc' === sortDir.value ? 1 : -1
|
const dir = 'asc' === sortDir.value ? 1 : -1;
|
||||||
const arr = [...filtered.value]
|
const arr = [...filtered.value];
|
||||||
|
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
if ('group' === sortBy.value) {
|
if ('group' === sortBy.value) {
|
||||||
const ga = (a.group || 'root').localeCompare(b.group || 'root')
|
const ga = (a.group || 'root').localeCompare(b.group || 'root');
|
||||||
if (0 !== ga) {
|
if (0 !== ga) {
|
||||||
return ga * dir
|
return ga * dir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '')
|
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '');
|
||||||
return fa * dir
|
return fa * dir;
|
||||||
})
|
});
|
||||||
|
|
||||||
return arr
|
return arr;
|
||||||
})
|
});
|
||||||
|
|
||||||
const visible = computed<YTDLPOption[]>(() => sorted.value)
|
const visible = computed<YTDLPOption[]>(() => 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 o of visible.value) {
|
||||||
const key = o.group || 'root'
|
const key = o.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(o);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = 'asc' === sortDir.value ? 1 : -1
|
const dir = 'asc' === sortDir.value ? 1 : -1;
|
||||||
const out = Array.from(map.entries()).map(([name, items]) => ({ name, items }))
|
const out = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||||
|
|
||||||
if ('group' === sortBy.value) {
|
if ('group' === sortBy.value) {
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name) * dir)
|
out.sort((a, b) => a.name.localeCompare(b.name) * dir);
|
||||||
} else {
|
} else {
|
||||||
// When sorting by flag, groups should still be in alphabetical order
|
// When sorting by flag, groups should still be in alphabetical order
|
||||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out;
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<section
|
<section
|
||||||
class="hero has-text-centered is-flex is-align-items-center is-justify-content-center">
|
class="hero has-text-centered is-flex is-align-items-center is-justify-content-center"
|
||||||
|
>
|
||||||
<div class="goodbye-box">
|
<div class="goodbye-box">
|
||||||
<div class="goodbye-title">Goodbye!</div>
|
<div class="goodbye-title">Goodbye!</div>
|
||||||
<p class="goodbye-subtitle">YTPTube has shut down.</p>
|
<p class="goodbye-subtitle">YTPTube has shut down.</p>
|
||||||
|
|
@ -72,7 +73,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes floatTitle {
|
@keyframes floatTitle {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: translateY(0px);
|
transform: translateY(0px);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { ref, readonly, computed, toRaw } from 'vue'
|
import { ref, readonly, computed, toRaw } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { useConfigStore } from '~/stores/ConfigStore'
|
import { useConfigStore } from '~/stores/ConfigStore';
|
||||||
import { request, parse_api_error, sTrim, encodePath } from '~/utils'
|
import { request, parse_api_error, sTrim, encodePath } from '~/utils';
|
||||||
import type { FileItem, Pagination } from '~/types/filebrowser'
|
import type { FileItem, Pagination } from '~/types/filebrowser';
|
||||||
|
|
||||||
const items = ref<FileItem[]>([])
|
const items = ref<FileItem[]>([]);
|
||||||
const path = ref<string>('/')
|
const path = ref<string>('/');
|
||||||
const pagination = ref<Pagination>({
|
const pagination = ref<Pagination>({
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 50,
|
per_page: 50,
|
||||||
|
|
@ -14,219 +14,228 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
const selectedElms = ref<string[]>([])
|
const selectedElms = ref<string[]>([]);
|
||||||
const masterSelectAll = ref<boolean>(false)
|
const masterSelectAll = ref<boolean>(false);
|
||||||
const sort_by = ref<string>('name')
|
const sort_by = ref<string>('name');
|
||||||
const sort_order = ref<string>('asc')
|
const sort_order = ref<string>('asc');
|
||||||
const search = ref<string>('')
|
const search = ref<string>('');
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
const buildQueryParams = (page?: number): string => {
|
const buildQueryParams = (page?: number): string => {
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams();
|
||||||
params.set('page', String(page ?? pagination.value.page))
|
params.set('page', String(page ?? pagination.value.page));
|
||||||
params.set('per_page', String(config.app.default_pagination || 50))
|
params.set('per_page', String(config.app.default_pagination || 50));
|
||||||
params.set('sort_by', sort_by.value)
|
params.set('sort_by', sort_by.value);
|
||||||
params.set('sort_order', sort_order.value)
|
params.set('sort_order', sort_order.value);
|
||||||
if (search.value) {
|
if (search.value) {
|
||||||
params.set('search', search.value)
|
params.set('search', search.value);
|
||||||
}
|
}
|
||||||
return params.toString()
|
return params.toString();
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadContents = async (dir: string = '/', page: number = 1): Promise<boolean> => {
|
const loadContents = async (dir: string = '/', page: number = 1): Promise<boolean> => {
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
if (typeof dir !== 'string') {
|
if (typeof dir !== 'string') {
|
||||||
dir = '/'
|
dir = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
dir = encodePath(sTrim(dir, '/'))
|
dir = encodePath(sTrim(dir, '/'));
|
||||||
const query = buildQueryParams(page)
|
const query = buildQueryParams(page);
|
||||||
const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`)
|
const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`);
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
|
|
||||||
items.value = data.contents || []
|
items.value = data.contents || [];
|
||||||
path.value = data.path || '/'
|
path.value = data.path || '/';
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
pagination.value = data.pagination
|
pagination.value = data.pagination;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
masterSelectAll.value = false
|
masterSelectAll.value = false;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeSort = async (by: string): Promise<void> => {
|
const changeSort = async (by: string): Promise<void> => {
|
||||||
if (!['name', 'size', 'date', 'type'].includes(by)) {
|
if (!['name', 'size', 'date', 'type'].includes(by)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (by !== sort_by.value) {
|
if (by !== sort_by.value) {
|
||||||
sort_by.value = by
|
sort_by.value = by;
|
||||||
}
|
} else {
|
||||||
else {
|
sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc';
|
||||||
sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadContents(path.value, 1)
|
await loadContents(path.value, 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
const setSearch = async (value: string): Promise<void> => {
|
const setSearch = async (value: string): Promise<void> => {
|
||||||
search.value = value
|
search.value = value;
|
||||||
await loadContents(path.value, 1)
|
await loadContents(path.value, 1);
|
||||||
}
|
};
|
||||||
|
|
||||||
const setSortBy = (value: string): void => {
|
const setSortBy = (value: string): void => {
|
||||||
sort_by.value = value
|
sort_by.value = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const setSortOrder = (value: string): void => {
|
const setSortOrder = (value: string): void => {
|
||||||
sort_order.value = value
|
sort_order.value = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const setSearchValue = (value: string): void => {
|
const setSearchValue = (value: string): void => {
|
||||||
search.value = value
|
search.value = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const setPage = (value: number): void => {
|
const setPage = (value: number): void => {
|
||||||
pagination.value.page = value
|
pagination.value.page = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const changePage = async (page: number): Promise<void> => {
|
const changePage = async (page: number): Promise<void> => {
|
||||||
await loadContents(path.value, page)
|
await loadContents(path.value, page);
|
||||||
}
|
};
|
||||||
|
|
||||||
const performAction = async (
|
const performAction = async (
|
||||||
item: FileItem,
|
item: FileItem,
|
||||||
action: string,
|
action: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
callback?: (item: FileItem, action: string, data: Record<string, unknown>, source: FileItem) => void,
|
callback?: (
|
||||||
multiple: boolean = false
|
item: FileItem,
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
source: FileItem,
|
||||||
|
) => void,
|
||||||
|
multiple: boolean = false,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/file/actions', {
|
const response = await request('/api/file/actions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify([{ path: item.path, action, ...payload }]),
|
body: JSON.stringify([{ path: item.path, action, ...payload }]),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const results = await response.json() as Array<{ path: string, status: boolean, error?: string, [key: string]: unknown }>
|
const results = (await response.json()) as Array<{
|
||||||
|
path: string;
|
||||||
|
status: boolean;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (!multiple && result.path !== item.path) {
|
if (!multiple && result.path !== item.path) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!multiple && !result.status) {
|
if (!multiple && !result.status) {
|
||||||
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`)
|
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback && typeof callback === 'function') {
|
if (callback && typeof callback === 'function') {
|
||||||
if (!multiple) {
|
if (!multiple) {
|
||||||
callback(item, action, payload as Record<string, unknown>, item)
|
callback(item, action, payload as Record<string, unknown>, item);
|
||||||
}
|
} else {
|
||||||
else {
|
const matchedItem = items.value.find((it) => it.path === result.path);
|
||||||
const matchedItem = items.value.find(it => it.path === result.path)
|
|
||||||
if (matchedItem) {
|
if (matchedItem) {
|
||||||
callback(matchedItem, action, result as Record<string, unknown>, toRaw(item))
|
callback(matchedItem, action, result as Record<string, unknown>, toRaw(item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const performMassAction = async (
|
const performMassAction = async (
|
||||||
actions: Array<{ path: string, action: string, [key: string]: unknown }>,
|
actions: Array<{ path: string; action: string; [key: string]: unknown }>,
|
||||||
callback?: (result: { path: string, status: boolean, error?: string }) => void
|
callback?: (result: { path: string; status: boolean; error?: string }) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/file/actions', {
|
const response = await request('/api/file/actions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(actions),
|
body: JSON.stringify(actions),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const results = await response.json() as Array<{ path: string, status: boolean, error?: string }>
|
const results = (await response.json()) as Array<{
|
||||||
|
path: string;
|
||||||
|
status: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (!result.status) {
|
if (!result.status) {
|
||||||
notify.error(`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`)
|
notify.error(
|
||||||
continue
|
`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callback && typeof callback === 'function') {
|
if (callback && typeof callback === 'function') {
|
||||||
callback(result)
|
callback(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDirectory = async (dir: string, newDir: string): Promise<boolean> => {
|
const createDirectory = async (dir: string, newDir: string): Promise<boolean> => {
|
||||||
const trimmedDir = sTrim(newDir, '/')
|
const trimmedDir = sTrim(newDir, '/');
|
||||||
if (!trimmedDir || trimmedDir === dir) {
|
if (!trimmedDir || trimmedDir === dir) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await performAction(
|
const success = await performAction(
|
||||||
|
|
@ -234,17 +243,17 @@ const createDirectory = async (dir: string, newDir: string): Promise<boolean> =>
|
||||||
'directory',
|
'directory',
|
||||||
{ new_dir: trimmedDir },
|
{ new_dir: trimmedDir },
|
||||||
() => {
|
() => {
|
||||||
notify.success(`Successfully created '${trimmedDir}'.`)
|
notify.success(`Successfully created '${trimmedDir}'.`);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
return success
|
return success;
|
||||||
}
|
};
|
||||||
|
|
||||||
const renameItem = async (item: FileItem, newName: string): Promise<boolean> => {
|
const renameItem = async (item: FileItem, newName: string): Promise<boolean> => {
|
||||||
const trimmedName = newName.trim()
|
const trimmedName = newName.trim();
|
||||||
if (!trimmedName || trimmedName === item.name) {
|
if (!trimmedName || trimmedName === item.name) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await performAction(
|
return await performAction(
|
||||||
|
|
@ -252,33 +261,28 @@ const renameItem = async (item: FileItem, newName: string): Promise<boolean> =>
|
||||||
'rename',
|
'rename',
|
||||||
{ new_name: trimmedName },
|
{ new_name: trimmedName },
|
||||||
(it, _, data) => {
|
(it, _, data) => {
|
||||||
const source = data as { new_path?: string }
|
const source = data as { new_path?: string };
|
||||||
if (source.new_path) {
|
if (source.new_path) {
|
||||||
it.name = source.new_path.split('/').pop() || trimmedName
|
it.name = source.new_path.split('/').pop() || trimmedName;
|
||||||
it.path = source.new_path
|
it.path = source.new_path;
|
||||||
}
|
}
|
||||||
notify.success(`Renamed '${item.name}'.`)
|
notify.success(`Renamed '${item.name}'.`);
|
||||||
},
|
},
|
||||||
true
|
true,
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteItem = async (item: FileItem): Promise<boolean> => {
|
const deleteItem = async (item: FileItem): Promise<boolean> => {
|
||||||
return await performAction(
|
return await performAction(item, 'delete', {}, () => {
|
||||||
item,
|
items.value = items.value.filter((i) => i.path !== item.path);
|
||||||
'delete',
|
notify.warning(`Deleted '${item.name}'.`);
|
||||||
{},
|
});
|
||||||
() => {
|
};
|
||||||
items.value = items.value.filter(i => i.path !== item.path)
|
|
||||||
notify.warning(`Deleted '${item.name}'.`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
|
const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
|
||||||
const trimmedPath = sTrim(newPath, '/') || '/'
|
const trimmedPath = sTrim(newPath, '/') || '/';
|
||||||
if (!trimmedPath || trimmedPath === item.path) {
|
if (!trimmedPath || trimmedPath === item.path) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await performAction(
|
return await performAction(
|
||||||
|
|
@ -286,73 +290,73 @@ const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
|
||||||
'move',
|
'move',
|
||||||
{ new_path: trimmedPath },
|
{ new_path: trimmedPath },
|
||||||
(it, _, data) => {
|
(it, _, data) => {
|
||||||
const source = data as { new_path?: string; path?: string }
|
const source = data as { new_path?: string; path?: string };
|
||||||
if (source.path) {
|
if (source.path) {
|
||||||
items.value = items.value.filter(i => i.path !== source.path)
|
items.value = items.value.filter((i) => i.path !== source.path);
|
||||||
}
|
}
|
||||||
notify.success(`Moved '${item.name}' to '${trimmedPath}'.`)
|
notify.success(`Moved '${item.name}' to '${trimmedPath}'.`);
|
||||||
},
|
},
|
||||||
true
|
true,
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteSelected = async (): Promise<boolean> => {
|
const deleteSelected = async (): Promise<boolean> => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (selectedElms.value.length < 1) {
|
||||||
notify.error('No items selected.')
|
notify.error('No items selected.');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = selectedElms.value.map(p => {
|
const actions = selectedElms.value.map((p) => {
|
||||||
return { path: p, action: 'delete' }
|
return { path: p, action: 'delete' };
|
||||||
})
|
});
|
||||||
|
|
||||||
const success = await performMassAction(actions, (result) => {
|
const success = await performMassAction(actions, (result) => {
|
||||||
const item = items.value.find(it => it.path === result.path)
|
const item = items.value.find((it) => it.path === result.path);
|
||||||
if (item) {
|
if (item) {
|
||||||
items.value = items.value.filter(it => it.path !== result.path)
|
items.value = items.value.filter((it) => it.path !== result.path);
|
||||||
notify.warning(`Deleted '${item.name}'.`)
|
notify.warning(`Deleted '${item.name}'.`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success;
|
||||||
}
|
};
|
||||||
|
|
||||||
const moveSelected = async (newPath: string): Promise<boolean> => {
|
const moveSelected = async (newPath: string): Promise<boolean> => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (selectedElms.value.length < 1) {
|
||||||
notify.error('No items selected.')
|
notify.error('No items selected.');
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedPath = sTrim(newPath, '/') || '/'
|
const trimmedPath = sTrim(newPath, '/') || '/';
|
||||||
const actions = selectedElms.value.map(p => ({
|
const actions = selectedElms.value.map((p) => ({
|
||||||
path: p,
|
path: p,
|
||||||
action: 'move',
|
action: 'move',
|
||||||
new_path: trimmedPath,
|
new_path: trimmedPath,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const success = await performMassAction(actions, (result) => {
|
const success = await performMassAction(actions, (result) => {
|
||||||
items.value = items.value.filter(it => it.path !== result.path)
|
items.value = items.value.filter((it) => it.path !== result.path);
|
||||||
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`)
|
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`);
|
||||||
})
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return success
|
return success;
|
||||||
}
|
};
|
||||||
|
|
||||||
const clearError = (): void => {
|
const clearError = (): void => {
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const reset = (): void => {
|
const reset = (): void => {
|
||||||
items.value = []
|
items.value = [];
|
||||||
path.value = '/'
|
path.value = '/';
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 50,
|
per_page: 50,
|
||||||
|
|
@ -360,16 +364,16 @@ const reset = (): void => {
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
}
|
};
|
||||||
selectedElms.value = []
|
selectedElms.value = [];
|
||||||
masterSelectAll.value = false
|
masterSelectAll.value = false;
|
||||||
search.value = ''
|
search.value = '';
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const filteredItems = computed<FileItem[]>(() => {
|
const filteredItems = computed<FileItem[]>(() => {
|
||||||
return items.value
|
return items.value;
|
||||||
})
|
});
|
||||||
|
|
||||||
export const useBrowser = () => ({
|
export const useBrowser = () => ({
|
||||||
items: readonly(items),
|
items: readonly(items),
|
||||||
|
|
@ -403,4 +407,4 @@ export const useBrowser = () => ({
|
||||||
clearError,
|
clearError,
|
||||||
reset,
|
reset,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type {
|
import type {
|
||||||
Condition,
|
Condition,
|
||||||
ConditionTestRequest,
|
ConditionTestRequest,
|
||||||
ConditionTestResponse,
|
ConditionTestResponse,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from '~/types/conditions'
|
} from '~/types/conditions';
|
||||||
import type { APIResponse } from '~/types/responses'
|
import type { APIResponse } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all conditions in memory.
|
* List of all conditions in memory.
|
||||||
*/
|
*/
|
||||||
const conditions = ref<Array<Condition>>([])
|
const conditions = ref<Array<Condition>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for conditions list.
|
* Pagination state for conditions list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,32 +24,32 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
* Used separately from isLoading for finer control.
|
* Used separately from isLoading for finer control.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts conditions by priority (descending - higher number first), then name (A-Z).
|
* Sorts conditions by priority (descending - higher number first), then name (A-Z).
|
||||||
|
|
@ -59,12 +59,12 @@ const notify = useNotification()
|
||||||
const sortConditions = (items: Array<Condition>): Array<Condition> => {
|
const sortConditions = (items: Array<Condition>): Array<Condition> => {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
if (a.priority === b.priority) {
|
if (a.priority === b.priority) {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.priority - a.priority
|
return b.priority - a.priority;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely reads JSON from a Response, returns null on error.
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
|
@ -73,13 +73,12 @@ const sortConditions = (items: Array<Condition>): Array<Condition> => {
|
||||||
*/
|
*/
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -88,23 +87,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a condition in the conditions list, keeping sort order.
|
* Updates or adds a condition in the conditions list, keeping sort order.
|
||||||
|
|
@ -112,15 +111,15 @@ const handleError = (error: unknown): void => {
|
||||||
* @param condition Condition to update/add
|
* @param condition Condition to update/add
|
||||||
*/
|
*/
|
||||||
const updateConditions = (condition: Condition): void => {
|
const updateConditions = (condition: Condition): void => {
|
||||||
const isNew = !conditions.value.some(item => item.id === condition.id)
|
const isNew = !conditions.value.some((item) => item.id === condition.id);
|
||||||
conditions.value = sortConditions([
|
conditions.value = sortConditions([
|
||||||
...conditions.value.filter(item => item.id !== condition.id),
|
...conditions.value.filter((item) => item.id !== condition.id),
|
||||||
condition,
|
condition,
|
||||||
])
|
]);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a condition from the conditions list by ID.
|
* Removes a condition from the conditions list by ID.
|
||||||
|
|
@ -128,12 +127,12 @@ const updateConditions = (condition: Condition): void => {
|
||||||
* @param id Condition ID
|
* @param id Condition ID
|
||||||
*/
|
*/
|
||||||
const removeCondition = (id: number) => {
|
const removeCondition = (id: number) => {
|
||||||
const initialLength = conditions.value.length
|
const initialLength = conditions.value.length;
|
||||||
conditions.value = conditions.value.filter(item => item.id !== id)
|
conditions.value = conditions.value.filter((item) => item.id !== id);
|
||||||
if (conditions.value.length < initialLength) {
|
if (conditions.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all conditions from the API with pagination support.
|
* Loads all conditions from the API with pagination support.
|
||||||
|
|
@ -141,31 +140,32 @@ const removeCondition = (id: number) => {
|
||||||
* @param page Page number
|
* @param page Page number
|
||||||
* @param perPage Items per page
|
* @param perPage Items per page
|
||||||
*/
|
*/
|
||||||
const loadConditions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadConditions = async (
|
||||||
isLoading.value = true
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/conditions/?page=${page}`
|
let url = `/api/conditions/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<Condition>(json)
|
const { items, pagination: paginationData } = await parse_list_response<Condition>(json);
|
||||||
|
|
||||||
conditions.value = sortConditions(items)
|
conditions.value = sortConditions(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a single condition by ID from the API.
|
* Fetches a single condition by ID from the API.
|
||||||
|
|
@ -174,21 +174,20 @@ const loadConditions = async (page: number = 1, perPage: number | undefined = un
|
||||||
*/
|
*/
|
||||||
const getCondition = async (id: number): Promise<Condition | null> => {
|
const getCondition = async (id: number): Promise<Condition | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/conditions/${id}`)
|
const response = await request(`/api/conditions/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const condition = await parse_api_response<Condition>(json)
|
const condition = await parse_api_response<Condition>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return condition
|
return condition;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new condition via API.
|
* Creates a new condition via API.
|
||||||
|
|
@ -200,43 +199,41 @@ const createCondition = async (
|
||||||
condition: Omit<Condition, 'id'>,
|
condition: Omit<Condition, 'id'>,
|
||||||
callback?: (response: APIResponse<Condition>) => void,
|
callback?: (response: APIResponse<Condition>) => void,
|
||||||
): Promise<Condition | null> => {
|
): Promise<Condition | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/conditions/', {
|
const response = await request('/api/conditions/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(condition),
|
body: JSON.stringify(condition),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const created = await parse_api_response<Condition>(json)
|
const created = await parse_api_response<Condition>(json);
|
||||||
|
|
||||||
updateConditions(created)
|
updateConditions(created);
|
||||||
notify.success('Condition created.')
|
notify.success('Condition created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing condition via API (PUT - full update).
|
* Updates an existing condition via API (PUT - full update).
|
||||||
|
|
@ -250,46 +247,44 @@ const updateCondition = async (
|
||||||
condition: Condition,
|
condition: Condition,
|
||||||
callback?: (response: APIResponse<Condition>) => void,
|
callback?: (response: APIResponse<Condition>) => void,
|
||||||
): Promise<Condition | null> => {
|
): Promise<Condition | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (condition.id) {
|
if (condition.id) {
|
||||||
condition.id = undefined
|
condition.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/conditions/${id}`, {
|
const response = await request(`/api/conditions/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(condition),
|
body: JSON.stringify(condition),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Condition>(json)
|
const updated = await parse_api_response<Condition>(json);
|
||||||
|
|
||||||
updateConditions(updated)
|
updateConditions(updated);
|
||||||
notify.success(`Condition '${updated.name}' updated.`)
|
notify.success(`Condition '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partially updates an existing condition via API (PATCH).
|
* Partially updates an existing condition via API (PATCH).
|
||||||
|
|
@ -303,46 +298,44 @@ const patchCondition = async (
|
||||||
patch: Partial<Condition>,
|
patch: Partial<Condition>,
|
||||||
callback?: (response: APIResponse<Condition>) => void,
|
callback?: (response: APIResponse<Condition>) => void,
|
||||||
): Promise<Condition | null> => {
|
): Promise<Condition | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (patch.id) {
|
if (patch.id) {
|
||||||
patch.id = undefined
|
patch.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/conditions/${id}`, {
|
const response = await request(`/api/conditions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Condition>(json)
|
const updated = await parse_api_response<Condition>(json);
|
||||||
|
|
||||||
updateConditions(updated)
|
updateConditions(updated);
|
||||||
notify.success(`Condition '${updated.name}' updated.`)
|
notify.success(`Condition '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a condition by ID via API.
|
* Deletes a condition by ID via API.
|
||||||
|
|
@ -355,63 +348,63 @@ const deleteCondition = async (
|
||||||
callback?: (response: APIResponse<boolean>) => void,
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removeCondition(id)
|
removeCondition(id);
|
||||||
notify.success('Condition deleted.')
|
notify.success('Condition deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: true })
|
callback({ success: true, error: null, detail: null, data: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: false })
|
callback({ success: false, error: errorMessage, detail: error, data: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests a condition against a URL.
|
* Tests a condition against a URL.
|
||||||
* @param testRequest Test request parameters
|
* @param testRequest Test request parameters
|
||||||
* @returns Test result or null on error
|
* @returns Test result or null on error
|
||||||
*/
|
*/
|
||||||
const testCondition = async (testRequest: ConditionTestRequest): Promise<ConditionTestResponse | null> => {
|
const testCondition = async (
|
||||||
|
testRequest: ConditionTestRequest,
|
||||||
|
): Promise<ConditionTestResponse | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/conditions/test/', {
|
const response = await request('/api/conditions/test/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(testRequest),
|
body: JSON.stringify(testRequest),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const result = await parse_api_response<ConditionTestResponse>(json)
|
const result = await parse_api_response<ConditionTestResponse>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return result
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useConditions composable
|
* useConditions composable
|
||||||
|
|
@ -434,4 +427,4 @@ export const useConditions = () => ({
|
||||||
testCondition,
|
testCondition,
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,47 @@
|
||||||
import { useDialog, type ConfirmOptions, type AlertOptions, type PromptOptions } from './useDialog'
|
import { useDialog, type ConfirmOptions, type AlertOptions, type PromptOptions } from './useDialog';
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog();
|
||||||
|
|
||||||
const confirm = async (msg: string, opts: ConfirmOptions = {}) => {
|
const confirm = async (msg: string, opts: ConfirmOptions = {}) => {
|
||||||
const { status } = await dialog.confirmDialog(Object.assign({
|
const { status } = await dialog.confirmDialog(
|
||||||
title: 'Please Confirm',
|
Object.assign(
|
||||||
message: msg,
|
{
|
||||||
cancelText: 'Cancel',
|
title: 'Please Confirm',
|
||||||
confirmText: 'OK',
|
message: msg,
|
||||||
} as ConfirmOptions, opts || {}))
|
cancelText: 'Cancel',
|
||||||
|
confirmText: 'OK',
|
||||||
|
} as ConfirmOptions,
|
||||||
|
opts || {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return status
|
return status;
|
||||||
}
|
};
|
||||||
|
|
||||||
const alert = async (msg: string, opts: AlertOptions = {}) => {
|
const alert = async (msg: string, opts: AlertOptions = {}) => {
|
||||||
const { status } = await dialog.alertDialog(Object.assign({
|
const { status } = await dialog.alertDialog(
|
||||||
title: 'Alert',
|
Object.assign(
|
||||||
message: msg,
|
{
|
||||||
confirmText: 'OK',
|
title: 'Alert',
|
||||||
} as AlertOptions, opts || {}))
|
message: msg,
|
||||||
return status
|
confirmText: 'OK',
|
||||||
}
|
} as AlertOptions,
|
||||||
|
opts || {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
const prompt = async (msg: string, opts: PromptOptions = {}) => {
|
const prompt = async (msg: string, opts: PromptOptions = {}) => {
|
||||||
const { status, value } = await dialog.promptDialog(Object.assign({ message: msg } as PromptOptions, opts || {}))
|
const { status, value } = await dialog.promptDialog(
|
||||||
|
Object.assign({ message: msg } as PromptOptions, opts || {}),
|
||||||
|
);
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useConfirm = () => ({ confirm, alert, prompt })
|
export const useConfirm = () => ({ confirm, alert, prompt });
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,136 @@
|
||||||
import { reactive, readonly } from 'vue'
|
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 };
|
||||||
|
|
||||||
type BaseOptions = {
|
type BaseOptions = {
|
||||||
/**
|
/**
|
||||||
* Title of the dialog
|
* Title of the dialog
|
||||||
*/
|
*/
|
||||||
title?: string
|
title?: string;
|
||||||
/**
|
/**
|
||||||
* Message to display in the dialog
|
* Message to display in the dialog
|
||||||
*/
|
*/
|
||||||
message?: string
|
message?: string;
|
||||||
/**
|
/**
|
||||||
* Text for the confirm button
|
* Text for the confirm button
|
||||||
*/
|
*/
|
||||||
confirmText?: string
|
confirmText?: string;
|
||||||
/**
|
/**
|
||||||
* Color class for the confirm button (e.g., 'is-primary', 'is-danger')
|
* Color class for the confirm button (e.g., 'is-primary', 'is-danger')
|
||||||
*/
|
*/
|
||||||
confirmColor?: string
|
confirmColor?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type PromptOptions = BaseOptions & {
|
export type PromptOptions = BaseOptions & {
|
||||||
/**
|
/**
|
||||||
* Text for the input field
|
* Text for the input field
|
||||||
*/
|
*/
|
||||||
initial?: string
|
initial?: string;
|
||||||
/**
|
/**
|
||||||
* Placeholder text for the input field
|
* Placeholder text for the input field
|
||||||
*/
|
*/
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
/**
|
/**
|
||||||
* Text for the cancel button
|
* Text for the cancel button
|
||||||
*/
|
*/
|
||||||
cancelText?: string
|
cancelText?: string;
|
||||||
/**
|
/**
|
||||||
* Function to validate the input value
|
* Function to validate the input value
|
||||||
* @returns true if valid, or an error message string if invalid
|
* @returns true if valid, or an error message string if invalid
|
||||||
*/
|
*/
|
||||||
validate?: (v: string) => true | string
|
validate?: (v: string) => true | string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ConfirmOptions = BaseOptions & {
|
export type ConfirmOptions = BaseOptions & {
|
||||||
/**
|
/**
|
||||||
* Text for the confirm button
|
* Text for the confirm button
|
||||||
*/
|
*/
|
||||||
cancelText?: string
|
cancelText?: string;
|
||||||
/**
|
/**
|
||||||
* Raw HTML content to include in the dialog message.
|
* Raw HTML content to include in the dialog message.
|
||||||
*/
|
*/
|
||||||
rawHTML?: string
|
rawHTML?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type AlertOptions = BaseOptions & {}
|
export type AlertOptions = BaseOptions & {};
|
||||||
|
|
||||||
type QueueItem = {
|
type QueueItem = {
|
||||||
type: 'prompt' | 'confirm' | 'alert'
|
type: 'prompt' | 'confirm' | 'alert';
|
||||||
opts: PromptOptions | ConfirmOptions | AlertOptions
|
opts: PromptOptions | ConfirmOptions | AlertOptions;
|
||||||
resolve: (r: DialogResult<any>) => void
|
resolve: (r: DialogResult<any>) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
type DialogState = {
|
type DialogState = {
|
||||||
current: QueueItem | null
|
current: QueueItem | null;
|
||||||
queue: QueueItem[]
|
queue: QueueItem[];
|
||||||
errorMsg: string | null
|
errorMsg: string | null;
|
||||||
input: string
|
input: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useDialog = () => {
|
export const useDialog = () => {
|
||||||
const raw = useState<DialogState>('dialog:state', () => reactive({
|
const raw = useState<DialogState>('dialog:state', () =>
|
||||||
current: null,
|
reactive({
|
||||||
queue: [],
|
current: null,
|
||||||
errorMsg: null,
|
queue: [],
|
||||||
input: '',
|
errorMsg: null,
|
||||||
} as DialogState))
|
input: '',
|
||||||
|
} as DialogState),
|
||||||
|
);
|
||||||
|
|
||||||
const state = raw.value
|
const state = raw.value;
|
||||||
|
|
||||||
const _dequeue = () => {
|
const _dequeue = () => {
|
||||||
if (!state.current && state.queue.length) {
|
if (!state.current && state.queue.length) {
|
||||||
state.current = state.queue.shift()!
|
state.current = state.queue.shift()!;
|
||||||
state.errorMsg = null
|
state.errorMsg = null;
|
||||||
state.input = state.current.type === 'prompt' ? (state.current.opts as PromptOptions).initial ?? '' : ''
|
state.input =
|
||||||
|
state.current.type === 'prompt'
|
||||||
|
? ((state.current.opts as PromptOptions).initial ?? '')
|
||||||
|
: '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const promptDialog = (opts: PromptOptions) => new Promise<DialogResult<string>>((resolve) => {
|
const promptDialog = (opts: PromptOptions) =>
|
||||||
state.queue.push({ type: 'prompt', opts, resolve })
|
new Promise<DialogResult<string>>((resolve) => {
|
||||||
_dequeue()
|
state.queue.push({ type: 'prompt', opts, resolve });
|
||||||
})
|
_dequeue();
|
||||||
|
});
|
||||||
|
|
||||||
const confirmDialog = (opts: ConfirmOptions) => new Promise<DialogResult<null>>((resolve) => {
|
const confirmDialog = (opts: ConfirmOptions) =>
|
||||||
state.queue.push({ type: 'confirm', opts, resolve })
|
new Promise<DialogResult<null>>((resolve) => {
|
||||||
_dequeue()
|
state.queue.push({ type: 'confirm', opts, resolve });
|
||||||
})
|
_dequeue();
|
||||||
|
});
|
||||||
|
|
||||||
const alertDialog = (opts: AlertOptions) => new Promise<DialogResult<null>>((resolve) => {
|
const alertDialog = (opts: AlertOptions) =>
|
||||||
state.queue.push({ type: 'alert', opts, resolve })
|
new Promise<DialogResult<null>>((resolve) => {
|
||||||
_dequeue()
|
state.queue.push({ type: 'alert', opts, resolve });
|
||||||
})
|
_dequeue();
|
||||||
|
});
|
||||||
|
|
||||||
const confirm = (value?: string) => {
|
const confirm = (value?: string) => {
|
||||||
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 = 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 {
|
} else {
|
||||||
state.current.resolve({ status: true, value: null })
|
state.current.resolve({ status: true, value: null });
|
||||||
}
|
}
|
||||||
state.current = null
|
state.current = null;
|
||||||
_dequeue()
|
_dequeue();
|
||||||
}
|
};
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (!state.current) return
|
if (!state.current) return;
|
||||||
state.current.resolve({ status: false, value: null })
|
state.current.resolve({ status: false, value: null });
|
||||||
state.current = null
|
state.current = null;
|
||||||
_dequeue()
|
_dequeue();
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
promptDialog,
|
promptDialog,
|
||||||
|
|
@ -131,5 +139,5 @@ export const useDialog = () => {
|
||||||
confirm,
|
confirm,
|
||||||
cancel,
|
cancel,
|
||||||
state: readonly(state) as Readonly<DialogState>,
|
state: readonly(state) as Readonly<DialogState>,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type { DLField, DLFieldRequest } from '~/types/dl_fields'
|
import type { DLField, DLFieldRequest } from '~/types/dl_fields';
|
||||||
import type { APIResponse, Pagination } from '~/types/responses'
|
import type { APIResponse, Pagination } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all dl fields in memory.
|
* List of all dl fields in memory.
|
||||||
*/
|
*/
|
||||||
const dlFields = ref<Array<DLField>>([])
|
const dlFields = ref<Array<DLField>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for dl fields list.
|
* Pagination state for dl fields list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts dl fields by order (ascending), then name (A-Z).
|
* Sorts dl fields by order (ascending), then name (A-Z).
|
||||||
|
|
@ -49,12 +49,12 @@ const notify = useNotification()
|
||||||
const sortDlFields = (items: Array<DLField>): Array<DLField> => {
|
const sortDlFields = (items: Array<DLField>): Array<DLField> => {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
if (a.order === b.order) {
|
if (a.order === b.order) {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.order - b.order
|
return a.order - b.order;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely reads JSON from a Response, returns null on error.
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
|
@ -63,13 +63,12 @@ const sortDlFields = (items: Array<DLField>): Array<DLField> => {
|
||||||
*/
|
*/
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a dl field in the dlFields list, keeping sort order.
|
* Updates or adds a dl field in the dlFields list, keeping sort order.
|
||||||
|
|
@ -102,15 +101,12 @@ const handleError = (error: unknown): void => {
|
||||||
* @param field DLField to update/add
|
* @param field DLField to update/add
|
||||||
*/
|
*/
|
||||||
const updateDlFields = (field: DLField): void => {
|
const updateDlFields = (field: DLField): void => {
|
||||||
const isNew = !dlFields.value.some(item => item.id === field.id)
|
const isNew = !dlFields.value.some((item) => item.id === field.id);
|
||||||
dlFields.value = sortDlFields([
|
dlFields.value = sortDlFields([...dlFields.value.filter((item) => item.id !== field.id), field]);
|
||||||
...dlFields.value.filter(item => item.id !== field.id),
|
|
||||||
field,
|
|
||||||
])
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a dl field from the dlFields list by ID.
|
* Removes a dl field from the dlFields list by ID.
|
||||||
|
|
@ -118,12 +114,12 @@ const updateDlFields = (field: DLField): void => {
|
||||||
* @param id DLField ID
|
* @param id DLField ID
|
||||||
*/
|
*/
|
||||||
const removeDlField = (id: number) => {
|
const removeDlField = (id: number) => {
|
||||||
const initialLength = dlFields.value.length
|
const initialLength = dlFields.value.length;
|
||||||
dlFields.value = dlFields.value.filter(item => item.id !== id)
|
dlFields.value = dlFields.value.filter((item) => item.id !== id);
|
||||||
if (dlFields.value.length < initialLength) {
|
if (dlFields.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all dl fields from the API with pagination support.
|
* Loads all dl fields from the API with pagination support.
|
||||||
|
|
@ -131,31 +127,32 @@ const removeDlField = (id: number) => {
|
||||||
* @param page Page number
|
* @param page Page number
|
||||||
* @param perPage Items per page
|
* @param perPage Items per page
|
||||||
*/
|
*/
|
||||||
const loadDlFields = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadDlFields = async (
|
||||||
isLoading.value = true
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/dl_fields/?page=${page}`
|
let url = `/api/dl_fields/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<DLField>(json)
|
const { items, pagination: paginationData } = await parse_list_response<DLField>(json);
|
||||||
|
|
||||||
dlFields.value = sortDlFields(items)
|
dlFields.value = sortDlFields(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a single dl field by ID from the API.
|
* Fetches a single dl field by ID from the API.
|
||||||
|
|
@ -164,21 +161,20 @@ const loadDlFields = async (page: number = 1, perPage: number | undefined = unde
|
||||||
*/
|
*/
|
||||||
const getDlField = async (id: number): Promise<DLField | null> => {
|
const getDlField = async (id: number): Promise<DLField | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/dl_fields/${id}`)
|
const response = await request(`/api/dl_fields/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const field = await parse_api_response<DLField>(json)
|
const field = await parse_api_response<DLField>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return field
|
return field;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new dl field via API.
|
* Creates a new dl field via API.
|
||||||
|
|
@ -190,43 +186,41 @@ const createDlField = async (
|
||||||
field: DLFieldRequest,
|
field: DLFieldRequest,
|
||||||
callback?: (response: APIResponse<DLField>) => void,
|
callback?: (response: APIResponse<DLField>) => void,
|
||||||
): Promise<DLField | null> => {
|
): Promise<DLField | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/dl_fields/', {
|
const response = await request('/api/dl_fields/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(field),
|
body: JSON.stringify(field),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const created = await parse_api_response<DLField>(json)
|
const created = await parse_api_response<DLField>(json);
|
||||||
|
|
||||||
updateDlFields(created)
|
updateDlFields(created);
|
||||||
notify.success('DL field created.')
|
notify.success('DL field created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing dl field via API (PUT - full update).
|
* Updates an existing dl field via API (PUT - full update).
|
||||||
|
|
@ -240,46 +234,44 @@ const updateDlField = async (
|
||||||
field: DLField,
|
field: DLField,
|
||||||
callback?: (response: APIResponse<DLField>) => void,
|
callback?: (response: APIResponse<DLField>) => void,
|
||||||
): Promise<DLField | null> => {
|
): Promise<DLField | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (field.id) {
|
if (field.id) {
|
||||||
field.id = undefined
|
field.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/dl_fields/${id}`, {
|
const response = await request(`/api/dl_fields/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(field),
|
body: JSON.stringify(field),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<DLField>(json)
|
const updated = await parse_api_response<DLField>(json);
|
||||||
|
|
||||||
updateDlFields(updated)
|
updateDlFields(updated);
|
||||||
notify.success(`DL field '${updated.name}' updated.`)
|
notify.success(`DL field '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partially updates an existing dl field via API (PATCH).
|
* Partially updates an existing dl field via API (PATCH).
|
||||||
|
|
@ -293,46 +285,44 @@ const patchDlField = async (
|
||||||
patch: Partial<DLField>,
|
patch: Partial<DLField>,
|
||||||
callback?: (response: APIResponse<DLField>) => void,
|
callback?: (response: APIResponse<DLField>) => void,
|
||||||
): Promise<DLField | null> => {
|
): Promise<DLField | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (patch.id) {
|
if (patch.id) {
|
||||||
patch.id = undefined
|
patch.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/dl_fields/${id}`, {
|
const response = await request(`/api/dl_fields/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<DLField>(json)
|
const updated = await parse_api_response<DLField>(json);
|
||||||
|
|
||||||
updateDlFields(updated)
|
updateDlFields(updated);
|
||||||
notify.success(`DL field '${updated.name}' updated.`)
|
notify.success(`DL field '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a dl field by ID via API.
|
* Deletes a dl field by ID via API.
|
||||||
|
|
@ -345,36 +335,35 @@ const deleteDlField = async (
|
||||||
callback?: (response: APIResponse<boolean>) => void,
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removeDlField(id)
|
removeDlField(id);
|
||||||
notify.success('DL field deleted.')
|
notify.success('DL field deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: true })
|
callback({ success: true, error: null, detail: null, data: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: false })
|
callback({ success: false, error: errorMessage, detail: error, data: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useDlFields composable
|
* useDlFields composable
|
||||||
|
|
@ -396,4 +385,4 @@ export const useDlFields = () => ({
|
||||||
deleteDlField,
|
deleteDlField,
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* Vue composable for handling YouTube-style keyboard shortcuts in video player
|
* Vue composable for handling YouTube-style keyboard shortcuts in video player
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue';
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
handlePlayPause,
|
handlePlayPause,
|
||||||
handleRewind,
|
handleRewind,
|
||||||
|
|
@ -20,136 +20,135 @@ import {
|
||||||
handleToggleCaptions,
|
handleToggleCaptions,
|
||||||
shouldHandleKeyboardShortcut,
|
shouldHandleKeyboardShortcut,
|
||||||
isModifierKey,
|
isModifierKey,
|
||||||
} from '~/utils/keyboard'
|
} from '~/utils/keyboard';
|
||||||
import type { KeyboardShortcutContext } from '~/types/video'
|
import type { KeyboardShortcutContext } from '~/types/video';
|
||||||
|
|
||||||
|
|
||||||
export interface UseKeyboardShortcutsOptions {
|
export interface UseKeyboardShortcutsOptions {
|
||||||
videoElement: Ref<HTMLVideoElement | null | undefined>
|
videoElement: Ref<HTMLVideoElement | null | undefined>;
|
||||||
enabled?: Ref<boolean>
|
enabled?: Ref<boolean>;
|
||||||
closePlayer?: () => void
|
closePlayer?: () => void;
|
||||||
onHelpToggle?: () => void
|
onHelpToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
||||||
const { videoElement, enabled, closePlayer, onHelpToggle } = options
|
const { videoElement, enabled, closePlayer, onHelpToggle } = options;
|
||||||
const showHelp = ref(false)
|
const showHelp = ref(false);
|
||||||
|
|
||||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||||
// Don't handle if composable is disabled
|
// Don't handle if composable is disabled
|
||||||
if (enabled && !enabled.value) {
|
if (enabled && !enabled.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't handle if user is typing in an input
|
// Don't handle if user is typing in an input
|
||||||
if (!shouldHandleKeyboardShortcut(event)) {
|
if (!shouldHandleKeyboardShortcut(event)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const video = videoElement.value
|
const video = videoElement.value;
|
||||||
if (!video) {
|
if (!video) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if modifier keys are pressed (except for shortcuts that need them)
|
// Skip if modifier keys are pressed (except for shortcuts that need them)
|
||||||
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
|
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase();
|
||||||
const ctx: KeyboardShortcutContext = { video }
|
const ctx: KeyboardShortcutContext = { video };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
// Play/Pause
|
// Play/Pause
|
||||||
case ' ':
|
case ' ':
|
||||||
case 'k':
|
case 'k':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handlePlayPause(ctx)
|
handlePlayPause(ctx);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Rewind 10 seconds (J key)
|
// Rewind 10 seconds (J key)
|
||||||
case 'j':
|
case 'j':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleRewind(ctx, 10)
|
handleRewind(ctx, 10);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Forward 10 seconds (L key)
|
// Forward 10 seconds (L key)
|
||||||
case 'l':
|
case 'l':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleForward(ctx, 10)
|
handleForward(ctx, 10);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Seek backward 5 seconds (left arrow)
|
// Seek backward 5 seconds (left arrow)
|
||||||
case 'arrowleft':
|
case 'arrowleft':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleSeekBackward(ctx, 5)
|
handleSeekBackward(ctx, 5);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Seek forward 5 seconds (right arrow)
|
// Seek forward 5 seconds (right arrow)
|
||||||
case 'arrowright':
|
case 'arrowright':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleSeekForward(ctx, 5)
|
handleSeekForward(ctx, 5);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Increase volume (up arrow)
|
// Increase volume (up arrow)
|
||||||
case 'arrowup':
|
case 'arrowup':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleVolumeChange(ctx, 0.1)
|
handleVolumeChange(ctx, 0.1);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Decrease volume (down arrow)
|
// Decrease volume (down arrow)
|
||||||
case 'arrowdown':
|
case 'arrowdown':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleVolumeChange(ctx, -0.1)
|
handleVolumeChange(ctx, -0.1);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Mute/Unmute
|
// Mute/Unmute
|
||||||
case 'm':
|
case 'm':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleMute(ctx)
|
handleMute(ctx);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Toggle fullscreen
|
// Toggle fullscreen
|
||||||
case 'f':
|
case 'f':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleFullscreen(video)
|
handleFullscreen(video);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Picture-in-Picture
|
// Picture-in-Picture
|
||||||
case 'p':
|
case 'p':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
await handlePictureInPicture(video)
|
await handlePictureInPicture(video);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Toggle captions
|
// Toggle captions
|
||||||
case 'c':
|
case 'c':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
handleToggleCaptions(video)
|
handleToggleCaptions(video);
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Frame advance (period key) / Increase playback speed (> or ')
|
// Frame advance (period key) / Increase playback speed (> or ')
|
||||||
case '.':
|
case '.':
|
||||||
case "'": {
|
case "'": {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
if ('.' === key) {
|
if ('.' === key) {
|
||||||
handleFrameStep(ctx, 'forward')
|
handleFrameStep(ctx, 'forward');
|
||||||
} else {
|
} else {
|
||||||
handlePlaybackSpeedChange(ctx, 0.25)
|
handlePlaybackSpeedChange(ctx, 0.25);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame rewind (comma key) / Decrease playback speed (< or ;)
|
// Frame rewind (comma key) / Decrease playback speed (< or ;)
|
||||||
case ',':
|
case ',':
|
||||||
case ';': {
|
case ';': {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
if (',' === key) {
|
if (',' === key) {
|
||||||
handleFrameStep(ctx, 'backward')
|
handleFrameStep(ctx, 'backward');
|
||||||
} else {
|
} else {
|
||||||
handlePlaybackSpeedChange(ctx, -0.25)
|
handlePlaybackSpeedChange(ctx, -0.25);
|
||||||
}
|
}
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump to percentage (0-9 keys)
|
// Jump to percentage (0-9 keys)
|
||||||
|
|
@ -163,55 +162,55 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
||||||
case '7':
|
case '7':
|
||||||
case '8':
|
case '8':
|
||||||
case '9': {
|
case '9': {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
const percent = parseInt(key) * 10
|
const percent = parseInt(key) * 10;
|
||||||
handleSeekToPercent(ctx, percent)
|
handleSeekToPercent(ctx, percent);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jump to start (Home)
|
// Jump to start (Home)
|
||||||
case 'home':
|
case 'home':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
video.currentTime = 0
|
video.currentTime = 0;
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Jump to end (End)
|
// Jump to end (End)
|
||||||
case 'end':
|
case 'end':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
video.currentTime = video.duration
|
video.currentTime = video.duration;
|
||||||
break
|
break;
|
||||||
|
|
||||||
// Show/hide help (Shift+/)
|
// Show/hide help (Shift+/)
|
||||||
case '?':
|
case '?':
|
||||||
case '/': {
|
case '/': {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
showHelp.value = !showHelp.value
|
showHelp.value = !showHelp.value;
|
||||||
onHelpToggle?.()
|
onHelpToggle?.();
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close player (Escape - already handled elsewhere but kept for completeness)
|
// Close player (Escape - already handled elsewhere but kept for completeness)
|
||||||
case 'escape':
|
case 'escape':
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
closePlayer?.()
|
closePlayer?.();
|
||||||
break
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// No action for unrecognized keys
|
// No action for unrecognized keys
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling keyboard shortcut:', error)
|
console.error('Error handling keyboard shortcut:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showHelp,
|
showHelp,
|
||||||
attach,
|
attach,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref, onMounted, onBeforeUnmount, shallowReadonly, type Ref } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, shallowReadonly, type Ref } from 'vue';
|
||||||
|
|
||||||
export interface MediaQueryOptions {
|
export interface MediaQueryOptions {
|
||||||
/**
|
/**
|
||||||
|
|
@ -6,56 +6,58 @@ export interface MediaQueryOptions {
|
||||||
* Example: "(max-width: 640px) or (hover: none)"
|
* Example: "(max-width: 640px) or (hover: none)"
|
||||||
* If provided, takes precedence over maxWidth.
|
* If provided, takes precedence over maxWidth.
|
||||||
*/
|
*/
|
||||||
query?: string
|
query?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Max width in px considered true.
|
* Max width in px considered true.
|
||||||
* Ignored if `query` is provided. Default: 768.
|
* Ignored if `query` is provided. Default: 768.
|
||||||
*/
|
*/
|
||||||
maxWidth?: number
|
maxWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive state of a CSS media query.
|
* Reactive state of a CSS media query.
|
||||||
*/
|
*/
|
||||||
export function useMediaQuery(options: MediaQueryOptions = {}): Readonly<Ref<boolean>> {
|
export function useMediaQuery(options: MediaQueryOptions = {}): Readonly<Ref<boolean>> {
|
||||||
const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)`
|
const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)`;
|
||||||
const matches = ref(false)
|
const matches = ref(false);
|
||||||
|
|
||||||
let mql: MediaQueryList | null = null
|
let mql: MediaQueryList | null = null;
|
||||||
let onChange: ((ev: MediaQueryListEvent) => void) | null = null
|
let onChange: ((ev: MediaQueryListEvent) => void) | null = null;
|
||||||
|
|
||||||
const setup = () => {
|
const setup = () => {
|
||||||
if ('undefined' === typeof window || !window.matchMedia) {
|
if ('undefined' === typeof window || !window.matchMedia) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
mql = window.matchMedia(query)
|
mql = window.matchMedia(query);
|
||||||
matches.value = mql.matches
|
matches.value = mql.matches;
|
||||||
|
|
||||||
onChange = ev => { matches.value = ev.matches }
|
onChange = (ev) => {
|
||||||
|
matches.value = ev.matches;
|
||||||
|
};
|
||||||
if ('addEventListener' in mql) {
|
if ('addEventListener' in mql) {
|
||||||
mql.addEventListener('change', onChange as EventListener)
|
mql.addEventListener('change', onChange as EventListener);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error legacy Safari
|
// @ts-expect-error legacy Safari
|
||||||
mql.addListener(onChange)
|
mql.addListener(onChange);
|
||||||
}
|
};
|
||||||
|
|
||||||
const teardown = () => {
|
const teardown = () => {
|
||||||
if (!mql || !onChange) return
|
if (!mql || !onChange) return;
|
||||||
if ('removeEventListener' in mql) {
|
if ('removeEventListener' in mql) {
|
||||||
mql.removeEventListener('change', onChange as EventListener)
|
mql.removeEventListener('change', onChange as EventListener);
|
||||||
} else {
|
} else {
|
||||||
// @ts-expect-error legacy Safari
|
// @ts-expect-error legacy Safari
|
||||||
mql.removeListener(onChange)
|
mql.removeListener(onChange);
|
||||||
}
|
}
|
||||||
mql = null
|
mql = null;
|
||||||
onChange = null
|
onChange = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(setup)
|
onMounted(setup);
|
||||||
onBeforeUnmount(teardown)
|
onBeforeUnmount(teardown);
|
||||||
|
|
||||||
return shallowReadonly(matches)
|
return shallowReadonly(matches);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,83 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import { POSITION, useToast } from "vue-toastification"
|
import { POSITION, useToast } from 'vue-toastification';
|
||||||
|
|
||||||
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 interface notification {
|
export interface notification {
|
||||||
id: string;
|
id: string;
|
||||||
message: string
|
message: string;
|
||||||
level: notificationType
|
level: notificationType;
|
||||||
seen: boolean
|
seen: boolean;
|
||||||
created: Date
|
created: Date;
|
||||||
};
|
|
||||||
|
|
||||||
export interface notificationOptions {
|
|
||||||
timeout?: number,
|
|
||||||
force?: boolean,
|
|
||||||
closeOnClick?: boolean,
|
|
||||||
position?: POSITION
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
||||||
onClick?: (closeToast: Function) => void
|
|
||||||
store?: boolean,
|
|
||||||
lowPriority?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowToast = useStorage<boolean>('allow_toasts', true)
|
export interface notificationOptions {
|
||||||
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
timeout?: number;
|
||||||
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
|
force?: boolean;
|
||||||
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast')
|
closeOnClick?: boolean;
|
||||||
const toast = useToast()
|
position?: POSITION;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
|
onClick?: (closeToast: Function) => void;
|
||||||
|
store?: boolean;
|
||||||
|
lowPriority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowToast = useStorage<boolean>('allow_toasts', true);
|
||||||
|
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
|
||||||
|
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true);
|
||||||
|
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast');
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const requestBrowserPermission = async (): Promise<NotificationPermission> => {
|
const requestBrowserPermission = async (): Promise<NotificationPermission> => {
|
||||||
if (!('Notification' in window)) {
|
if (!('Notification' in window)) {
|
||||||
return 'denied'
|
return 'denied';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Notification.permission === 'granted') {
|
if (Notification.permission === 'granted') {
|
||||||
return 'granted'
|
return 'granted';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Notification.permission !== 'denied') {
|
if (Notification.permission !== 'denied') {
|
||||||
return await Notification.requestPermission()
|
return await Notification.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Notification.permission
|
return Notification.permission;
|
||||||
}
|
};
|
||||||
|
|
||||||
const sendMessage = (type: notificationType, id: string, message: string, opts?: notificationOptions): void => {
|
const sendMessage = (
|
||||||
const notificationStore = useNotificationStore()
|
type: notificationType,
|
||||||
|
id: string,
|
||||||
|
message: string,
|
||||||
|
opts?: notificationOptions,
|
||||||
|
): void => {
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value ||
|
const useToastNotification =
|
||||||
!('Notification' in window) || 'granted' !== Notification.permission;
|
!window.isSecureContext ||
|
||||||
|
'toast' === toastTarget.value ||
|
||||||
|
!('Notification' in window) ||
|
||||||
|
'granted' !== Notification.permission;
|
||||||
|
|
||||||
if (useToastNotification) {
|
if (useToastNotification) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'info':
|
case 'info':
|
||||||
toast.info(message, opts)
|
toast.info(message, opts);
|
||||||
break;
|
break;
|
||||||
case 'success':
|
case 'success':
|
||||||
toast.success(message, opts)
|
toast.success(message, opts);
|
||||||
break;
|
break;
|
||||||
case 'warning':
|
case 'warning':
|
||||||
toast.warning(message, opts)
|
toast.warning(message, opts);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
toast.error(message, opts)
|
toast.error(message, opts);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
toast.error(`Unknown notification type: ${type}. ${message}`, opts)
|
toast.error(`Unknown notification type: ${type}. ${message}`, opts);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notification = new Notification('YTPTube', {
|
const notification = new Notification('YTPTube', {
|
||||||
|
|
@ -77,32 +85,32 @@ const sendMessage = (type: notificationType, id: string, message: string, opts?:
|
||||||
icon: '/favicon.ico',
|
icon: '/favicon.ico',
|
||||||
badge: '/favicon.ico',
|
badge: '/favicon.ico',
|
||||||
tag: `ytptube-${type}`,
|
tag: `ytptube-${type}`,
|
||||||
silent: false
|
silent: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
setTimeout(() => notification.close(), 5000)
|
setTimeout(() => notification.close(), 5000);
|
||||||
|
|
||||||
notification.onclick = () => {
|
notification.onclick = () => {
|
||||||
notificationStore.markRead(id)
|
notificationStore.markRead(id);
|
||||||
window.focus()
|
window.focus();
|
||||||
notification.close()
|
notification.close();
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
if (!opts) {
|
if (!opts) {
|
||||||
opts = {}
|
opts = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: string = ''
|
let id: string = '';
|
||||||
const force = opts?.force || false;
|
const force = opts?.force || false;
|
||||||
const store = opts?.store || true;
|
const store = opts?.store || true;
|
||||||
const lowPriority = opts?.lowPriority || false;
|
const lowPriority = opts?.lowPriority || false;
|
||||||
|
|
||||||
if (notificationStore && (store || true === lowPriority)) {
|
if (notificationStore && (store || true === lowPriority)) {
|
||||||
id = notificationStore.add(type, message, false)
|
id = notificationStore.add(type, message, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === lowPriority || (false === allowToast.value && false === force)) {
|
if (true === lowPriority || (false === allowToast.value && false === force)) {
|
||||||
|
|
@ -113,20 +121,20 @@ const notify = (type: notificationType, message: string, opts?: notificationOpti
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.closeOnClick = toastDismissOnClick.value
|
opts.closeOnClick = toastDismissOnClick.value;
|
||||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
opts.onClick = (closeToast: Function) => {
|
opts.onClick = (closeToast: Function) => {
|
||||||
if (opts?.closeOnClick !== false) {
|
if (opts?.closeOnClick !== false) {
|
||||||
closeToast()
|
closeToast();
|
||||||
}
|
}
|
||||||
if (notificationStore) {
|
if (notificationStore) {
|
||||||
notificationStore.markRead(id)
|
notificationStore.markRead(id);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
sendMessage(type, id, message, opts)
|
sendMessage(type, id, message, opts);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useNotification = () => {
|
export const useNotification = () => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -136,5 +144,5 @@ export const useNotification = () => {
|
||||||
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
||||||
notify,
|
notify,
|
||||||
requestBrowserPermission,
|
requestBrowserPermission,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type { notification } from '~/types/notification'
|
import type { notification } from '~/types/notification';
|
||||||
import type { APIResponse, Pagination } from '~/types/responses'
|
import type { APIResponse, Pagination } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all notifications in memory.
|
* List of all notifications in memory.
|
||||||
*/
|
*/
|
||||||
const notifications = ref<Array<notification>>([])
|
const notifications = ref<Array<notification>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for notifications list.
|
* Pagination state for notifications list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -19,31 +19,31 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* List of allowed notification events.
|
* List of allowed notification events.
|
||||||
*/
|
*/
|
||||||
const events = ref<Array<string>>([])
|
const events = ref<Array<string>>([]);
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts notifications by name (A-Z).
|
* Sorts notifications by name (A-Z).
|
||||||
|
|
@ -51,8 +51,8 @@ const notify = useNotification()
|
||||||
* @returns Sorted array of notifications
|
* @returns Sorted array of notifications
|
||||||
*/
|
*/
|
||||||
const sortNotifications = (items: Array<notification>): Array<notification> => {
|
const sortNotifications = (items: Array<notification>): Array<notification> => {
|
||||||
return [...items].sort((a, b) => a.name.localeCompare(b.name))
|
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely reads JSON from a Response, returns null on error.
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
|
@ -61,13 +61,12 @@ const sortNotifications = (items: Array<notification>): Array<notification> => {
|
||||||
*/
|
*/
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -76,23 +75,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a notification in the list, keeping sort order.
|
* Updates or adds a notification in the list, keeping sort order.
|
||||||
|
|
@ -100,15 +99,15 @@ const handleError = (error: unknown): void => {
|
||||||
* @param item Notification to update/add
|
* @param item Notification to update/add
|
||||||
*/
|
*/
|
||||||
const updateNotifications = (item: notification): void => {
|
const updateNotifications = (item: notification): void => {
|
||||||
const isNew = !notifications.value.some(existing => existing.id === item.id)
|
const isNew = !notifications.value.some((existing) => existing.id === item.id);
|
||||||
notifications.value = sortNotifications([
|
notifications.value = sortNotifications([
|
||||||
...notifications.value.filter(existing => existing.id !== item.id),
|
...notifications.value.filter((existing) => existing.id !== item.id),
|
||||||
item,
|
item,
|
||||||
])
|
]);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a notification from the list by ID.
|
* Removes a notification from the list by ID.
|
||||||
|
|
@ -116,67 +115,67 @@ const updateNotifications = (item: notification): void => {
|
||||||
* @param id Notification ID
|
* @param id Notification ID
|
||||||
*/
|
*/
|
||||||
const removeNotification = (id: number) => {
|
const removeNotification = (id: number) => {
|
||||||
const initialLength = notifications.value.length
|
const initialLength = notifications.value.length;
|
||||||
notifications.value = notifications.value.filter(item => item.id !== id)
|
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||||
if (notifications.value.length < initialLength) {
|
if (notifications.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads notification targets from the API with pagination support.
|
* Loads notification targets from the API with pagination support.
|
||||||
* @param page Page number
|
* @param page Page number
|
||||||
* @param perPage Items per page
|
* @param perPage Items per page
|
||||||
*/
|
*/
|
||||||
const loadNotifications = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadNotifications = async (
|
||||||
await loadNotificationEvents()
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
await loadNotificationEvents();
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/notifications/?page=${page}`
|
let url = `/api/notifications/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<notification>(json)
|
const { items, pagination: paginationData } = await parse_list_response<notification>(json);
|
||||||
|
|
||||||
notifications.value = sortNotifications(items)
|
notifications.value = sortNotifications(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads notification events from the API.
|
* Loads notification events from the API.
|
||||||
*/
|
*/
|
||||||
const loadNotificationEvents = async (): Promise<void> => {
|
const loadNotificationEvents = async (): Promise<void> => {
|
||||||
if (events.value.length > 0) {
|
if (events.value.length > 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/notifications/events/')
|
const response = await request('/api/notifications/events/');
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
events.value = Array.isArray(json?.events) ? json.events : []
|
events.value = Array.isArray(json?.events) ? json.events : [];
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a single notification by ID from the API.
|
* Fetches a single notification by ID from the API.
|
||||||
|
|
@ -185,21 +184,20 @@ const loadNotificationEvents = async (): Promise<void> => {
|
||||||
*/
|
*/
|
||||||
const getNotification = async (id: number): Promise<notification | null> => {
|
const getNotification = async (id: number): Promise<notification | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/notifications/${id}`)
|
const response = await request(`/api/notifications/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const item = await parse_api_response<notification>(json)
|
const item = await parse_api_response<notification>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return item
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new notification via API.
|
* Creates a new notification via API.
|
||||||
|
|
@ -211,42 +209,40 @@ const createNotification = async (
|
||||||
item: Omit<notification, 'id'>,
|
item: Omit<notification, 'id'>,
|
||||||
callback?: (response: APIResponse<notification>) => void,
|
callback?: (response: APIResponse<notification>) => void,
|
||||||
): Promise<notification | null> => {
|
): Promise<notification | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/notifications/', {
|
const response = await request('/api/notifications/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(item),
|
body: JSON.stringify(item),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const created = await parse_api_response<notification>(json)
|
const created = await parse_api_response<notification>(json);
|
||||||
|
|
||||||
updateNotifications(created)
|
updateNotifications(created);
|
||||||
notify.success('Notification target created.')
|
notify.success('Notification target created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing notification via API (PUT - full update).
|
* Updates an existing notification via API (PUT - full update).
|
||||||
|
|
@ -260,45 +256,43 @@ const updateNotification = async (
|
||||||
item: notification,
|
item: notification,
|
||||||
callback?: (response: APIResponse<notification>) => void,
|
callback?: (response: APIResponse<notification>) => void,
|
||||||
): Promise<notification | null> => {
|
): Promise<notification | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (item.id) {
|
if (item.id) {
|
||||||
item.id = undefined
|
item.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/notifications/${id}`, {
|
const response = await request(`/api/notifications/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(item),
|
body: JSON.stringify(item),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<notification>(json)
|
const updated = await parse_api_response<notification>(json);
|
||||||
|
|
||||||
updateNotifications(updated)
|
updateNotifications(updated);
|
||||||
notify.success(`Notification target '${updated.name}' updated.`)
|
notify.success(`Notification target '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partially updates an existing notification via API (PATCH).
|
* Partially updates an existing notification via API (PATCH).
|
||||||
|
|
@ -312,45 +306,43 @@ const patchNotification = async (
|
||||||
patch: Partial<notification>,
|
patch: Partial<notification>,
|
||||||
callback?: (response: APIResponse<notification>) => void,
|
callback?: (response: APIResponse<notification>) => void,
|
||||||
): Promise<notification | null> => {
|
): Promise<notification | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
if (patch.id) {
|
if (patch.id) {
|
||||||
patch.id = undefined
|
patch.id = undefined;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/notifications/${id}`, {
|
const response = await request(`/api/notifications/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<notification>(json)
|
const updated = await parse_api_response<notification>(json);
|
||||||
|
|
||||||
updateNotifications(updated)
|
updateNotifications(updated);
|
||||||
notify.success(`Notification target '${updated.name}' updated.`)
|
notify.success(`Notification target '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a notification by ID via API.
|
* Deletes a notification by ID via API.
|
||||||
|
|
@ -363,43 +355,42 @@ const deleteNotification = async (
|
||||||
callback?: (response: APIResponse<boolean>) => void,
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/notifications/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/notifications/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removeNotification(id)
|
removeNotification(id);
|
||||||
notify.success('Notification target deleted.')
|
notify.success('Notification target deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: true })
|
callback({ success: true, error: null, detail: null, data: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: false })
|
callback({ success: false, error: errorMessage, detail: error, data: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a notification URL is for Apprise (non-HTTP).
|
* Determines if a notification URL is for Apprise (non-HTTP).
|
||||||
* @param url Notification URL
|
* @param url Notification URL
|
||||||
* @returns true if Apprise URL, false otherwise
|
* @returns true if Apprise URL, false otherwise
|
||||||
*/
|
*/
|
||||||
const isApprise = (url: string) => !url.startsWith('http')
|
const isApprise = (url: string) => !url.startsWith('http');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useNotifications composable
|
* useNotifications composable
|
||||||
|
|
@ -423,5 +414,5 @@ export const useNotifications = () => ({
|
||||||
deleteNotification,
|
deleteNotification,
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
isApprise
|
isApprise,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type { Preset, PresetRequest } from '~/types/presets'
|
import type { Preset, PresetRequest } from '~/types/presets';
|
||||||
import type { APIResponse, Pagination } from '~/types/responses'
|
import type { APIResponse, Pagination } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all presets in memory.
|
* List of all presets in memory.
|
||||||
*/
|
*/
|
||||||
const presets = ref<Array<Preset>>([])
|
const presets = ref<Array<Preset>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for presets list.
|
* Pagination state for presets list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts presets by priority (descending), then name (A-Z).
|
* Sorts presets by priority (descending), then name (A-Z).
|
||||||
|
|
@ -49,12 +49,12 @@ const notify = useNotification()
|
||||||
const sortPresets = (items: Array<Preset>): Array<Preset> => {
|
const sortPresets = (items: Array<Preset>): Array<Preset> => {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
if (a.priority === b.priority) {
|
if (a.priority === b.priority) {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.priority - a.priority
|
return b.priority - a.priority;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely reads JSON from a Response, returns null on error.
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
|
@ -63,13 +63,12 @@ const sortPresets = (items: Array<Preset>): Array<Preset> => {
|
||||||
*/
|
*/
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a preset in the presets list, keeping sort order.
|
* Updates or adds a preset in the presets list, keeping sort order.
|
||||||
|
|
@ -102,15 +101,12 @@ const handleError = (error: unknown): void => {
|
||||||
* @param preset Preset to update/add
|
* @param preset Preset to update/add
|
||||||
*/
|
*/
|
||||||
const updatePresets = (preset: Preset): void => {
|
const updatePresets = (preset: Preset): void => {
|
||||||
const isNew = !presets.value.some(item => item.id === preset.id)
|
const isNew = !presets.value.some((item) => item.id === preset.id);
|
||||||
presets.value = sortPresets([
|
presets.value = sortPresets([...presets.value.filter((item) => item.id !== preset.id), preset]);
|
||||||
...presets.value.filter(item => item.id !== preset.id),
|
|
||||||
preset,
|
|
||||||
])
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a preset from the presets list by ID.
|
* Removes a preset from the presets list by ID.
|
||||||
|
|
@ -118,12 +114,12 @@ const updatePresets = (preset: Preset): void => {
|
||||||
* @param id Preset ID
|
* @param id Preset ID
|
||||||
*/
|
*/
|
||||||
const removePreset = (id: number) => {
|
const removePreset = (id: number) => {
|
||||||
const initialLength = presets.value.length
|
const initialLength = presets.value.length;
|
||||||
presets.value = presets.value.filter(item => item.id !== id)
|
presets.value = presets.value.filter((item) => item.id !== id);
|
||||||
if (presets.value.length < initialLength) {
|
if (presets.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all presets from the API with pagination support.
|
* Loads all presets from the API with pagination support.
|
||||||
|
|
@ -131,31 +127,32 @@ const removePreset = (id: number) => {
|
||||||
* @param page Page number
|
* @param page Page number
|
||||||
* @param perPage Items per page
|
* @param perPage Items per page
|
||||||
*/
|
*/
|
||||||
const loadPresets = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadPresets = async (
|
||||||
isLoading.value = true
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/presets/?page=${page}`
|
let url = `/api/presets/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<Preset>(json)
|
const { items, pagination: paginationData } = await parse_list_response<Preset>(json);
|
||||||
|
|
||||||
presets.value = sortPresets(items)
|
presets.value = sortPresets(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a single preset by ID from the API.
|
* Fetches a single preset by ID from the API.
|
||||||
|
|
@ -164,21 +161,20 @@ const loadPresets = async (page: number = 1, perPage: number | undefined = undef
|
||||||
*/
|
*/
|
||||||
const getPreset = async (id: number): Promise<Preset | null> => {
|
const getPreset = async (id: number): Promise<Preset | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/presets/${id}`)
|
const response = await request(`/api/presets/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const preset = await parse_api_response<Preset>(json)
|
const preset = await parse_api_response<Preset>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return preset
|
return preset;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new preset via API.
|
* Creates a new preset via API.
|
||||||
|
|
@ -190,43 +186,41 @@ const createPreset = async (
|
||||||
preset: PresetRequest,
|
preset: PresetRequest,
|
||||||
callback?: (response: APIResponse<Preset>) => void,
|
callback?: (response: APIResponse<Preset>) => void,
|
||||||
): Promise<Preset | null> => {
|
): Promise<Preset | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/presets/', {
|
const response = await request('/api/presets/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(preset),
|
body: JSON.stringify(preset),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const created = await parse_api_response<Preset>(json)
|
const created = await parse_api_response<Preset>(json);
|
||||||
|
|
||||||
updatePresets(created)
|
updatePresets(created);
|
||||||
notify.success('Preset created.')
|
notify.success('Preset created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing preset via API (PUT - full update).
|
* Updates an existing preset via API (PUT - full update).
|
||||||
|
|
@ -240,50 +234,48 @@ const updatePreset = async (
|
||||||
preset: Preset,
|
preset: Preset,
|
||||||
callback?: (response: APIResponse<Preset>) => void,
|
callback?: (response: APIResponse<Preset>) => void,
|
||||||
): Promise<Preset | null> => {
|
): Promise<Preset | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = { ...preset }
|
const payload = { ...preset };
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
payload.id = undefined
|
payload.id = undefined;
|
||||||
}
|
}
|
||||||
if ('default' in payload) {
|
if ('default' in payload) {
|
||||||
payload.default = false
|
payload.default = false;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/presets/${id}`, {
|
const response = await request(`/api/presets/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Preset>(json)
|
const updated = await parse_api_response<Preset>(json);
|
||||||
|
|
||||||
updatePresets(updated)
|
updatePresets(updated);
|
||||||
notify.success(`Preset '${updated.name}' updated.`)
|
notify.success(`Preset '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partially updates an existing preset via API (PATCH).
|
* Partially updates an existing preset via API (PATCH).
|
||||||
|
|
@ -297,50 +289,48 @@ const patchPreset = async (
|
||||||
patch: Partial<Preset>,
|
patch: Partial<Preset>,
|
||||||
callback?: (response: APIResponse<Preset>) => void,
|
callback?: (response: APIResponse<Preset>) => void,
|
||||||
): Promise<Preset | null> => {
|
): Promise<Preset | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = { ...patch }
|
const payload = { ...patch };
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
payload.id = undefined
|
payload.id = undefined;
|
||||||
}
|
}
|
||||||
if ('default' in payload) {
|
if ('default' in payload) {
|
||||||
payload.default = false
|
payload.default = false;
|
||||||
}
|
}
|
||||||
const response = await request(`/api/presets/${id}`, {
|
const response = await request(`/api/presets/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Preset>(json)
|
const updated = await parse_api_response<Preset>(json);
|
||||||
|
|
||||||
updatePresets(updated)
|
updatePresets(updated);
|
||||||
notify.success(`Preset '${updated.name}' updated.`)
|
notify.success(`Preset '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a preset by ID via API.
|
* Deletes a preset by ID via API.
|
||||||
|
|
@ -353,36 +343,35 @@ const deletePreset = async (
|
||||||
callback?: (response: APIResponse<boolean>) => void,
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/presets/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/presets/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removePreset(id)
|
removePreset(id);
|
||||||
notify.success('Preset deleted.')
|
notify.success('Preset deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: true })
|
callback({ success: true, error: null, detail: null, data: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: false })
|
callback({ success: false, error: errorMessage, detail: error, data: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* usePresets composable
|
* usePresets composable
|
||||||
|
|
@ -404,4 +393,4 @@ export const usePresets = () => ({
|
||||||
deletePreset,
|
deletePreset,
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
|
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type {
|
import type {
|
||||||
TaskDefinitionDetailed,
|
TaskDefinitionDetailed,
|
||||||
TaskDefinitionDocument,
|
TaskDefinitionDocument,
|
||||||
TaskDefinitionSummary,
|
TaskDefinitionSummary,
|
||||||
} from '~/types/task_definitions'
|
} from '~/types/task_definitions';
|
||||||
import type { Pagination } from '~/types/responses'
|
import type { Pagination } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive list of all task definition summaries, sorted by priority and name.
|
* Reactive list of all task definition summaries, sorted by priority and name.
|
||||||
*/
|
*/
|
||||||
const definitions = ref<Array<TaskDefinitionSummary>>([])
|
const definitions = ref<Array<TaskDefinitionSummary>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for task definitions list.
|
* Pagination state for task definitions list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -23,25 +23,25 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts task definition summaries by priority (ascending), then name (A-Z).
|
* Sorts task definition summaries by priority (ascending), then name (A-Z).
|
||||||
|
|
@ -51,12 +51,12 @@ const notify = useNotification()
|
||||||
const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => {
|
const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => {
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
if (a.priority === b.priority) {
|
if (a.priority === b.priority) {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.priority - b.priority
|
return a.priority - b.priority;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -65,80 +65,85 @@ const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitio
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.clone().json().catch(() => null)
|
const payload = await response
|
||||||
const message = await parse_api_error(payload)
|
.clone()
|
||||||
throw new Error(message)
|
.json()
|
||||||
}
|
.catch(() => null);
|
||||||
|
const message = await parse_api_error(payload);
|
||||||
|
throw new Error(message);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a summary in the definitions list, keeping sort order.
|
* Updates or adds a summary in the definitions list, keeping sort order.
|
||||||
* @param summary TaskDefinitionSummary to update/add
|
* @param summary TaskDefinitionSummary to update/add
|
||||||
*/
|
*/
|
||||||
const updateSummaries = (summary: TaskDefinitionSummary): void => {
|
const updateSummaries = (summary: TaskDefinitionSummary): void => {
|
||||||
const isNew = !definitions.value.some(item => item.id === summary.id)
|
const isNew = !definitions.value.some((item) => item.id === summary.id);
|
||||||
definitions.value = sortSummaries([
|
definitions.value = sortSummaries([
|
||||||
...definitions.value.filter(item => item.id !== summary.id),
|
...definitions.value.filter((item) => item.id !== summary.id),
|
||||||
summary,
|
summary,
|
||||||
])
|
]);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a summary from the definitions list by ID.
|
* Removes a summary from the definitions list by ID.
|
||||||
* @param id Task definition ID
|
* @param id Task definition ID
|
||||||
*/
|
*/
|
||||||
const removeSummary = (id: number) => {
|
const removeSummary = (id: number) => {
|
||||||
const initialLength = definitions.value.length
|
const initialLength = definitions.value.length;
|
||||||
definitions.value = definitions.value.filter(item => item.id !== id)
|
definitions.value = definitions.value.filter((item) => item.id !== id);
|
||||||
if (definitions.value.length < initialLength) {
|
if (definitions.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all task definition summaries from the API.
|
* Loads all task definition summaries from the API.
|
||||||
* Updates definitions and lastError.
|
* Updates definitions and lastError.
|
||||||
*/
|
*/
|
||||||
const loadDefinitions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadDefinitions = async (
|
||||||
isLoading.value = true
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/tasks/definitions/?page=${page}`
|
let url = `/api/tasks/definitions/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<TaskDefinitionSummary>(json)
|
const { items, pagination: paginationData } =
|
||||||
|
await parse_list_response<TaskDefinitionSummary>(json);
|
||||||
|
|
||||||
definitions.value = sortSummaries(items)
|
definitions.value = sortSummaries(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a detailed task definition by ID from the API.
|
* Fetches a detailed task definition by ID from the API.
|
||||||
|
|
@ -147,36 +152,37 @@ const loadDefinitions = async (page: number = 1, perPage: number | undefined = u
|
||||||
*/
|
*/
|
||||||
const getDefinition = async (id: number): Promise<TaskDefinitionDetailed | null> => {
|
const getDefinition = async (id: number): Promise<TaskDefinitionDetailed | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/definitions/${id}`)
|
const response = await request(`/api/tasks/definitions/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const payload = await response.json()
|
const payload = await response.json();
|
||||||
const detailed = await parse_api_response<TaskDefinitionDetailed>(payload)
|
const detailed = await parse_api_response<TaskDefinitionDetailed>(payload);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return detailed
|
return detailed;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new task definition via API.
|
* Creates a new task definition via API.
|
||||||
* @param definition TaskDefinitionDocument to create
|
* @param definition TaskDefinitionDocument to create
|
||||||
* @returns Created TaskDefinitionDetailed or null on error
|
* @returns Created TaskDefinitionDetailed or null on error
|
||||||
*/
|
*/
|
||||||
const createDefinition = async (definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
|
const createDefinition = async (
|
||||||
|
definition: TaskDefinitionDocument,
|
||||||
|
): Promise<TaskDefinitionDetailed | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/tasks/definitions/', {
|
const response = await request('/api/tasks/definitions/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(definition),
|
body: JSON.stringify(definition),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json())
|
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
|
||||||
|
|
||||||
updateSummaries({
|
updateSummaries({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
|
|
@ -186,18 +192,17 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
created_at: payload.created_at,
|
created_at: payload.created_at,
|
||||||
updated_at: payload.updated_at,
|
updated_at: payload.updated_at,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify.success('Task definition created.')
|
notify.success('Task definition created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return payload
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing task definition via API.
|
* Updates an existing task definition via API.
|
||||||
|
|
@ -205,16 +210,19 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
|
||||||
* @param definition Updated TaskDefinitionDocument
|
* @param definition Updated TaskDefinitionDocument
|
||||||
* @returns Updated TaskDefinitionDetailed or null on error
|
* @returns Updated TaskDefinitionDetailed or null on error
|
||||||
*/
|
*/
|
||||||
const updateDefinition = async (id: number, definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
|
const updateDefinition = async (
|
||||||
|
id: number,
|
||||||
|
definition: TaskDefinitionDocument,
|
||||||
|
): Promise<TaskDefinitionDetailed | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/definitions/${id}`, {
|
const response = await request(`/api/tasks/definitions/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(definition),
|
body: JSON.stringify(definition),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json())
|
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
|
||||||
|
|
||||||
updateSummaries({
|
updateSummaries({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
|
|
@ -224,18 +232,17 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
created_at: payload.created_at,
|
created_at: payload.created_at,
|
||||||
updated_at: payload.updated_at,
|
updated_at: payload.updated_at,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify.success('Task definition updated.')
|
notify.success('Task definition updated.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return payload
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a task definition by ID via API.
|
* Deletes a task definition by ID via API.
|
||||||
|
|
@ -244,20 +251,19 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
|
||||||
*/
|
*/
|
||||||
const deleteDefinition = async (id: number): Promise<boolean> => {
|
const deleteDefinition = async (id: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removeSummary(id)
|
removeSummary(id);
|
||||||
notify.success('Task definition deleted.')
|
notify.success('Task definition deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return true
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the enabled status of a task definition.
|
* Toggles the enabled status of a task definition.
|
||||||
|
|
@ -265,16 +271,19 @@ const deleteDefinition = async (id: number): Promise<boolean> => {
|
||||||
* @param enabled New enabled status
|
* @param enabled New enabled status
|
||||||
* @returns Updated TaskDefinitionDetailed or null on error
|
* @returns Updated TaskDefinitionDetailed or null on error
|
||||||
*/
|
*/
|
||||||
const toggleEnabled = async (id: number, enabled: boolean): Promise<TaskDefinitionDetailed | null> => {
|
const toggleEnabled = async (
|
||||||
|
id: number,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<TaskDefinitionDetailed | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/definitions/${id}`, {
|
const response = await request(`/api/tasks/definitions/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
})
|
});
|
||||||
|
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json())
|
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
|
||||||
|
|
||||||
updateSummaries({
|
updateSummaries({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
|
|
@ -284,23 +293,22 @@ const toggleEnabled = async (id: number, enabled: boolean): Promise<TaskDefiniti
|
||||||
enabled: payload.enabled,
|
enabled: payload.enabled,
|
||||||
created_at: payload.created_at,
|
created_at: payload.created_at,
|
||||||
updated_at: payload.updated_at,
|
updated_at: payload.updated_at,
|
||||||
})
|
});
|
||||||
|
|
||||||
notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`)
|
notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return payload
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useTaskDefinitions composable
|
* useTaskDefinitions composable
|
||||||
|
|
@ -321,6 +329,6 @@ export const useTaskDefinitions = () => ({
|
||||||
toggleEnabled,
|
toggleEnabled,
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
})
|
});
|
||||||
|
|
||||||
export default useTaskDefinitions
|
export default useTaskDefinitions;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
import { useNotification } from '~/composables/useNotification'
|
import { useNotification } from '~/composables/useNotification';
|
||||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||||
import type {
|
import type {
|
||||||
Task,
|
Task,
|
||||||
TaskPatch,
|
TaskPatch,
|
||||||
TaskInspectRequest,
|
TaskInspectRequest,
|
||||||
TaskInspectResponse,
|
TaskInspectResponse,
|
||||||
TaskMetadataResponse,
|
TaskMetadataResponse,
|
||||||
} from '~/types/tasks'
|
} from '~/types/tasks';
|
||||||
import type { APIResponse, Pagination } from '~/types/responses'
|
import type { APIResponse, Pagination } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all tasks in memory.
|
* List of all tasks in memory.
|
||||||
*/
|
*/
|
||||||
const tasks = ref<Array<Task>>([])
|
const tasks = ref<Array<Task>>([]);
|
||||||
/**
|
/**
|
||||||
* Pagination state for tasks list.
|
* Pagination state for tasks list.
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,31 +24,31 @@ const pagination = ref<Pagination>({
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
})
|
});
|
||||||
/**
|
/**
|
||||||
* Indicates if a request is in progress.
|
* Indicates if a request is in progress.
|
||||||
*/
|
*/
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Indicates if an add/update operation is in progress.
|
* Indicates if an add/update operation is in progress.
|
||||||
*/
|
*/
|
||||||
const addInProgress = ref<boolean>(false)
|
const addInProgress = ref<boolean>(false);
|
||||||
/**
|
/**
|
||||||
* Set of task IDs that are currently in progress.
|
* Set of task IDs that are currently in progress.
|
||||||
*/
|
*/
|
||||||
const inProgressIds = ref<Set<number>>(new Set())
|
const inProgressIds = ref<Set<number>>(new Set());
|
||||||
/**
|
/**
|
||||||
* Stores the last error message, if any.
|
* Stores the last error message, if any.
|
||||||
*/
|
*/
|
||||||
const lastError = ref<string | null>(null)
|
const lastError = ref<string | null>(null);
|
||||||
/**
|
/**
|
||||||
* If true, methods will throw errors instead of returning null/false (for testing)
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
*/
|
*/
|
||||||
const throwInstead = ref(false)
|
const throwInstead = ref(false);
|
||||||
/**
|
/**
|
||||||
* Notification composable for showing success/error messages.
|
* Notification composable for showing success/error messages.
|
||||||
*/
|
*/
|
||||||
const notify = useNotification()
|
const notify = useNotification();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts tasks by name (A-Z).
|
* Sorts tasks by name (A-Z).
|
||||||
|
|
@ -56,8 +56,8 @@ const notify = useNotification()
|
||||||
* @returns Sorted array of tasks
|
* @returns Sorted array of tasks
|
||||||
*/
|
*/
|
||||||
const sortTasks = (items: Array<Task>): Array<Task> => {
|
const sortTasks = (items: Array<Task>): Array<Task> => {
|
||||||
return [...items].sort((a, b) => a.name.localeCompare(b.name))
|
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely reads JSON from a Response, returns null on error.
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
|
@ -66,13 +66,12 @@ const sortTasks = (items: Array<Task>): Array<Task> => {
|
||||||
*/
|
*/
|
||||||
const readJson = async (response: Response): Promise<unknown> => {
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
try {
|
try {
|
||||||
const clone = response.clone()
|
const clone = response.clone();
|
||||||
return await clone.json()
|
return await clone.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch {
|
};
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws an error if the response is not OK, using API error message if available.
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
|
@ -81,23 +80,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
||||||
*/
|
*/
|
||||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await readJson(response)
|
const payload = await readJson(response);
|
||||||
const message = await parse_api_error(payload)
|
const message = await parse_api_error(payload);
|
||||||
throw new Error(message)
|
throw new Error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors by updating lastError and showing a notification.
|
* Handles errors by updating lastError and showing a notification.
|
||||||
* @param error Error object or unknown
|
* @param error Error object or unknown
|
||||||
*/
|
*/
|
||||||
const handleError = (error: unknown): void => {
|
const handleError = (error: unknown): void => {
|
||||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
lastError.value = message
|
lastError.value = message;
|
||||||
notify.error(message)
|
notify.error(message);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates or adds a task in the list, keeping sort order.
|
* Updates or adds a task in the list, keeping sort order.
|
||||||
|
|
@ -105,15 +104,12 @@ const handleError = (error: unknown): void => {
|
||||||
* @param item Task to update/add
|
* @param item Task to update/add
|
||||||
*/
|
*/
|
||||||
const updateTasksList = (item: Task): void => {
|
const updateTasksList = (item: Task): void => {
|
||||||
const isNew = !tasks.value.some(existing => existing.id === item.id)
|
const isNew = !tasks.value.some((existing) => existing.id === item.id);
|
||||||
tasks.value = sortTasks([
|
tasks.value = sortTasks([...tasks.value.filter((existing) => existing.id !== item.id), item]);
|
||||||
...tasks.value.filter(existing => existing.id !== item.id),
|
|
||||||
item,
|
|
||||||
])
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
pagination.value.total++
|
pagination.value.total++;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a task from the list by ID.
|
* Removes a task from the list by ID.
|
||||||
|
|
@ -121,43 +117,44 @@ const updateTasksList = (item: Task): void => {
|
||||||
* @param id Task ID
|
* @param id Task ID
|
||||||
*/
|
*/
|
||||||
const removeTask = (id: number) => {
|
const removeTask = (id: number) => {
|
||||||
const initialLength = tasks.value.length
|
const initialLength = tasks.value.length;
|
||||||
tasks.value = tasks.value.filter(item => item.id !== id)
|
tasks.value = tasks.value.filter((item) => item.id !== id);
|
||||||
if (tasks.value.length < initialLength) {
|
if (tasks.value.length < initialLength) {
|
||||||
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
pagination.value.total = Math.max(0, pagination.value.total - 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads tasks from the API with pagination support.
|
* Loads tasks from the API with pagination support.
|
||||||
* @param page Page number
|
* @param page Page number
|
||||||
* @param perPage Items per page
|
* @param perPage Items per page
|
||||||
*/
|
*/
|
||||||
const loadTasks = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
const loadTasks = async (
|
||||||
isLoading.value = true
|
page: number = 1,
|
||||||
|
perPage: number | undefined = undefined,
|
||||||
|
): Promise<void> => {
|
||||||
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
let url = `/api/tasks/?page=${page}`
|
let url = `/api/tasks/?page=${page}`;
|
||||||
if (perPage !== undefined) {
|
if (perPage !== undefined) {
|
||||||
url += `&per_page=${perPage}`
|
url += `&per_page=${perPage}`;
|
||||||
}
|
}
|
||||||
const response = await request(url)
|
const response = await request(url);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const { items, pagination: paginationData } = await parse_list_response<Task>(json)
|
const { items, pagination: paginationData } = await parse_list_response<Task>(json);
|
||||||
|
|
||||||
tasks.value = sortTasks(items)
|
tasks.value = sortTasks(items);
|
||||||
pagination.value = paginationData
|
pagination.value = paginationData;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a single task by ID from the API.
|
* Fetches a single task by ID from the API.
|
||||||
|
|
@ -166,21 +163,20 @@ const loadTasks = async (page: number = 1, perPage: number | undefined = undefin
|
||||||
*/
|
*/
|
||||||
const getTask = async (id: number): Promise<Task | null> => {
|
const getTask = async (id: number): Promise<Task | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}`)
|
const response = await request(`/api/tasks/${id}`);
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const task = await parse_api_response<Task>(json)
|
const task = await parse_api_response<Task>(json);
|
||||||
|
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return task
|
return task;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new task via API.
|
* Creates a new task via API.
|
||||||
|
|
@ -189,57 +185,57 @@ const getTask = async (id: number): Promise<Task | null> => {
|
||||||
* @returns Created task(s) or null on error
|
* @returns Created task(s) or null on error
|
||||||
*/
|
*/
|
||||||
const createTask = async (
|
const createTask = async (
|
||||||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
|
task:
|
||||||
|
| Omit<Task, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
| Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
|
||||||
callback?: (response: APIResponse<Task | Task[]>) => void,
|
callback?: (response: APIResponse<Task | Task[]>) => void,
|
||||||
): Promise<Task | Task[] | null> => {
|
): Promise<Task | Task[] | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/tasks/', {
|
const response = await request('/api/tasks/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(task),
|
body: JSON.stringify(task),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const created = await parse_api_response<Task | Array<Task>>(json)
|
const created = await parse_api_response<Task | Array<Task>>(json);
|
||||||
|
|
||||||
if (Array.isArray(created)) {
|
if (Array.isArray(created)) {
|
||||||
notify.success(`${created.length} tasks created.`)
|
notify.success(`${created.length} tasks created.`);
|
||||||
created.forEach(t => updateTasksList(t))
|
created.forEach((t) => updateTasksList(t));
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTasksList(created)
|
updateTasksList(created);
|
||||||
notify.success('Task created.')
|
notify.success('Task created.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: created })
|
callback({ success: true, error: null, detail: null, data: created });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing task via API (PUT - full update).
|
* Updates an existing task via API (PUT - full update).
|
||||||
|
|
@ -253,45 +249,43 @@ const updateTask = async (
|
||||||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
||||||
callback?: (response: APIResponse<Task>) => void,
|
callback?: (response: APIResponse<Task>) => void,
|
||||||
): Promise<Task | null> => {
|
): Promise<Task | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
// Explicitly remove id, created_at, updated_at fields if present
|
// Explicitly remove id, created_at, updated_at fields if present
|
||||||
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
|
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task;
|
||||||
|
|
||||||
const response = await request(`/api/tasks/${id}`, {
|
const response = await request(`/api/tasks/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(taskData),
|
body: JSON.stringify(taskData),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Task>(json)
|
const updated = await parse_api_response<Task>(json);
|
||||||
|
|
||||||
updateTasksList(updated)
|
updateTasksList(updated);
|
||||||
notify.success(`Task '${updated.name}' updated.`)
|
notify.success(`Task '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partially updates an existing task via API (PATCH).
|
* Partially updates an existing task via API (PATCH).
|
||||||
|
|
@ -305,42 +299,40 @@ const patchTask = async (
|
||||||
patch: TaskPatch,
|
patch: TaskPatch,
|
||||||
callback?: (response: APIResponse<Task>) => void,
|
callback?: (response: APIResponse<Task>) => void,
|
||||||
): Promise<Task | null> => {
|
): Promise<Task | null> => {
|
||||||
addInProgress.value = true
|
addInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}`, {
|
const response = await request(`/api/tasks/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const updated = await parse_api_response<Task>(json)
|
const updated = await parse_api_response<Task>(json);
|
||||||
|
|
||||||
updateTasksList(updated)
|
updateTasksList(updated);
|
||||||
notify.success(`Task '${updated.name}' updated.`)
|
notify.success(`Task '${updated.name}' updated.`);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: updated })
|
callback({ success: true, error: null, detail: null, data: updated });
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated
|
return updated;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
callback({ success: false, error: errorMessage, detail: error, data: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return null
|
return null;
|
||||||
|
} finally {
|
||||||
|
addInProgress.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a task by ID via API.
|
* Deletes a task by ID via API.
|
||||||
|
|
@ -353,55 +345,55 @@ const deleteTask = async (
|
||||||
callback?: (response: APIResponse<boolean>) => void,
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' })
|
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
removeTask(id)
|
removeTask(id);
|
||||||
notify.success('Task deleted.')
|
notify.success('Task deleted.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: true, error: null, detail: null, data: true })
|
callback({ success: true, error: null, detail: null, data: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
handleError(error);
|
||||||
handleError(error)
|
|
||||||
|
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback({ success: false, error: errorMessage, detail: error, data: false })
|
callback({ success: false, error: errorMessage, detail: error, data: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwInstead.value) throw error
|
if (throwInstead.value) throw error;
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects a task handler to check if it can process the given URL.
|
* Inspects a task handler to check if it can process the given URL.
|
||||||
* @param request Task inspect request parameters
|
* @param request Task inspect request parameters
|
||||||
* @returns Inspect result or null on error
|
* @returns Inspect result or null on error
|
||||||
*/
|
*/
|
||||||
const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInspectResponse | null> => {
|
const inspectTaskHandler = async (
|
||||||
|
request: TaskInspectRequest,
|
||||||
|
): Promise<TaskInspectResponse | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tasks/inspect', {
|
const response = await fetch('/api/tasks/inspect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
})
|
});
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return json as TaskInspectResponse
|
return json as TaskInspectResponse;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks all items from a task as downloaded in the download archive.
|
* Marks all items from a task as downloaded in the download archive.
|
||||||
|
|
@ -410,22 +402,21 @@ const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInsp
|
||||||
*/
|
*/
|
||||||
const markTaskItems = async (id: number): Promise<string | null> => {
|
const markTaskItems = async (id: number): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' })
|
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const message = json.message || 'All items marked as downloaded.'
|
const message = json.message || 'All items marked as downloaded.';
|
||||||
|
|
||||||
notify.success(message)
|
notify.success(message);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return message
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes all items from a task from the download archive.
|
* Removes all items from a task from the download archive.
|
||||||
|
|
@ -434,22 +425,21 @@ const markTaskItems = async (id: number): Promise<string | null> => {
|
||||||
*/
|
*/
|
||||||
const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' })
|
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const message = json.message || 'All items removed from archive.'
|
const message = json.message || 'All items removed from archive.';
|
||||||
|
|
||||||
notify.success(message)
|
notify.success(message);
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return message
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates metadata for a task (tvshow.nfo, info.json, thumbnails).
|
* Generates metadata for a task (tvshow.nfo, info.json, thumbnails).
|
||||||
|
|
@ -458,56 +448,55 @@ const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
||||||
*/
|
*/
|
||||||
const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => {
|
const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' })
|
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' });
|
||||||
await ensureSuccess(response)
|
await ensureSuccess(response);
|
||||||
|
|
||||||
const json = await response.json()
|
const json = await response.json();
|
||||||
const metadata = await parse_api_response<TaskMetadataResponse>(json)
|
const metadata = await parse_api_response<TaskMetadataResponse>(json);
|
||||||
|
|
||||||
notify.success('Metadata generation completed.')
|
notify.success('Metadata generation completed.');
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
return metadata
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
if (throwInstead.value) throw error;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
catch (error) {
|
};
|
||||||
handleError(error)
|
|
||||||
if (throwInstead.value) throw error
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the last error message.
|
* Clears the last error message.
|
||||||
*/
|
*/
|
||||||
const clearError = () => lastError.value = null
|
const clearError = () => (lastError.value = null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a task is currently in progress.
|
* Checks if a task is currently in progress.
|
||||||
* @param id Task ID
|
* @param id Task ID
|
||||||
* @returns true if the task is in progress
|
* @returns true if the task is in progress
|
||||||
*/
|
*/
|
||||||
const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id)
|
const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a task as in progress.
|
* Sets a task as in progress.
|
||||||
* @param id Task ID
|
* @param id Task ID
|
||||||
*/
|
*/
|
||||||
const setTaskInProgress = (id: number): void => {
|
const setTaskInProgress = (id: number): void => {
|
||||||
inProgressIds.value.add(id)
|
inProgressIds.value.add(id);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the in progress state for a task.
|
* Clears the in progress state for a task.
|
||||||
* @param id Task ID
|
* @param id Task ID
|
||||||
*/
|
*/
|
||||||
const clearTaskInProgress = (id: number): void => {
|
const clearTaskInProgress = (id: number): void => {
|
||||||
inProgressIds.value.delete(id)
|
inProgressIds.value.delete(id);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets all state to initial values (for testing only).
|
* Resets all state to initial values (for testing only).
|
||||||
*/
|
*/
|
||||||
const __resetForTesting = () => {
|
const __resetForTesting = () => {
|
||||||
tasks.value = []
|
tasks.value = [];
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 50,
|
per_page: 50,
|
||||||
|
|
@ -515,13 +504,13 @@ const __resetForTesting = () => {
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
has_next: false,
|
has_next: false,
|
||||||
has_prev: false,
|
has_prev: false,
|
||||||
}
|
};
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
addInProgress.value = false
|
addInProgress.value = false;
|
||||||
lastError.value = null
|
lastError.value = null;
|
||||||
throwInstead.value = false
|
throwInstead.value = false;
|
||||||
inProgressIds.value = new Set()
|
inProgressIds.value = new Set();
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useTasks composable
|
* useTasks composable
|
||||||
|
|
@ -552,4 +541,4 @@ export const useTasks = () => ({
|
||||||
clearError,
|
clearError,
|
||||||
throwInstead,
|
throwInstead,
|
||||||
__resetForTesting,
|
__resetForTesting,
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<h1 class="title is-4">
|
<h1 class="title is-4">
|
||||||
{{ error.statusCode }}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span>
|
{{ error.statusCode
|
||||||
|
}}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,10 +47,10 @@
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
error: {
|
error: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
const showStacks = ref(false)
|
const showStacks = ref(false);
|
||||||
|
|
||||||
onMounted(() => console.error(props.error))
|
onMounted(() => console.error(props.error));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<template v-if="simpleMode">
|
<template v-if="simpleMode">
|
||||||
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
||||||
<Simple @show_settings="() => show_settings = true" :class="{ 'settings-open': show_settings }" />
|
<Simple
|
||||||
|
@show_settings="() => (show_settings = true)"
|
||||||
|
:class="{ 'settings-open': show_settings }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
|
<SettingsPanel
|
||||||
@reload_bg="() => loadImage(true)" direction="right" />
|
:isOpen="show_settings"
|
||||||
|
:isLoading="loadingImage"
|
||||||
|
@close="closeSettings()"
|
||||||
|
@reload_bg="() => loadImage(true)"
|
||||||
|
direction="right"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-if="!simpleMode">
|
<template v-if="!simpleMode">
|
||||||
<Shutdown v-if="app_shutdown" />
|
<Shutdown v-if="app_shutdown" />
|
||||||
|
|
@ -14,17 +21,25 @@
|
||||||
<NewVersion v-if="newVersionIsAvailable" />
|
<NewVersion v-if="newVersionIsAvailable" />
|
||||||
<nav class="navbar is-mobile is-dark">
|
<nav class="navbar is-mobile is-dark">
|
||||||
<div class="navbar-brand pl-5">
|
<div class="navbar-brand pl-5">
|
||||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink
|
||||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'" id="top">
|
class="navbar-item is-text-overflow"
|
||||||
|
to="/"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'"
|
||||||
|
id="top"
|
||||||
|
>
|
||||||
<span class="is-text-overflow">
|
<span class="is-text-overflow">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i v-if="'connecting' === socket.connectionStatus" class="fas fa-arrows-rotate fa-spin" />
|
<i
|
||||||
|
v-if="'connecting' === socket.connectionStatus"
|
||||||
|
class="fas fa-arrows-rotate fa-spin"
|
||||||
|
/>
|
||||||
<i v-else class="fas fa-home" />
|
<i v-else class="fas fa-home" />
|
||||||
</span>
|
</span>
|
||||||
<span class="has-text-bold" :class="connectionStatusColor">
|
<span class="has-text-bold" :class="connectionStatusColor"> YTPTube </span>
|
||||||
YTPTube
|
<span class="has-text-bold" v-if="config?.app?.instance_title"
|
||||||
</span>
|
>: {{ config.app.instance_title }}</span
|
||||||
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
|
@ -37,12 +52,20 @@
|
||||||
|
|
||||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/browser"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/presets"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span>Presets</span>
|
<span>Presets</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -53,30 +76,45 @@
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/tasks"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||||
<span>List</span>
|
<span>List</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/task_definitions"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||||
<span>Definitions</span>
|
<span>Definitions</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/notifications"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/conditions"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
<span>Conditions</span>
|
<span>Conditions</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<div class="navbar-item has-dropdown">
|
<div class="navbar-item has-dropdown">
|
||||||
|
|
@ -86,19 +124,31 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink
|
||||||
v-if="config.app?.file_logging">
|
class="navbar-item"
|
||||||
|
to="/logs"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
v-if="config.app?.file_logging"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||||
<span>Logs</span>
|
<span>Logs</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink
|
||||||
v-if="config.app.console_enabled">
|
class="navbar-item"
|
||||||
|
to="/console"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
v-if="config.app.console_enabled"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Console</span>
|
<span>Console</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/dl_fields" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink
|
||||||
|
class="navbar-item"
|
||||||
|
to="/dl_fields"
|
||||||
|
@click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||||
<span>Custom fields</span>
|
<span>Custom fields</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -122,36 +172,47 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<button class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }"
|
<button
|
||||||
@click="show_settings = !show_settings">
|
class="button is-dark"
|
||||||
|
:class="{ 'has-tooltip-bottom mr-4': !isMobile }"
|
||||||
|
@click="show_settings = !show_settings"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-cog" /></span>
|
<span class="icon"><i class="fas fa-cog" /></span>
|
||||||
<span v-if="isMobile">WebUI Settings</span>
|
<span v-if="isMobile">WebUI Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div>
|
<div>
|
||||||
<NuxtLoadingIndicator />
|
<NuxtLoadingIndicator />
|
||||||
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
<NuxtPage
|
||||||
<Message v-if="!config.is_loaded" class="mt-5"
|
v-if="config.is_loaded"
|
||||||
|
:isLoading="loadingImage"
|
||||||
|
@reload_bg="() => loadImage(true)"
|
||||||
|
/>
|
||||||
|
<Message
|
||||||
|
v-if="!config.is_loaded"
|
||||||
|
class="mt-5"
|
||||||
:class="{ 'is-info': config.is_loading, 'is-danger': !config.is_loading }"
|
:class="{ 'is-info': config.is_loading, 'is-danger': !config.is_loading }"
|
||||||
:title="config.is_loading ? 'Loading configuration...' : 'Failed to load configuration'"
|
:title="config.is_loading ? 'Loading configuration...' : 'Failed to load configuration'"
|
||||||
:icon="config.is_loading ? 'fas fa-spinner fa-spin' : 'fas fa-triangle-exclamation'">
|
:icon="config.is_loading ? 'fas fa-spinner fa-spin' : 'fas fa-triangle-exclamation'"
|
||||||
|
>
|
||||||
<p v-if="config.is_loading">
|
<p v-if="config.is_loading">
|
||||||
This usually takes less than a few seconds. If this is taking too long,
|
This usually takes less than a few seconds. If this is taking too long,
|
||||||
<NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload the
|
<NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload
|
||||||
page.
|
the page.
|
||||||
</p>
|
</p>
|
||||||
<p v-if="!config.is_loading">
|
<p v-if="!config.is_loading">
|
||||||
Failed to load the application configuration. This likely indicates a problem with the backend. Try to
|
Failed to load the application configuration. This likely indicates a problem with the
|
||||||
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)">reload
|
backend. Try to
|
||||||
configuration</NuxtLink> or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page
|
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)"
|
||||||
</NuxtLink>.
|
>reload configuration</NuxtLink
|
||||||
|
>
|
||||||
|
or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page </NuxtLink>.
|
||||||
</p>
|
</p>
|
||||||
<template v-if="socket.error">
|
<template v-if="socket.error">
|
||||||
<hr>
|
<hr />
|
||||||
<p class="has-text-danger">
|
<p class="has-text-danger">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
|
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
|
||||||
|
|
@ -161,7 +222,7 @@
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</Message>
|
</Message>
|
||||||
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
|
<Markdown @closeModel="() => (doc.file = '')" :file="doc.file" v-if="doc.file" />
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<Dialog />
|
<Dialog />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
@ -179,15 +240,23 @@
|
||||||
<div class="columns is-multiline is-variable is-8">
|
<div class="columns is-multiline is-variable is-8">
|
||||||
<div class="column is-12-mobile is-6-tablet">
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank"
|
<NuxtLink
|
||||||
class="has-text-weight-semibold is-size-6">
|
href="https://github.com/ArabCoders/ytptube"
|
||||||
|
target="_blank"
|
||||||
|
class="has-text-weight-semibold is-size-6"
|
||||||
|
>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fab fa-github" /></span>
|
<span class="icon"><i class="fab fa-github" /></span>
|
||||||
<span>YTPTube</span>
|
<span>YTPTube</span>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="is-size-7 ml-2 has-tooltip" style="opacity: 0.7"
|
<span
|
||||||
v-tooltip="`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`">
|
class="is-size-7 ml-2 has-tooltip"
|
||||||
|
style="opacity: 0.7"
|
||||||
|
v-tooltip="
|
||||||
|
`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ config?.app?.app_version || 'unknown' }}
|
{{ config?.app?.app_version || 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -196,20 +265,37 @@
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
||||||
<span>Update available:</span>
|
<span>Update available:</span>
|
||||||
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
|
<NuxtLink
|
||||||
target="_blank" class="has-text-weight-semibold ml-1">
|
:href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
|
||||||
|
target="_blank"
|
||||||
|
class="has-text-weight-semibold ml-1"
|
||||||
|
>
|
||||||
{{ config.app.new_version }}
|
{{ config.app.new_version }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-else class="is-size-7 mb-2" style="opacity: 0.6">
|
<p v-else class="is-size-7 mb-2" style="opacity: 0.6">
|
||||||
<button @click="checkForUpdates" :disabled="checkingUpdates" class="is-text is-small p-0 is-size-7"
|
<button
|
||||||
|
@click="checkForUpdates"
|
||||||
|
:disabled="checkingUpdates"
|
||||||
|
class="is-text is-small p-0 is-size-7"
|
||||||
:class="{ 'is-loading': checkingUpdates }"
|
:class="{ 'is-loading': checkingUpdates }"
|
||||||
style="opacity: 0.8; height: auto; vertical-align: baseline; border: none; text-decoration: none; background: none;">
|
style="
|
||||||
|
opacity: 0.8;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: baseline;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas" :class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'" />
|
<i
|
||||||
|
class="fas"
|
||||||
|
:class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ updateCheckMessage }}</span>
|
<span>{{ updateCheckMessage }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -219,8 +305,12 @@
|
||||||
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
|
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-clock" /></span>
|
<span class="icon"><i class="fas fa-clock" /></span>
|
||||||
<span class="has-tooltip"
|
<span
|
||||||
v-tooltip="'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')">
|
class="has-tooltip"
|
||||||
|
v-tooltip="
|
||||||
|
'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ moment.unix(config.app?.started).fromNow() }}
|
{{ moment.unix(config.app?.started).fromNow() }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -228,16 +318,28 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12-mobile is-3-tablet">
|
<div class="column is-12-mobile is-3-tablet">
|
||||||
<div class="footer-divider"
|
<div
|
||||||
style="border-left: 1px solid rgba(128,128,128,0.2); border-right: 1px solid rgba(128,128,128,0.2); padding: 0 2rem;">
|
class="footer-divider"
|
||||||
|
style="
|
||||||
|
border-left: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-right: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
padding: 0 2rem;
|
||||||
|
"
|
||||||
|
>
|
||||||
<p class="is-size-7 mb-2" style="opacity: 0.7">Powered by</p>
|
<p class="is-size-7 mb-2" style="opacity: 0.7">Powered by</p>
|
||||||
<NuxtLink href="https://github.com/yt-dlp/yt-dlp" target="_blank" class="has-text-weight-semibold">
|
<NuxtLink
|
||||||
|
href="https://github.com/yt-dlp/yt-dlp"
|
||||||
|
target="_blank"
|
||||||
|
class="has-text-weight-semibold"
|
||||||
|
>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fab fa-github" /></span>
|
<span class="icon"><i class="fab fa-github" /></span>
|
||||||
<span>yt-dlp</span>
|
<span>yt-dlp</span>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="is-size-7 ml-1" style="opacity: 0.6">{{ config?.app?.ytdlp_version || 'unknown' }}</span>
|
<span class="is-size-7 ml-1" style="opacity: 0.6">{{
|
||||||
|
config?.app?.ytdlp_version || 'unknown'
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<p v-if="config.app?.yt_new_version" class="is-size-7 mb-0 mt-2" style="opacity: 0.8">
|
<p v-if="config.app?.yt_new_version" class="is-size-7 mb-0 mt-2" style="opacity: 0.8">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
|
|
@ -245,8 +347,11 @@
|
||||||
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
||||||
<span>Update available:</span>
|
<span>Update available:</span>
|
||||||
</span>
|
</span>
|
||||||
<NuxtLink :href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
|
<NuxtLink
|
||||||
target="_blank" class="has-text-weight-semibold ml-1">
|
:href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
|
||||||
|
target="_blank"
|
||||||
|
class="has-text-weight-semibold ml-1"
|
||||||
|
>
|
||||||
{{ config.app.yt_new_version }}
|
{{ config.app.yt_new_version }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -258,7 +363,8 @@
|
||||||
<p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p>
|
<p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p>
|
||||||
<div
|
<div
|
||||||
class="is-flex is-flex-direction-row is-flex-wrap-wrap is-justify-content-flex-start is-justify-content-flex-end-tablet"
|
class="is-flex is-flex-direction-row is-flex-wrap-wrap is-justify-content-flex-start is-justify-content-flex-end-tablet"
|
||||||
style="gap: 0.75rem;">
|
style="gap: 0.75rem"
|
||||||
|
>
|
||||||
<NuxtLink to="/changelog" class="is-size-7">
|
<NuxtLink to="/changelog" class="is-size-7">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-list" /></span>
|
<span class="icon"><i class="fas fa-list" /></span>
|
||||||
|
|
@ -298,370 +404,377 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, readonly } from 'vue'
|
import { ref, onMounted, watch, readonly } from 'vue';
|
||||||
import 'assets/css/bulma.css'
|
import 'assets/css/bulma.css';
|
||||||
import 'assets/css/style.css'
|
import 'assets/css/style.css';
|
||||||
import 'assets/css/all.css'
|
import 'assets/css/all.css';
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import moment from 'moment'
|
import moment from 'moment';
|
||||||
import type { YTDLPOption } from '~/types/ytdlp'
|
import type { YTDLPOption } from '~/types/ytdlp';
|
||||||
import { useDialog } from '~/composables/useDialog'
|
import { useDialog } from '~/composables/useDialog';
|
||||||
import Dialog from '~/components/Dialog.vue'
|
import Dialog from '~/components/Dialog.vue';
|
||||||
import Simple from '~/components/Simple.vue'
|
import Simple from '~/components/Simple.vue';
|
||||||
import Shutdown from '~/components/shutdown.vue'
|
import Shutdown from '~/components/shutdown.vue';
|
||||||
import Markdown from '~/components/Markdown.vue'
|
import Markdown from '~/components/Markdown.vue';
|
||||||
import type { version_check } from '~/types'
|
import type { version_check } from '~/types';
|
||||||
|
|
||||||
const selectedTheme = useStorage('theme', 'auto')
|
const selectedTheme = useStorage('theme', 'auto');
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const loadedImage = ref()
|
const loadedImage = ref();
|
||||||
const loadingImage = ref(false)
|
const loadingImage = ref(false);
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage('random_bg', true);
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage('random_bg_opacity', 0.95);
|
||||||
const showMenu = ref(false)
|
const showMenu = ref(false);
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
const app_shutdown = ref<boolean>(false)
|
const app_shutdown = ref<boolean>(false);
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||||
const show_settings = ref(false)
|
const show_settings = ref(false);
|
||||||
const doc = ref<{ file: string }>({ file: '' })
|
const doc = ref<{ file: string }>({ file: '' });
|
||||||
const checkingUpdates = ref(false)
|
const checkingUpdates = ref(false);
|
||||||
const updateCheckMessage = ref('Up to date - Click to check')
|
const updateCheckMessage = ref('Up to date - Click to check');
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
if (checkingUpdates.value) {
|
if (checkingUpdates.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = 'Up to date - Click to check'
|
const msg = 'Up to date - Click to check';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checkingUpdates.value = true
|
checkingUpdates.value = true;
|
||||||
updateCheckMessage.value = 'Checking...'
|
updateCheckMessage.value = 'Checking...';
|
||||||
|
|
||||||
const response = await fetch('/api/system/check-updates', { method: 'POST' })
|
const response = await fetch('/api/system/check-updates', { method: 'POST' });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await response.json()
|
await response.json();
|
||||||
updateCheckMessage.value = 'Check failed'
|
updateCheckMessage.value = 'Check failed';
|
||||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await parse_api_response<version_check>(await response.json())
|
const data = await parse_api_response<version_check>(await response.json());
|
||||||
|
|
||||||
if ('update_available' === data.app.status) {
|
if ('update_available' === data.app.status) {
|
||||||
config.app.new_version = data.app.new_version
|
config.app.new_version = data.app.new_version;
|
||||||
}
|
}
|
||||||
if (data.ytdlp && 'update_available' === data.ytdlp.status) {
|
if (data.ytdlp && 'update_available' === data.ytdlp.status) {
|
||||||
config.app.yt_new_version = data.ytdlp.new_version
|
config.app.yt_new_version = data.ytdlp.new_version;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show "Update found!" if app has update (button is in app section)
|
// Only show "Update found!" if app has update (button is in app section)
|
||||||
if ('update_available' === data.app.status) {
|
if ('update_available' === data.app.status) {
|
||||||
updateCheckMessage.value = 'Update found!'
|
updateCheckMessage.value = 'Update found!';
|
||||||
} else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) {
|
} else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) {
|
||||||
updateCheckMessage.value = 'Up to date ✓'
|
updateCheckMessage.value = 'Up to date ✓';
|
||||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||||
} else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) {
|
} else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) {
|
||||||
// App is up to date, but yt-dlp has update (shows in yt-dlp section)
|
// App is up to date, but yt-dlp has update (shows in yt-dlp section)
|
||||||
updateCheckMessage.value = 'Up to date ✓'
|
updateCheckMessage.value = 'Up to date ✓';
|
||||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||||
} else {
|
} else {
|
||||||
updateCheckMessage.value = 'Check failed'
|
updateCheckMessage.value = 'Check failed';
|
||||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Update check failed:', e)
|
console.error('Update check failed:', e);
|
||||||
updateCheckMessage.value = 'Check failed'
|
updateCheckMessage.value = 'Check failed';
|
||||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
checkingUpdates.value = false
|
checkingUpdates.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const applyPreferredColorScheme = (scheme: string) => {
|
const applyPreferredColorScheme = (scheme: string) => {
|
||||||
if (!scheme || scheme === 'auto') {
|
if (!scheme || scheme === 'auto') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let s = 0; s < document.styleSheets.length; s++) {
|
for (let s = 0; s < document.styleSheets.length; s++) {
|
||||||
const styleSheet = document.styleSheets[s]
|
const styleSheet = document.styleSheets[s];
|
||||||
if (!styleSheet?.cssRules) {
|
if (!styleSheet?.cssRules) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rules: CSSRuleList
|
let rules: CSSRuleList;
|
||||||
try {
|
try {
|
||||||
rules = styleSheet.cssRules
|
rules = styleSheet.cssRules;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cross-origin stylesheet
|
// Cross-origin stylesheet
|
||||||
console.debug("Unable to access stylesheet rules:", e)
|
console.debug('Unable to access stylesheet rules:', e);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < rules.length; i++) {
|
for (let i = 0; i < rules.length; i++) {
|
||||||
const rule = rules[i]
|
const rule = rules[i];
|
||||||
|
|
||||||
if (rule instanceof CSSMediaRule && rule.media.mediaText.includes("prefers-color-scheme")) {
|
if (rule instanceof CSSMediaRule && rule.media.mediaText.includes('prefers-color-scheme')) {
|
||||||
const media = rule.media
|
const media = rule.media;
|
||||||
|
|
||||||
const safeDelete = (medium: string) => {
|
const safeDelete = (medium: string) => {
|
||||||
if (media.mediaText.includes(medium)) {
|
if (media.mediaText.includes(medium)) {
|
||||||
try {
|
try {
|
||||||
media.deleteMedium(medium)
|
media.deleteMedium(medium);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug(`Failed to delete medium "${medium}"`, e)
|
console.debug(`Failed to delete medium "${medium}"`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (scheme) {
|
switch (scheme) {
|
||||||
case "light":
|
case 'light':
|
||||||
if (!media.mediaText.includes("original-prefers-color-scheme")) {
|
if (!media.mediaText.includes('original-prefers-color-scheme')) {
|
||||||
media.appendMedium("original-prefers-color-scheme")
|
media.appendMedium('original-prefers-color-scheme');
|
||||||
}
|
}
|
||||||
safeDelete("(prefers-color-scheme: light)")
|
safeDelete('(prefers-color-scheme: light)');
|
||||||
safeDelete("(prefers-color-scheme: dark)")
|
safeDelete('(prefers-color-scheme: dark)');
|
||||||
break
|
break;
|
||||||
|
|
||||||
case "dark":
|
case 'dark':
|
||||||
if (!media.mediaText.includes("(prefers-color-scheme: light)")) {
|
if (!media.mediaText.includes('(prefers-color-scheme: light)')) {
|
||||||
media.appendMedium("(prefers-color-scheme: light)")
|
media.appendMedium('(prefers-color-scheme: light)');
|
||||||
}
|
}
|
||||||
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
|
if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
|
||||||
media.appendMedium("(prefers-color-scheme: dark)")
|
media.appendMedium('(prefers-color-scheme: dark)');
|
||||||
}
|
}
|
||||||
safeDelete("original-prefers-color-scheme")
|
safeDelete('original-prefers-color-scheme');
|
||||||
break
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
|
if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
|
||||||
media.appendMedium("(prefers-color-scheme: dark)")
|
media.appendMedium('(prefers-color-scheme: dark)');
|
||||||
}
|
}
|
||||||
safeDelete("(prefers-color-scheme: light)")
|
safeDelete('(prefers-color-scheme: light)');
|
||||||
safeDelete("original-prefers-color-scheme")
|
safeDelete('original-prefers-color-scheme');
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug("Error modifying media rule:", e)
|
console.debug('Error modifying media rule:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
applyPreferredColorScheme(selectedTheme.value)
|
applyPreferredColorScheme(selectedTheme.value);
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await config.loadConfig()
|
await config.loadConfig();
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const opts = await request('/api/yt-dlp/options')
|
const opts = await request('/api/yt-dlp/options');
|
||||||
if (opts.ok) {
|
if (opts.ok) {
|
||||||
config.ytdlp_options = await opts.json() as Array<YTDLPOption>
|
config.ytdlp_options = (await opts.json()) as Array<YTDLPOption>;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.connect()
|
socket.connect();
|
||||||
} catch { }
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleImage(bg_enable.value)
|
await handleImage(bg_enable.value);
|
||||||
} catch { }
|
} catch {}
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(selectedTheme, value => {
|
watch(selectedTheme, (value) => {
|
||||||
try {
|
try {
|
||||||
if ('auto' === value) {
|
if ('auto' === value) {
|
||||||
applyPreferredColorScheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
applyPreferredColorScheme(
|
||||||
return
|
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
applyPreferredColorScheme(value)
|
applyPreferredColorScheme(value);
|
||||||
} catch { }
|
} catch {}
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(bg_enable, async v => await handleImage(v))
|
watch(bg_enable, async (v) => await handleImage(v));
|
||||||
watch(bg_opacity, v => {
|
watch(bg_opacity, (v) => {
|
||||||
if (false === bg_enable.value) {
|
if (false === bg_enable.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: ${v}`)
|
document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(loadedImage, _ => {
|
watch(loadedImage, (_) => {
|
||||||
if (false === bg_enable.value) {
|
if (false === bg_enable.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = document.documentElement
|
const html = document.documentElement;
|
||||||
const body = document.querySelector('body')
|
const body = document.querySelector('body');
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
"background-color": "unset",
|
'background-color': 'unset',
|
||||||
"display": 'block',
|
display: 'block',
|
||||||
"min-height": '100%',
|
'min-height': '100%',
|
||||||
"min-width": '100%',
|
'min-width': '100%',
|
||||||
"background-image": `url(${loadedImage.value})`,
|
'background-image': `url(${loadedImage.value})`,
|
||||||
} as any
|
} as any;
|
||||||
|
|
||||||
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
|
html.setAttribute(
|
||||||
html.classList.add('bg-fanart')
|
'style',
|
||||||
body?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
Object.keys(style)
|
||||||
})
|
.map((k) => `${k}: ${style[k]}`)
|
||||||
|
.join('; ')
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
html.classList.add('bg-fanart');
|
||||||
|
body?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
||||||
|
});
|
||||||
|
|
||||||
const handleImage = async (enabled: boolean) => {
|
const handleImage = async (enabled: boolean) => {
|
||||||
if (false === enabled) {
|
if (false === enabled) {
|
||||||
if (!loadedImage.value) {
|
if (!loadedImage.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = document.documentElement
|
const html = document.documentElement;
|
||||||
const body = document.querySelector('body')
|
const body = document.querySelector('body');
|
||||||
|
|
||||||
if (html.getAttribute("style")) {
|
if (html.getAttribute('style')) {
|
||||||
html.removeAttribute("style")
|
html.removeAttribute('style');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body?.getAttribute("style")) {
|
if (body?.getAttribute('style')) {
|
||||||
body.removeAttribute("style")
|
body.removeAttribute('style');
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedImage.value = ''
|
loadedImage.value = '';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadedImage.value) {
|
if (loadedImage.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadImage()
|
await loadImage();
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadImage = async (force = false) => {
|
const loadImage = async (force = false) => {
|
||||||
if (loadingImage.value) {
|
if (loadingImage.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loadingImage.value = true
|
loadingImage.value = true;
|
||||||
|
|
||||||
let url = '/api/random/background'
|
let url = '/api/random/background';
|
||||||
if (force) {
|
if (force) {
|
||||||
url += '?force=true'
|
url += '?force=true';
|
||||||
}
|
}
|
||||||
|
|
||||||
const imgRequest = await request(url)
|
const imgRequest = await request(url);
|
||||||
if (200 !== imgRequest.status) {
|
if (200 !== imgRequest.status) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedImage.value = URL.createObjectURL(await imgRequest.blob())
|
loadedImage.value = URL.createObjectURL(await imgRequest.blob());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
loadingImage.value = false
|
loadingImage.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => {
|
const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => {
|
||||||
showMenu.value = false
|
showMenu.value = false;
|
||||||
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
document.querySelectorAll('div.has-dropdown').forEach((el) => el.classList.remove('is-active'));
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback()
|
callback();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const useVersionUpdate = () => {
|
const useVersionUpdate = () => {
|
||||||
const newVersionIsAvailable = ref(false)
|
const newVersionIsAvailable = ref(false);
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp();
|
||||||
nuxtApp.hooks.addHooks({
|
nuxtApp.hooks.addHooks({
|
||||||
'app:manifest:update': () => {
|
'app:manifest:update': () => {
|
||||||
newVersionIsAvailable.value = true
|
newVersionIsAvailable.value = true;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newVersionIsAvailable: readonly(newVersionIsAvailable),
|
newVersionIsAvailable: readonly(newVersionIsAvailable),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const { newVersionIsAvailable } = useVersionUpdate()
|
const { newVersionIsAvailable } = useVersionUpdate();
|
||||||
|
|
||||||
const closeSettings = () => show_settings.value = false
|
const closeSettings = () => (show_settings.value = false);
|
||||||
|
|
||||||
const shutdownApp = async () => {
|
const shutdownApp = async () => {
|
||||||
const { alertDialog, confirmDialog: cDialog } = useDialog()
|
const { alertDialog, confirmDialog: cDialog } = useDialog();
|
||||||
|
|
||||||
if (false === config.app.is_native) {
|
if (false === config.app.is_native) {
|
||||||
await alertDialog({
|
await alertDialog({
|
||||||
title: 'Shutdown Unavailable',
|
title: 'Shutdown Unavailable',
|
||||||
message: 'The shutdown feature is only available when running as native application.',
|
message: 'The shutdown feature is only available when running as native application.',
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = await cDialog({
|
const { status } = await cDialog({
|
||||||
title: 'Shutdown Application',
|
title: 'Shutdown Application',
|
||||||
message: 'Are you sure you want to shutdown the application?',
|
message: 'Are you sure you want to shutdown the application?',
|
||||||
})
|
});
|
||||||
|
|
||||||
if (false === status) {
|
if (false === status) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/system/shutdown', { method: 'POST' })
|
const resp = await fetch('/api/system/shutdown', { method: 'POST' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.json()
|
const body = await resp.json();
|
||||||
await alertDialog({
|
await alertDialog({
|
||||||
title: 'Shutdown Failed',
|
title: 'Shutdown Failed',
|
||||||
message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`,
|
message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
app_shutdown.value = true
|
app_shutdown.value = true;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
await alertDialog({
|
await alertDialog({
|
||||||
title: 'Shutdown Failed',
|
title: 'Shutdown Failed',
|
||||||
message: `Failed to shutdown the application: ${e.message || e}`,
|
message: `Failed to shutdown the application: ${e.message || e}`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const openMenu = (e: MouseEvent) => {
|
const openMenu = (e: MouseEvent) => {
|
||||||
const elm = (e.target as HTMLElement)?.closest('div.has-dropdown') as HTMLElement | null
|
const elm = (e.target as HTMLElement)?.closest('div.has-dropdown') as HTMLElement | null;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('div.has-dropdown').forEach(el => {
|
document.querySelectorAll<HTMLElement>('div.has-dropdown').forEach((el) => {
|
||||||
if (el !== elm) {
|
if (el !== elm) {
|
||||||
el.classList.remove('is-active')
|
el.classList.remove('is-active');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
elm?.classList.toggle('is-active')
|
elm?.classList.toggle('is-active');
|
||||||
}
|
};
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload()
|
const reloadPage = () => window.location.reload();
|
||||||
|
|
||||||
const connectionStatusColor = computed(() => {
|
const connectionStatusColor = computed(() => {
|
||||||
switch (socket.connectionStatus) {
|
switch (socket.connectionStatus) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
return 'has-text-success'
|
return 'has-text-success';
|
||||||
case 'connecting':
|
case 'connecting':
|
||||||
return 'has-text-warning'
|
return 'has-text-warning';
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
default:
|
default:
|
||||||
return 'has-text-danger'
|
return 'has-text-danger';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
|
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -669,7 +782,9 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
|
||||||
<style>
|
<style>
|
||||||
#main_container,
|
#main_container,
|
||||||
.basic-wrapper {
|
.basic-wrapper {
|
||||||
transition: transform 0.3s ease, margin-right 0.3s ease;
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
margin-right 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main_container.settings-open,
|
#main_container.settings-open,
|
||||||
|
|
@ -678,7 +793,6 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
#main_container.settings-open,
|
#main_container.settings-open,
|
||||||
.basic-wrapper.settings-open {
|
.basic-wrapper.settings-open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import 'assets/css/bulma.css'
|
import 'assets/css/bulma.css';
|
||||||
import 'assets/css/style.css'
|
import 'assets/css/style.css';
|
||||||
import 'assets/css/all.css'
|
import 'assets/css/all.css';
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
background-color: unset;
|
background-color: unset;
|
||||||
border-bottom: 1px solid var(--bulma-grey-light) !important
|
border-bottom: 1px solid var(--bulma-grey-light) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -25,8 +25,13 @@ hr {
|
||||||
<div class="is-pulled-right">
|
<div class="is-pulled-right">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0">
|
<p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter changelog entries">
|
type="search"
|
||||||
|
v-model.lazy="query"
|
||||||
|
class="input"
|
||||||
|
id="filter"
|
||||||
|
placeholder="Filter changelog entries"
|
||||||
|
/>
|
||||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -40,7 +45,9 @@ hr {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="is-hidden-mobile">
|
<div class="is-hidden-mobile">
|
||||||
<span class="subtitle">This page display the latest changes and updates from the project.</span>
|
<span class="subtitle"
|
||||||
|
>This page display the latest changes and updates from the project.</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,32 +67,40 @@ hr {
|
||||||
<div class="content p-0 m-0">
|
<div class="content p-0 m-0">
|
||||||
<h1 class="is-4">
|
<h1 class="is-4">
|
||||||
<span class="icon"><i class="fas fa-code-branch" /></span>
|
<span class="icon"><i class="fas fa-code-branch" /></span>
|
||||||
{{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
{{ log.tag }}
|
||||||
|
<span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
||||||
<template v-if="log.date">
|
<template v-if="log.date">
|
||||||
<span style="font-size:0.5em;">
|
<span style="font-size: 0.5em">
|
||||||
- <span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
-
|
||||||
|
<span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
||||||
{{ moment(log.date).fromNow() }}
|
{{ moment(log.date).fromNow() }}
|
||||||
</span></span>
|
</span></span
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
</h1>
|
</h1>
|
||||||
<hr>
|
<hr />
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="commit in log.commits" :key="commit.sha">
|
<li v-for="commit in log.commits" :key="commit.sha">
|
||||||
<strong>
|
<strong> {{ ucFirst(commit.message).replace(/\.$/, '') }}. </strong> -
|
||||||
{{ ucFirst(commit.message).replace(/\.$/, "") }}.
|
|
||||||
</strong> -
|
|
||||||
<small>
|
<small>
|
||||||
<NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank">
|
<NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank">
|
||||||
<span class="has-tooltip" v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`">
|
<span
|
||||||
|
class="has-tooltip"
|
||||||
|
v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`"
|
||||||
|
>
|
||||||
{{ moment(commit.date).fromNow() }}
|
{{ moment(commit.date).fromNow() }}
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span v-tooltip="'Code is at this commit.'" v-if="commit.full_sha === app_sha"
|
<span
|
||||||
class="icon has-text-success"><i class="fas fa-check" /></span>
|
v-tooltip="'Code is at this commit.'"
|
||||||
|
v-if="commit.full_sha === app_sha"
|
||||||
|
class="icon has-text-success"
|
||||||
|
><i class="fas fa-check"
|
||||||
|
/></span>
|
||||||
</small>
|
</small>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr v-if="index < logs.length - 1">
|
<hr v-if="index < logs.length - 1" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,9 +108,18 @@ hr {
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
|
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message title="No Results" class="is-warning" icon="fas fa-search" v-if="query"
|
<Message
|
||||||
:useClose="true" @close="query = ''">
|
title="No Results"
|
||||||
<p>No changelog entries found for the query: <strong>{{ query }}</strong>.</p>
|
class="is-warning"
|
||||||
|
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>
|
<p>Please try a different search term.</p>
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,99 +129,105 @@ hr {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import moment from 'moment'
|
import moment from 'moment';
|
||||||
import type { changelogs, changeset } from '~/types/changelogs'
|
import type { changelogs, changeset } from '~/types/changelogs';
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
useHead({ title: 'CHANGELOG' })
|
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 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<string>('');
|
||||||
const toggleFilter = ref<boolean>(false)
|
const toggleFilter = ref<boolean>(false);
|
||||||
|
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
query.value = ''
|
query.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 logs.value;
|
||||||
|
|
||||||
return logs.value.map(log => {
|
return logs.value
|
||||||
const tagMatches = log.tag.toLowerCase().includes(q)
|
.map((log) => {
|
||||||
|
const tagMatches = log.tag.toLowerCase().includes(q);
|
||||||
|
|
||||||
const filteredCommits = log.commits?.filter(commit =>
|
const filteredCommits =
|
||||||
commit.message.toLowerCase().includes(q) ||
|
log.commits?.filter(
|
||||||
commit.author.toLowerCase().includes(q) ||
|
(commit) =>
|
||||||
commit.full_sha.toLowerCase().includes(q)
|
commit.message.toLowerCase().includes(q) ||
|
||||||
) ?? []
|
commit.author.toLowerCase().includes(q) ||
|
||||||
|
commit.full_sha.toLowerCase().includes(q),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
if (tagMatches || filteredCommits.length > 0) {
|
if (tagMatches || filteredCommits.length > 0) {
|
||||||
return { ...log, commits: tagMatches ? log.commits : filteredCommits }
|
return { ...log, commits: tagMatches ? log.commits : filteredCommits };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}).filter((log): log is changeset => log !== null)
|
})
|
||||||
})
|
.filter((log): log is changeset => log !== null);
|
||||||
|
});
|
||||||
|
|
||||||
const loadContent = async () => {
|
const loadContent = async () => {
|
||||||
if ('' === app_version.value || logs.value.length > 0) {
|
if ('' === app_version.value || logs.value.length > 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
const changes = await fetch(REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value))
|
const changes = await fetch(
|
||||||
logs.value = await changes.json()
|
REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
|
||||||
|
);
|
||||||
|
logs.value = await changes.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json()
|
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
logs.value = logs.value.slice(0, 10)
|
logs.value = logs.value.slice(0, 10);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
toast.error(`Failed to fetch changelog. ${e.message}`)
|
toast.error(`Failed to fetch changelog. ${e.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const isInstalled = (log: changeset) => {
|
const isInstalled = (log: changeset) => {
|
||||||
const installed = String(app_sha.value)
|
const installed = String(app_sha.value);
|
||||||
|
|
||||||
if (log.full_sha.startsWith(installed)) {
|
if (log.full_sha.startsWith(installed)) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const commit of log?.commits ?? []) {
|
for (const commit of log?.commits ?? []) {
|
||||||
if (commit.full_sha.startsWith(installed)) {
|
if (commit.full_sha.startsWith(installed)) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await awaiter(config.isLoaded)
|
await awaiter(config.isLoaded);
|
||||||
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()
|
loadContent();
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
<span class="title is-4">
|
<span class="title is-4">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<template v-if="toggleForm">
|
<template v-if="toggleForm">
|
||||||
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
|
||||||
|
/></span>
|
||||||
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span>
|
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -17,8 +19,13 @@
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
<div class="is-pulled-right" v-if="!toggleForm">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -30,25 +37,44 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
@click="
|
||||||
|
resetForm(false);
|
||||||
|
toggleForm = !toggleForm;
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Condition</span>
|
<span v-if="!isMobile">New Condition</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
v-tooltip.bottom="'Change display style'"
|
||||||
|
class="button has-tooltip-bottom"
|
||||||
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="async () => await loadContent(page)"
|
<button
|
||||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
|
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 class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -63,21 +89,40 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
<Pager
|
||||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
:page="paging.page"
|
||||||
|
:last_page="paging.total_pages"
|
||||||
|
:isLoading="isLoading"
|
||||||
|
@navigate="
|
||||||
|
async (newPage) => {
|
||||||
|
page = newPage;
|
||||||
|
await loadContent(newPage);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
<div class="column is-12" v-if="toggleForm">
|
||||||
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="(item as Condition)"
|
<ConditionForm
|
||||||
@cancel="resetForm(true)" @submit="updateItem" />
|
:addInProgress="conditions.addInProgress.value"
|
||||||
|
:reference="itemRef"
|
||||||
|
:item="item as Condition"
|
||||||
|
@cancel="resetForm(true)"
|
||||||
|
@submit="updateItem"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
|
||||||
|
>
|
||||||
<div class="column is-12" v-if="'list' === display_style">
|
<div class="column is-12" v-if="'list' === display_style">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 850px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 850px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="80%">
|
<th width="80%">
|
||||||
|
|
@ -97,11 +142,21 @@
|
||||||
{{ cond.name }}
|
{{ cond.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="is-unselectable">
|
<div class="is-unselectable">
|
||||||
<span class="icon-text is-clickable" @click="toggleEnabled(cond)"
|
<span
|
||||||
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'">
|
class="icon-text is-clickable"
|
||||||
|
@click="toggleEnabled(cond)"
|
||||||
|
v-tooltip="
|
||||||
|
'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-power-off"
|
<i
|
||||||
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" />
|
class="fa-solid fa-power-off"
|
||||||
|
:class="{
|
||||||
|
'has-text-success': cond.enabled !== false,
|
||||||
|
'has-text-danger': cond.enabled === false,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -147,19 +202,28 @@
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" @click="exportItem(cond)">
|
<button
|
||||||
|
class="button is-info is-small is-fullwidth"
|
||||||
|
@click="exportItem(cond)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" @click="editItem(cond)">
|
<button
|
||||||
|
class="button is-warning is-small is-fullwidth"
|
||||||
|
@click="editItem(cond)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(cond)">
|
<button
|
||||||
|
class="button is-danger is-small is-fullwidth"
|
||||||
|
@click="deleteItem(cond)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -185,13 +249,22 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control" @click="toggleEnabled(cond)">
|
<div class="control" @click="toggleEnabled(cond)">
|
||||||
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
<span
|
||||||
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
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" />
|
<i class="fa-solid fa-power-off" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
|
<a
|
||||||
|
class="has-text-info"
|
||||||
|
v-tooltip="'Export item'"
|
||||||
|
@click.prevent="exportItem(cond)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,16 +281,25 @@
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>{{ cond.cli }}</span>
|
<span>{{ cond.cli }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
<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 class="icon"><i class="fa-solid fa-list" /></span>
|
||||||
<span>Extras:
|
<span
|
||||||
|
>Extras:
|
||||||
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
||||||
<b>{{ key }}</b>: {{ value }}
|
<b>{{ key }}</b
|
||||||
|
>: {{ value }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
<p
|
||||||
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
|
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 class="icon"><i class="fa-solid fa-comment" /></span>
|
||||||
<span>{{ cond.description }}</span>
|
<span>{{ cond.description }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -242,44 +324,65 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||||
Loading data. Please wait...
|
Loading data. Please wait...
|
||||||
</Message>
|
</Message>
|
||||||
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
|
<Message
|
||||||
@close="query = ''">
|
title="No Results"
|
||||||
<p>No results found for the query: <code>{{ query }}</code>.</p>
|
class="is-warning"
|
||||||
|
icon="fas fa-search"
|
||||||
|
v-else-if="query"
|
||||||
|
:useClose="true"
|
||||||
|
@close="query = ''"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No results found for the query: <code>{{ query }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
<p>Please try a different search term.</p>
|
<p>Please try a different search term.</p>
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
<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>
|
There are no custom defined conditions yet. Click the
|
||||||
<strong>New Condition</strong> button to add your first condition.
|
<span class="icon"><i class="fas fa-add" /></span> <strong>New Condition</strong> button
|
||||||
|
to add your first condition.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="filteredItems && filteredItems.length > 0 && !toggleForm">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="filteredItems && filteredItems.length > 0 && !toggleForm"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message class="is-info" :body_class="'pl-0'">
|
<Message class="is-info" :body_class="'pl-0'">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Filtering is based on yt-dlp’s <code>--match-filter</code> logic. Any expression that works with
|
<li>
|
||||||
yt-dlp will also work here, including the same boolean operators. We added extended support for the
|
Filtering is based on yt-dlp’s <code>--match-filter</code> logic. Any expression that
|
||||||
<code>OR</code> ( <code>||</code> ) operator, which yt-dlp does not natively support. This allows you to
|
works with yt-dlp will also work here, including the same boolean operators. We added
|
||||||
combine multiple conditions more flexibly.
|
extended support for the <code>OR</code> ( <code>||</code> ) operator, which yt-dlp
|
||||||
|
does not natively support. This allows you to combine multiple conditions more
|
||||||
|
flexibly.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The primary use case for this feature is to apply custom cli arguments to specific returned info.
|
The primary use case for this feature is to apply custom cli arguments to specific
|
||||||
|
returned info.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
For example, i follow specific channel that sometimes region lock some videos, by using the following
|
For example, i follow specific channel that sometimes region lock some videos, by
|
||||||
filter i am able to bypass it <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>.
|
using the following filter i am able to bypass it
|
||||||
and set proxy for that specific video, while leaving the rest of the videos to be downloaded normally.
|
<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
|
||||||
|
normally.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The data which the filter is applied on is the same data that yt-dlp returns, simply, click on the
|
The data which the filter is applied on is the same data that yt-dlp returns, simply,
|
||||||
information button, and check the data to craft your filter. You will get instant feedback if the
|
click on the information button, and check the data to craft your filter. You will get
|
||||||
filter matches or not.
|
instant feedback if the filter matches or not.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
@ -289,32 +392,32 @@
|
||||||
</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 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 { APIResponse } from '~/types/responses'
|
import type { APIResponse } from '~/types/responses';
|
||||||
|
|
||||||
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 display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
|
||||||
const conditions = useConditions()
|
const conditions = useConditions();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
const items = conditions.conditions as Ref<ConditionItemWithUI[]>
|
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
|
||||||
const paging = conditions.pagination
|
const paging = conditions.pagination;
|
||||||
const isLoading = conditions.isLoading
|
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 toggleForm = ref(false);
|
||||||
const query = ref<string>('')
|
const query = ref<string>('');
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false);
|
||||||
|
|
||||||
const remove_keys = ['raw', 'toggle_description']
|
const remove_keys = ['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 q = query.value?.toLowerCase();
|
||||||
|
|
@ -323,87 +426,95 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadContent = async (page: number = 1): Promise<void> => {
|
const loadContent = async (page: number = 1): Promise<void> => {
|
||||||
await conditions.loadConditions(page)
|
await conditions.loadConditions(page);
|
||||||
await nextTick()
|
await nextTick();
|
||||||
if (conditions.pagination.value.total_pages > 1) {
|
if (conditions.pagination.value.total_pages > 1) {
|
||||||
useRouter().replace({ query: { ...route.query, page: page.toString() } })
|
useRouter().replace({ query: { ...route.query, page: page.toString() } });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleExpand = (itemId: number | undefined, field: string) => {
|
const toggleExpand = (itemId: number | undefined, field: string) => {
|
||||||
if (!itemId) return
|
if (!itemId) return;
|
||||||
|
|
||||||
if (!expandedItems[itemId]) {
|
if (!expandedItems[itemId]) {
|
||||||
expandedItems[itemId] = new Set()
|
expandedItems[itemId] = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expandedItems[itemId].has(field)) {
|
if (expandedItems[itemId].has(field)) {
|
||||||
expandedItems[itemId].delete(field)
|
expandedItems[itemId].delete(field);
|
||||||
} else {
|
} else {
|
||||||
expandedItems[itemId].add(field)
|
expandedItems[itemId].add(field);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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 => {
|
watch(toggleFilter, (val) => {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const resetForm = (closeForm = false): void => {
|
const resetForm = (closeForm = false): void => {
|
||||||
item.value = {}
|
item.value = {};
|
||||||
itemRef.value = null
|
itemRef.value = null;
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false
|
toggleForm.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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!);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateItem = async ({ reference, item: updatedItem }: {
|
const updateItem = async ({
|
||||||
reference: number | null | undefined,
|
reference,
|
||||||
item: Condition
|
item: updatedItem,
|
||||||
|
}: {
|
||||||
|
reference: number | null | undefined;
|
||||||
|
item: Condition;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
updatedItem = cleanObject(updatedItem, remove_keys) as Condition
|
updatedItem = cleanObject(updatedItem, remove_keys) as Condition;
|
||||||
const cb = (resp: APIResponse) => {
|
const cb = (resp: APIResponse) => {
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
resetForm(true)
|
resetForm(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await conditions.patchCondition(reference, updatedItem, cb)
|
await conditions.patchCondition(reference, updatedItem, cb);
|
||||||
} else {
|
} else {
|
||||||
await conditions.createCondition(updatedItem, cb)
|
await conditions.createCondition(updatedItem, cb);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const editItem = (_item: Condition): void => {
|
const editItem = (_item: Condition): void => {
|
||||||
item.value = JSON.parse(JSON.stringify(_item)) as Condition
|
item.value = JSON.parse(JSON.stringify(_item)) as Condition;
|
||||||
itemRef.value = _item.id
|
itemRef.value = _item.id;
|
||||||
toggleForm.value = true
|
toggleForm.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleEnabled = async (cond: Condition): Promise<void> => {
|
const toggleEnabled = async (cond: Condition): Promise<void> => {
|
||||||
const new_state = !cond.enabled
|
const new_state = !cond.enabled;
|
||||||
await conditions.patchCondition(cond.id!, { enabled: new_state })
|
await conditions.patchCondition(cond.id!, { enabled: new_state });
|
||||||
}
|
};
|
||||||
|
|
||||||
const exportItem = (cond: Condition): void => copyText(encode({
|
const exportItem = (cond: Condition): void =>
|
||||||
...Object.fromEntries(Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k))),
|
copyText(
|
||||||
_type: 'condition',
|
encode({
|
||||||
_version: '1.2',
|
...Object.fromEntries(
|
||||||
}))
|
Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k)),
|
||||||
|
),
|
||||||
|
_type: 'condition',
|
||||||
|
_version: '1.2',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => await loadContent(page.value))
|
onMounted(async () => await loadContent(page.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="subtitle is-6 is-unselectable">
|
<div class="subtitle is-6 is-unselectable">
|
||||||
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing the web interface and
|
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing
|
||||||
it's settings.
|
the web interface and it's settings.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
|
|
@ -43,22 +43,45 @@
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<section class="card-content p-0 m-0">
|
<section class="card-content p-0 m-0">
|
||||||
<div ref="terminal_window" style="min-height: 60vh;max-height:70vh;" />
|
<div ref="terminal_window" style="min-height: 60vh; max-height: 70vh" />
|
||||||
</section>
|
</section>
|
||||||
<section class="card-content p-1 m-1">
|
<section class="card-content p-1 m-1">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<TextareaAutocomplete v-if="isMultiLineInput" ref="commandTextarea" v-model="command"
|
<TextareaAutocomplete
|
||||||
:options="ytDlpOptions" :disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" />
|
v-if="isMultiLineInput"
|
||||||
<InputAutocomplete v-else v-model="command" ref="commandInput" :options="ytDlpOptions"
|
ref="commandTextarea"
|
||||||
:disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" @paste="handlePaste"
|
v-model="command"
|
||||||
:multiple="true" :allowShortFlags="true" />
|
:options="ytDlpOptions"
|
||||||
|
:disabled="isLoading"
|
||||||
|
placeholder="--help"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
/>
|
||||||
|
<InputAutocomplete
|
||||||
|
v-else
|
||||||
|
v-model="command"
|
||||||
|
ref="commandInput"
|
||||||
|
:options="ytDlpOptions"
|
||||||
|
:disabled="isLoading"
|
||||||
|
placeholder="--help"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
@paste="handlePaste"
|
||||||
|
:multiple="true"
|
||||||
|
:allowShortFlags="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" type="button" :disabled="isLoading || !hasValidCommand"
|
<button
|
||||||
@click="runCommand">
|
class="button is-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="isLoading || !hasValidCommand"
|
||||||
|
@click="runCommand"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'" />
|
<i
|
||||||
|
class="fa-solid"
|
||||||
|
:class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -70,7 +93,10 @@
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<p class="card-header-title is-pointer is-unselectable" @click="isHistoryCollapsed = !isHistoryCollapsed">
|
<p
|
||||||
|
class="card-header-title is-pointer is-unselectable"
|
||||||
|
@click="isHistoryCollapsed = !isHistoryCollapsed"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
||||||
<span class="ml-2">Commands History</span>
|
<span class="ml-2">Commands History</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -78,9 +104,15 @@
|
||||||
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
|
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
|
||||||
<i class="fa-solid fa-trash" />
|
<i class="fa-solid fa-trash" />
|
||||||
</span>
|
</span>
|
||||||
<span v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'" class="icon ml-2"
|
<span
|
||||||
@click="isHistoryCollapsed = !isHistoryCollapsed">
|
v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'"
|
||||||
<i class="fa-solid" :class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'" />
|
class="icon ml-2"
|
||||||
|
@click="isHistoryCollapsed = !isHistoryCollapsed"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="fa-solid"
|
||||||
|
:class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -89,16 +121,21 @@
|
||||||
Commands history is empty.
|
Commands history is empty.
|
||||||
</Message>
|
</Message>
|
||||||
<div class="table-container" v-if="commandHistory.length > 0">
|
<div class="table-container" v-if="commandHistory.length > 0">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 850px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 850px; table-layout: fixed"
|
||||||
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(cmd, index) in commandHistory" :key="index">
|
<tr v-for="(cmd, index) in commandHistory" :key="index">
|
||||||
<td>
|
<td>
|
||||||
<code class="is-family-monospace is-text-overflow is-pointer is-block" @click="loadCommand(cmd)">
|
<code
|
||||||
|
class="is-family-monospace is-text-overflow is-pointer is-block"
|
||||||
|
@click="loadCommand(cmd)"
|
||||||
|
>
|
||||||
{{ cmd.replace(/\n/g, ' ') }}
|
{{ cmd.replace(/\n/g, ' ') }}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 40px; text-align: center;">
|
<td style="width: 40px; text-align: center">
|
||||||
<button class="delete" @click="removeFromHistory(index)" />
|
<button class="delete" @click="removeFromHistory(index)" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -112,154 +149,166 @@
|
||||||
</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 '@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 { FitAddon } from '@xterm/addon-fit';
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils'
|
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';
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const dialog = useDialog()
|
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<string>('');
|
||||||
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<boolean>(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 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 ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
|
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
||||||
.map(flag => ({ value: flag, description: opt.description || '' }))
|
config.ytdlp_options.flatMap((opt) =>
|
||||||
))
|
opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0)
|
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0);
|
||||||
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'))
|
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'));
|
||||||
|
|
||||||
watch(() => isLoading.value, async value => {
|
watch(
|
||||||
if (value) {
|
() => isLoading.value,
|
||||||
return
|
async (value) => {
|
||||||
}
|
if (value) {
|
||||||
if (command.value.trim()) {
|
return;
|
||||||
addToHistory(command.value.trim())
|
}
|
||||||
}
|
if (command.value.trim()) {
|
||||||
command.value = ''
|
addToHistory(command.value.trim());
|
||||||
await nextTick();
|
}
|
||||||
focusInput()
|
command.value = '';
|
||||||
}, { immediate: true })
|
await nextTick();
|
||||||
|
focusInput();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
watch(() => config.app.console_enabled, async () => {
|
watch(
|
||||||
if (config.app.console_enabled) {
|
() => config.app.console_enabled,
|
||||||
return
|
async () => {
|
||||||
}
|
if (config.app.console_enabled) {
|
||||||
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
|
return;
|
||||||
await navigateTo('/')
|
}
|
||||||
})
|
toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
|
||||||
|
await navigateTo('/');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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 = 'TEXTAREA' === target.tagName;
|
||||||
|
|
||||||
if (event.key !== 'Enter') {
|
if (event.key !== 'Enter') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
runCommand()
|
runCommand();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.shiftKey && !isTextarea) {
|
if (event.shiftKey && !isTextarea) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
const cursorPos = target.selectionStart || command.value.length
|
const cursorPos = target.selectionStart || command.value.length;
|
||||||
command.value = command.value.substring(0, cursorPos) + '\n' + command.value.substring(target.selectionEnd || cursorPos)
|
command.value =
|
||||||
await nextTick()
|
command.value.substring(0, cursorPos) +
|
||||||
|
'\n' +
|
||||||
|
command.value.substring(target.selectionEnd || cursorPos);
|
||||||
|
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) {
|
||||||
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1)
|
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||||
textarea.focus()
|
textarea.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
||||||
const pastedText = event.clipboardData?.getData('text') || ''
|
const pastedText = event.clipboardData?.getData('text') || '';
|
||||||
if (!pastedText.includes('\n')) {
|
if (!pastedText.includes('\n')) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement;
|
||||||
const currentValue = command.value || ''
|
const currentValue = command.value || '';
|
||||||
const start = target.selectionStart || currentValue.length
|
const start = target.selectionStart || currentValue.length;
|
||||||
const end = target.selectionEnd || currentValue.length
|
const end = target.selectionEnd || currentValue.length;
|
||||||
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
|
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end);
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
if (!commandTextarea.value) {
|
if (!commandTextarea.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement
|
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const newPos = start + pastedText.length
|
const newPos = start + pastedText.length;
|
||||||
textarea.setSelectionRange(newPos, newPos)
|
textarea.setSelectionRange(newPos, newPos);
|
||||||
textarea.focus()
|
textarea.focus();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handle_event = () => {
|
const handle_event = () => {
|
||||||
if (!terminal.value) {
|
if (!terminal.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
terminalFit.value?.fit()
|
terminalFit.value?.fit();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleStreamMessage = (event: EventSourceMessage) => {
|
const handleStreamMessage = (event: EventSourceMessage) => {
|
||||||
if (!terminal.value) {
|
if (!terminal.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: { type?: string; line?: string; exitcode?: number } | null = null
|
let payload: { type?: string; line?: string; exitcode?: number } | null = null;
|
||||||
if (event.data) {
|
if (event.data) {
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number }
|
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number };
|
||||||
} catch {
|
} catch {
|
||||||
payload = null
|
payload = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('output' === event.event) {
|
if ('output' === event.event) {
|
||||||
terminal.value.writeln(payload?.line ?? '')
|
terminal.value.writeln(payload?.line ?? '');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('close' === event.event) {
|
if ('close' === event.event) {
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
sseController.value?.abort()
|
sseController.value?.abort();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const startStream = async (cmd: string) => {
|
const startStream = async (cmd: string) => {
|
||||||
sseController.value?.abort()
|
sseController.value?.abort();
|
||||||
const controller = new AbortController()
|
const controller = new AbortController();
|
||||||
sseController.value = controller
|
sseController.value = controller;
|
||||||
isLoading.value = true
|
isLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchEventSource(uri('/api/system/terminal'), {
|
await fetchEventSource(uri('/api/system/terminal'), {
|
||||||
|
|
@ -267,53 +316,53 @@ const startStream = async (cmd: string) => {
|
||||||
body: JSON.stringify({ command: cmd }),
|
body: JSON.stringify({ command: cmd }),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onopen: async (response) => {
|
onopen: async (response) => {
|
||||||
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 {
|
||||||
try {
|
try {
|
||||||
const text = await response.text()
|
const text = await response.text();
|
||||||
if (text) {
|
if (text) {
|
||||||
message = text
|
message = text;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
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,
|
||||||
onerror: (error) => {
|
onerror: (error) => {
|
||||||
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;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
terminal.value?.writeln(`Error: ${error}`)
|
terminal.value?.writeln(`Error: ${error}`);
|
||||||
isLoading.value = false
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (controller === sseController.value) {
|
if (controller === sseController.value) {
|
||||||
sseController.value = null
|
sseController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const ensureTerminal = async () => {
|
const ensureTerminal = async () => {
|
||||||
if (terminal.value) {
|
if (terminal.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.value = new Terminal({
|
terminal.value = new Terminal({
|
||||||
|
|
@ -325,124 +374,128 @@ const ensureTerminal = async () => {
|
||||||
rows: 10,
|
rows: 10,
|
||||||
disableStdin: true,
|
disableStdin: true,
|
||||||
scrollback: 1000,
|
scrollback: 1000,
|
||||||
})
|
});
|
||||||
|
|
||||||
terminalFit.value = new FitAddon()
|
terminalFit.value = new FitAddon();
|
||||||
terminal.value.loadAddon(terminalFit.value)
|
terminal.value.loadAddon(terminalFit.value);
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
|
|
||||||
if (terminal_window.value) {
|
if (terminal_window.value) {
|
||||||
terminal.value.open(terminal_window.value)
|
terminal.value.open(terminal_window.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
terminalFit.value.fit();
|
terminalFit.value.fit();
|
||||||
}
|
};
|
||||||
|
|
||||||
const runCommand = async () => {
|
const runCommand = async () => {
|
||||||
if (!hasValidCommand.value) {
|
if (!hasValidCommand.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true !== config.app.console_enabled) {
|
if (true !== config.app.console_enabled) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cmd = command.value.trim().replace(/\n/g, ' ').trim()
|
let cmd = command.value.trim().replace(/\n/g, ' ').trim();
|
||||||
|
|
||||||
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 ('clear' === cmd) {
|
||||||
clearOutput(true)
|
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) => {
|
||||||
if (terminal.value) {
|
if (terminal.value) {
|
||||||
terminal.value.clear()
|
terminal.value.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === withCommand) {
|
if (true === withCommand) {
|
||||||
command.value = ''
|
command.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
focusInput()
|
focusInput();
|
||||||
}
|
};
|
||||||
|
|
||||||
const focusInput = async () => {
|
const focusInput = async () => {
|
||||||
await nextTick()
|
await nextTick();
|
||||||
let elm;
|
let elm;
|
||||||
if (isMultiLineInput.value) {
|
if (isMultiLineInput.value) {
|
||||||
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement
|
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
} else {
|
} else {
|
||||||
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement
|
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
elm?.focus()
|
elm?.focus();
|
||||||
}
|
};
|
||||||
|
|
||||||
const addToHistory = (cmd: string) => {
|
const addToHistory = (cmd: string) => {
|
||||||
commandHistory.value = [cmd, ...commandHistory.value.filter(h => h !== cmd)].slice(0, MAX_HISTORY_ITEMS)
|
commandHistory.value = [cmd, ...commandHistory.value.filter((h) => h !== cmd)].slice(
|
||||||
}
|
0,
|
||||||
|
MAX_HISTORY_ITEMS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const loadCommand = async (cmd: string) => {
|
const loadCommand = async (cmd: string) => {
|
||||||
command.value = cmd
|
command.value = cmd;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
focusInput()
|
focusInput();
|
||||||
}
|
};
|
||||||
|
|
||||||
const clearHistory = async () => {
|
const clearHistory = async () => {
|
||||||
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: 'is-danger',
|
||||||
})
|
});
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
commandHistory.value = []
|
commandHistory.value = [];
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeFromHistory = (index: number) => commandHistory.value = commandHistory.value.filter((_, i) => i !== index)
|
const removeFromHistory = (index: number) =>
|
||||||
|
(commandHistory.value = commandHistory.value.filter((_, i) => i !== index));
|
||||||
|
|
||||||
watch(isMultiLineInput, () => focusInput())
|
watch(isMultiLineInput, () => focusInput());
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('resize', handle_event);
|
document.addEventListener('resize', handle_event);
|
||||||
focusInput()
|
focusInput();
|
||||||
|
|
||||||
disableOpacity()
|
disableOpacity();
|
||||||
|
|
||||||
await ensureTerminal()
|
await ensureTerminal();
|
||||||
|
|
||||||
if (storedCommand.value) {
|
if (storedCommand.value) {
|
||||||
command.value = storedCommand.value
|
command.value = storedCommand.value;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
sseController.value?.abort()
|
sseController.value?.abort();
|
||||||
document.removeEventListener('resize', handle_event)
|
document.removeEventListener('resize', handle_event);
|
||||||
enableOpacity()
|
enableOpacity();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
<span class="title is-4">
|
<span class="title is-4">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<template v-if="toggleForm">
|
<template v-if="toggleForm">
|
||||||
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
|
||||||
|
/></span>
|
||||||
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span>
|
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -17,8 +19,13 @@
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
<div class="is-pulled-right" v-if="!toggleForm">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -30,25 +37,44 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
@click="
|
||||||
|
resetForm(false);
|
||||||
|
toggleForm = !toggleForm;
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Field</span>
|
<span v-if="!isMobile">New Field</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
v-tooltip.bottom="'Change display style'"
|
||||||
|
class="button has-tooltip-bottom"
|
||||||
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="async () => await loadContent(page)"
|
<button
|
||||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
|
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 class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -63,21 +89,40 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
<Pager
|
||||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
:page="paging.page"
|
||||||
|
:last_page="paging.total_pages"
|
||||||
|
:isLoading="isLoading"
|
||||||
|
@navigate="
|
||||||
|
async (newPage) => {
|
||||||
|
page = newPage;
|
||||||
|
await loadContent(newPage);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
<div class="column is-12" v-if="toggleForm">
|
||||||
<DLFieldForm :addInProgress="dlFields.addInProgress.value" :reference="itemRef" :item="(item as DLField)"
|
<DLFieldForm
|
||||||
@cancel="resetForm(true)" @submit="updateItem" />
|
:addInProgress="dlFields.addInProgress.value"
|
||||||
|
:reference="itemRef"
|
||||||
|
:item="item as DLField"
|
||||||
|
@cancel="resetForm(true)"
|
||||||
|
@submit="updateItem"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
|
||||||
|
>
|
||||||
<div class="column is-12" v-if="'list' === display_style">
|
<div class="column is-12" v-if="'list' === display_style">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 850px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 850px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="80%">
|
<th width="80%">
|
||||||
|
|
@ -111,19 +156,28 @@
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" @click="exportItem(field)">
|
<button
|
||||||
|
class="button is-info is-small is-fullwidth"
|
||||||
|
@click="exportItem(field)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" @click="editItem(field)">
|
<button
|
||||||
|
class="button is-warning is-small is-fullwidth"
|
||||||
|
@click="editItem(field)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(field)">
|
<button
|
||||||
|
class="button is-danger is-small is-fullwidth"
|
||||||
|
@click="deleteItem(field)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -149,7 +203,11 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(field)">
|
<a
|
||||||
|
class="has-text-info"
|
||||||
|
v-tooltip="'Export item'"
|
||||||
|
@click.prevent="exportItem(field)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,19 +245,32 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||||
Loading data. Please wait...
|
Loading data. Please wait...
|
||||||
</Message>
|
</Message>
|
||||||
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
|
<Message
|
||||||
@close="query = ''">
|
title="No Results"
|
||||||
<p>No results found for the query: <code>{{ query }}</code>.</p>
|
class="is-warning"
|
||||||
|
icon="fas fa-search"
|
||||||
|
v-else-if="query"
|
||||||
|
:useClose="true"
|
||||||
|
@close="query = ''"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No results found for the query: <code>{{ query }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
<p>Please try a different search term.</p>
|
<p>Please try a different search term.</p>
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
||||||
There are no custom defined fields yet. Click the <span class="icon"><i class="fas fa-add" /></span>
|
There are no custom defined fields yet. Click the
|
||||||
<strong>New Field</strong> button to add your first field.
|
<span class="icon"><i class="fas fa-add" /></span> <strong>New Field</strong> button to
|
||||||
|
add your first field.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,92 +278,98 @@
|
||||||
</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 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 { 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 display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
|
||||||
const dlFields = useDlFields()
|
const dlFields = useDlFields();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
const items = dlFields.dlFields as Ref<DLField[]>
|
const items = dlFields.dlFields as Ref<DLField[]>;
|
||||||
const paging = dlFields.pagination
|
const paging = dlFields.pagination;
|
||||||
const isLoading = dlFields.isLoading
|
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 toggleForm = ref(false);
|
||||||
const query = ref<string>('')
|
const query = ref<string>('');
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false);
|
||||||
|
|
||||||
const filteredItems = computed<DLField[]>(() => {
|
const filteredItems = computed<DLField[]>(() => {
|
||||||
const q = query.value?.toLowerCase()
|
const q = query.value?.toLowerCase();
|
||||||
if (!q) return items.value
|
if (!q) return items.value;
|
||||||
return items.value.filter(entry => deepIncludes(entry, q, new WeakSet()))
|
return items.value.filter((entry) => deepIncludes(entry, q, new WeakSet()));
|
||||||
})
|
});
|
||||||
|
|
||||||
const loadContent = async (pageNumber: number = 1): Promise<void> => {
|
const loadContent = async (pageNumber: number = 1): Promise<void> => {
|
||||||
await dlFields.loadDlFields(pageNumber)
|
await dlFields.loadDlFields(pageNumber);
|
||||||
await nextTick()
|
await nextTick();
|
||||||
if (dlFields.pagination.value.total_pages > 1) {
|
if (dlFields.pagination.value.total_pages > 1) {
|
||||||
useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } })
|
useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(toggleFilter, value => {
|
watch(toggleFilter, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const resetForm = (closeForm = false): void => {
|
const resetForm = (closeForm = false): void => {
|
||||||
item.value = {}
|
item.value = {};
|
||||||
itemRef.value = null
|
itemRef.value = null;
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false
|
toggleForm.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
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!);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateItem = async ({ reference, item: updatedItem }: {
|
const updateItem = async ({
|
||||||
reference: number | null | undefined,
|
reference,
|
||||||
item: DLField
|
item: updatedItem,
|
||||||
|
}: {
|
||||||
|
reference: number | null | undefined;
|
||||||
|
item: DLField;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const cb = (resp: APIResponse) => {
|
const cb = (resp: APIResponse) => {
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
resetForm(true)
|
resetForm(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await dlFields.patchDlField(reference, updatedItem, cb)
|
await dlFields.patchDlField(reference, updatedItem, cb);
|
||||||
} else {
|
} else {
|
||||||
await dlFields.createDlField(updatedItem, cb)
|
await dlFields.createDlField(updatedItem, cb);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const editItem = (field: DLField): void => {
|
const editItem = (field: DLField): void => {
|
||||||
item.value = { ...field }
|
item.value = { ...field };
|
||||||
itemRef.value = field.id
|
itemRef.value = field.id;
|
||||||
toggleForm.value = true
|
toggleForm.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const exportItem = (field: DLField): void => copyText(encode({
|
const exportItem = (field: DLField): void =>
|
||||||
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
|
copyText(
|
||||||
_type: 'dl_field',
|
encode({
|
||||||
_version: '1.0',
|
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
|
||||||
}))
|
_type: 'dl_field',
|
||||||
|
_version: '1.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => await loadContent(page.value))
|
onMounted(async () => await loadContent(page.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
<div class="is-pulled-right">
|
<div class="is-pulled-right">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter">
|
<p class="control has-icons-left" v-if="toggleFilter">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -25,28 +30,49 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused">
|
<button
|
||||||
|
class="button is-warning"
|
||||||
|
@click="pauseDownload"
|
||||||
|
v-if="false === config.paused"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-pause" /></span>
|
<span class="icon"><i class="fas fa-pause" /></span>
|
||||||
<span v-if="!isMobile">Pause</span>
|
<span v-if="!isMobile">Pause</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'">
|
<button
|
||||||
|
class="button is-danger"
|
||||||
|
@click="resumeDownload"
|
||||||
|
v-else
|
||||||
|
v-tooltip.bottom="'Resume downloading.'"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-play" /></span>
|
<span class="icon"><i class="fas fa-play" /></span>
|
||||||
<span v-if="!isMobile">Resume</span>
|
<span v-if="!isMobile">Resume</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm">
|
<button
|
||||||
|
class="button is-primary has-tooltip-bottom"
|
||||||
|
@click="config.showForm = !config.showForm"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
<span v-if="!isMobile">Add</span>
|
<span v-if="!isMobile">Add</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => changeDisplay()">
|
v-tooltip.bottom="'Change display style'"
|
||||||
<span class="icon"><i class="fa-solid"
|
class="button has-tooltip-bottom"
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
@click="() => changeDisplay()"
|
||||||
|
>
|
||||||
|
<span class="icon"
|
||||||
|
><i
|
||||||
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -55,15 +81,19 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isMobile">
|
<div v-if="!isMobile">
|
||||||
<span class="subtitle">
|
<span class="subtitle"> Queued and completed downloads are displayed here. </span>
|
||||||
Queued and completed downloads are displayed here.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NewDownload v-if="config.showForm" :item="item_form" @clear_form="item_form = {}"
|
<NewDownload
|
||||||
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" />
|
v-if="config.showForm"
|
||||||
|
:item="item_form"
|
||||||
|
@clear_form="item_form = {}"
|
||||||
|
@getInfo="
|
||||||
|
(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="tabs is-boxed is-medium">
|
<div class="tabs is-boxed is-medium">
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -86,132 +116,170 @@
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div v-show="'queue' === activeTab">
|
<div v-show="'queue' === activeTab">
|
||||||
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
<Queue
|
||||||
:thumbnails="show_thumbnail" :query="query"
|
@getInfo="
|
||||||
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
|
(url: string, preset: string = '', cli: string = '') =>
|
||||||
|
view_info(url, false, preset, cli)
|
||||||
|
"
|
||||||
|
:thumbnails="show_thumbnail"
|
||||||
|
:query="query"
|
||||||
|
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||||
|
@clear_search="query = ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="'history' === activeTab">
|
<div v-show="'history' === activeTab">
|
||||||
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
<History
|
||||||
@add_new="(item: item_request) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
|
@getInfo="
|
||||||
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
|
(url: string, preset: string = '', cli: string = '') =>
|
||||||
|
view_info(url, false, preset, cli)
|
||||||
|
"
|
||||||
|
@add_new="(item: item_request) => toNewDownload(item)"
|
||||||
|
:query="query"
|
||||||
|
:thumbnails="show_thumbnail"
|
||||||
|
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||||
|
@clear_search="query = ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
|
<GetInfo
|
||||||
:useUrl="info_view.useUrl" @closeModel="close_info()" />
|
v-if="info_view.url"
|
||||||
|
:link="info_view.url"
|
||||||
|
:preset="info_view.preset"
|
||||||
|
:cli="info_view.cli"
|
||||||
|
:useUrl="info_view.useUrl"
|
||||||
|
@closeModel="close_info()"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
<ConfirmDialog
|
||||||
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
v-if="dialog_confirm.visible"
|
||||||
@cancel="() => dialog_confirm.visible = false" />
|
: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 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';
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
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 display_style = useStorage<string>('display_style', 'grid')
|
const display_style = useStorage<string>('display_style', 'grid');
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
const info_view = ref({
|
const info_view = ref({
|
||||||
url: '',
|
url: '',
|
||||||
preset: '',
|
preset: '',
|
||||||
cli: '',
|
cli: '',
|
||||||
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({
|
const dialog_confirm = ref({
|
||||||
visible: false,
|
visible: false,
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
confirm: () => { },
|
confirm: () => {},
|
||||||
html_message: '',
|
html_message: '',
|
||||||
options: [],
|
options: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Tab management with URL state
|
// Tab management with URL state
|
||||||
type TabType = 'queue' | 'history'
|
type TabType = 'queue' | 'history';
|
||||||
|
|
||||||
const activeTab = ref<TabType>('queue')
|
const activeTab = ref<TabType>('queue');
|
||||||
|
|
||||||
const getInitialTab = (): TabType => {
|
const getInitialTab = (): TabType => {
|
||||||
const tabParam = route.query.tab as string
|
const tabParam = route.query.tab as string;
|
||||||
return (tabParam === 'queue' || tabParam === 'history') ? tabParam : 'queue'
|
return tabParam === 'queue' || tabParam === 'history' ? tabParam : 'queue';
|
||||||
}
|
};
|
||||||
|
|
||||||
const setActiveTab = async (tab: TabType): Promise<void> => {
|
const setActiveTab = async (tab: TabType): Promise<void> => {
|
||||||
activeTab.value = tab
|
activeTab.value = tab;
|
||||||
await router.push({ query: { ...route.query, tab }, replace: true })
|
await router.push({ query: { ...route.query, tab }, replace: true });
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => route.query.tab, (newTab) => {
|
watch(
|
||||||
if (!['queue', 'history'].includes(newTab as string)) {
|
() => route.query.tab,
|
||||||
return
|
(newTab) => {
|
||||||
}
|
if (!['queue', 'history'].includes(newTab as string)) {
|
||||||
activeTab.value = newTab as TabType
|
return;
|
||||||
})
|
}
|
||||||
|
activeTab.value = newTab as TabType;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
if (route.query?.simple !== undefined) {
|
if (route.query?.simple !== undefined) {
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
|
||||||
await nextTick()
|
await nextTick();
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete('simple')
|
url.searchParams.delete('simple');
|
||||||
window.history.replaceState({}, '', url.toString())
|
window.history.replaceState({}, '', url.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTab.value = getInitialTab()
|
activeTab.value = getInitialTab();
|
||||||
useHead({ title: getTitle() })
|
useHead({ title: getTitle() });
|
||||||
})
|
});
|
||||||
|
|
||||||
const queueCount = computed(() => stateStore.count('queue'))
|
const queueCount = computed(() => stateStore.count('queue'));
|
||||||
const historyCount = computed(() => stateStore.count('history'))
|
const historyCount = computed(() => stateStore.count('history'));
|
||||||
|
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getTitle = (): string => {
|
const getTitle = (): string => {
|
||||||
if (!config.app.ui_update_title) {
|
if (!config.app.ui_update_title) {
|
||||||
return 'YTPTube'
|
return 'YTPTube';
|
||||||
}
|
}
|
||||||
return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )`
|
return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )`;
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => stateStore.history, () => {
|
watch(
|
||||||
if (!config.app.ui_update_title) {
|
() => stateStore.history,
|
||||||
return
|
() => {
|
||||||
}
|
if (!config.app.ui_update_title) {
|
||||||
useHead({ title: getTitle() })
|
return;
|
||||||
}, { deep: true })
|
}
|
||||||
|
useHead({ title: getTitle() });
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
watch(() => stateStore.queue, () => {
|
watch(
|
||||||
if (!config.app.ui_update_title) {
|
() => stateStore.queue,
|
||||||
return
|
() => {
|
||||||
}
|
if (!config.app.ui_update_title) {
|
||||||
useHead({ title: getTitle() })
|
return;
|
||||||
}, { deep: true })
|
}
|
||||||
|
useHead({ title: getTitle() });
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' })
|
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' });
|
||||||
|
|
||||||
const pauseDownload = () => {
|
const pauseDownload = () => {
|
||||||
dialog_confirm.value.visible = true
|
dialog_confirm.value.visible = true;
|
||||||
dialog_confirm.value.html_message = `
|
dialog_confirm.value.html_message = `
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
|
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
|
||||||
|
|
@ -223,49 +291,53 @@ const pauseDownload = () => {
|
||||||
<li>This will not stop downloads that are currently in progress.</li>
|
<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>
|
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>`
|
</span>`;
|
||||||
dialog_confirm.value.confirm = async () => {
|
dialog_confirm.value.confirm = async () => {
|
||||||
await request('/api/system/pause', { method: 'POST' })
|
await request('/api/system/pause', { method: 'POST' });
|
||||||
dialog_confirm.value.visible = false
|
dialog_confirm.value.visible = false;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const close_info = () => {
|
const close_info = () => {
|
||||||
info_view.value.url = ''
|
info_view.value.url = '';
|
||||||
info_view.value.preset = ''
|
info_view.value.preset = '';
|
||||||
info_view.value.useUrl = false
|
info_view.value.useUrl = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
|
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
|
||||||
info_view.value.url = url
|
info_view.value.url = url;
|
||||||
info_view.value.useUrl = useUrl
|
info_view.value.useUrl = useUrl;
|
||||||
info_view.value.preset = preset
|
info_view.value.preset = preset;
|
||||||
info_view.value.cli = cli
|
info_view.value.cli = cli;
|
||||||
}
|
};
|
||||||
|
|
||||||
watch(() => info_view.value.url, v => {
|
watch(
|
||||||
if (!bg_enable.value) {
|
() => info_view.value.url,
|
||||||
return
|
(v) => {
|
||||||
}
|
if (!bg_enable.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
|
||||||
})
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const changeDisplay = () => display_style.value = display_style.value === 'grid' ? 'list' : 'grid'
|
const changeDisplay = () =>
|
||||||
|
(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) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.showForm) {
|
if (config.showForm) {
|
||||||
config.showForm = false
|
config.showForm = false;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
item_form.value = item
|
item_form.value = item;
|
||||||
|
|
||||||
await nextTick()
|
await nextTick();
|
||||||
config.showForm = true
|
config.showForm = true;
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,19 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logView>span:nth-child(even) {
|
#logView > span:nth-child(even) {
|
||||||
color: #ffc9d4;
|
color: #ffc9d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
#logView>span:nth-child(odd) {
|
#logView > span:nth-child(odd) {
|
||||||
color: #e3c981;
|
color: #e3c981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbox {
|
.logbox {
|
||||||
background-color: #1f2229 !important;
|
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);
|
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%;
|
min-width: 100%;
|
||||||
max-height: 73vh;
|
max-height: 73vh;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
|
@ -52,8 +54,13 @@ code {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control has-icons-left" v-if="toggleFilter || query">
|
<div class="control has-icons-left" v-if="toggleFilter || query">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -63,9 +70,12 @@ code {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-purple" @click="textWrap = !textWrap" v-tooltip.bottom="'Toggle text wrap'">
|
<button
|
||||||
|
class="button is-purple"
|
||||||
|
@click="textWrap = !textWrap"
|
||||||
|
v-tooltip.bottom="'Toggle text wrap'"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-text-width"></i></span>
|
<span class="icon"><i class="fas fa-text-width"></i></span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -73,35 +83,50 @@ code {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="is-hidden-mobile">
|
<div class="is-hidden-mobile">
|
||||||
<span class="subtitle">The logs are being streamed in real-time. You can scroll up to view older logs.</span>
|
<span class="subtitle"
|
||||||
|
>The logs are being streamed in real-time. You can scroll up to view older logs.</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
||||||
<code id="logView" class="p-2 logline is-block"
|
<code
|
||||||
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }">
|
id="logView"
|
||||||
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
|
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="notification-title">
|
||||||
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
|
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
||||||
No more logs available for this file.
|
No more logs available for this file.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-for="log in filteredItems" :key="log.id" class="is-block">
|
<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>
|
<template v-if="log?.datetime"
|
||||||
{{ log.line }}
|
>[<span class="has-tooltip" :title="log.datetime">{{
|
||||||
</span>
|
moment(log.datetime).format('HH:mm:ss')
|
||||||
<span class="is-block" v-if="filteredItems.length < 1">
|
}}</span
|
||||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
>]</template
|
||||||
<span class="notification-title is-danger">
|
>
|
||||||
<span class="icon"><i class="fas fa-filter" /></span>
|
{{ log.line }}
|
||||||
No logs match this query: <u>{{ query }}</u>
|
</span>
|
||||||
</span>
|
<span class="is-block" v-if="filteredItems.length < 1">
|
||||||
</span>
|
<span
|
||||||
<span v-else>
|
class="is-block m-0 notification is-warning is-dark has-text-centered"
|
||||||
<span class="has-text-danger">No logs available</span></span>
|
v-if="query"
|
||||||
</span>
|
>
|
||||||
</code>
|
<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 ref="bottomMarker"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,263 +135,274 @@ code {
|
||||||
</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 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 { parse_api_error, uri } from '~/utils';
|
||||||
|
|
||||||
let scrollTimeout: NodeJS.Timeout | null = null
|
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const route = useRoute()
|
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_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 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<number>(0);
|
||||||
const loading = ref<boolean>(false)
|
const loading = ref<boolean>(false);
|
||||||
const autoScroll = ref<boolean>(true)
|
const autoScroll = ref<boolean>(true);
|
||||||
const reachedEnd = ref<boolean>(false)
|
const reachedEnd = ref<boolean>(false);
|
||||||
|
|
||||||
const query = ref<string>((() => {
|
const query = ref<string>(
|
||||||
const filter = route.query.filter ?? ''
|
(() => {
|
||||||
if (!filter) {
|
const filter = route.query.filter ?? '';
|
||||||
return ''
|
if (!filter) {
|
||||||
}
|
return '';
|
||||||
if (typeof filter === 'string') {
|
}
|
||||||
return filter.trim()
|
if (typeof filter === 'string') {
|
||||||
}
|
return filter.trim();
|
||||||
if (Array.isArray(filter) && filter.length > 0) {
|
}
|
||||||
return filter[0]?.trim() ?? ''
|
if (Array.isArray(filter) && filter.length > 0) {
|
||||||
}
|
return filter[0]?.trim() ?? '';
|
||||||
return ''
|
}
|
||||||
})())
|
return '';
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false);
|
||||||
watch(toggleFilter, () => {
|
watch(toggleFilter, () => {
|
||||||
if (!toggleFilter.value) {
|
if (!toggleFilter.value) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
scrollToBottom(true)
|
scrollToBottom(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => config.app.file_logging, async v => {
|
watch(
|
||||||
if (v) {
|
() => config.app.file_logging,
|
||||||
return
|
async (v) => {
|
||||||
}
|
if (v) {
|
||||||
await navigateTo('/')
|
return;
|
||||||
})
|
}
|
||||||
|
await navigateTo('/');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const raw = query.value.trim().toLowerCase()
|
const raw = query.value.trim().toLowerCase();
|
||||||
const contextMatch = raw.match(/context:(\d+)/)
|
const contextMatch = raw.match(/context:(\d+)/);
|
||||||
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0
|
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0;
|
||||||
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
const searchTerm = raw.replace(/context:\d+/, '').trim();
|
||||||
|
|
||||||
if (!searchTerm) {
|
if (!searchTerm) {
|
||||||
return logs.value
|
return logs.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Array<log_line> = []
|
const result: Array<log_line> = [];
|
||||||
const matchedIndexes = new Set()
|
const matchedIndexes = new Set();
|
||||||
|
|
||||||
logs.value.forEach((log, i) => {
|
logs.value.forEach((log, i) => {
|
||||||
if (log.line.toLowerCase().includes(searchTerm)) {
|
if (log.line.toLowerCase().includes(searchTerm)) {
|
||||||
for (let j = Math.max(0, i - context); j <= Math.min(logs.value.length - 1, i + context); j++) {
|
for (
|
||||||
matchedIndexes.add(j)
|
let j = Math.max(0, i - context);
|
||||||
|
j <= Math.min(logs.value.length - 1, i + context);
|
||||||
|
j++
|
||||||
|
) {
|
||||||
|
matchedIndexes.add(j);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
|
Array.from(matchedIndexes)
|
||||||
result.push(logs.value[index] as log_line)
|
.sort((a: any, b: any) => a - b)
|
||||||
})
|
.forEach((index: any) => {
|
||||||
|
result.push(logs.value[index] as log_line);
|
||||||
|
});
|
||||||
|
|
||||||
return result
|
return result;
|
||||||
})
|
});
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const fetchLogs = async () => {
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
|
|
||||||
if (reachedEnd.value || query.value) {
|
if (reachedEnd.value || query.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const req = await request(`/api/logs?offset=${offset.value}`)
|
const req = await request(`/api/logs?offset=${offset.value}`);
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
toast.error('Failed to fetch logs');
|
toast.error('Failed to fetch logs');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await req.json()
|
const response = await req.json();
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
toast.error(response.error)
|
toast.error(response.error);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = response.logs ?? [];
|
const lines = response.logs ?? [];
|
||||||
|
|
||||||
if (lines.length) {
|
if (lines.length) {
|
||||||
logs.value.unshift(...response.logs)
|
logs.value.unshift(...response.logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response?.next_offset) {
|
if (response?.next_offset) {
|
||||||
offset.value = response.next_offset
|
offset.value = response.next_offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response?.end_is_reached) {
|
if (response?.end_is_reached) {
|
||||||
reachedEnd.value = true
|
reachedEnd.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (autoScroll.value && bottomMarker.value) {
|
if (autoScroll.value && bottomMarker.value) {
|
||||||
bottomMarker.value.scrollIntoView({ behavior: 'auto' })
|
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch logs:', err)
|
console.error('Failed to fetch logs:', err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!logContainer.value || query.value) {
|
if (!logContainer.value || query.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = logContainer.value
|
const container = logContainer.value;
|
||||||
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50
|
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
|
||||||
const nearTop = container.scrollTop < 50
|
const nearTop = container.scrollTop < 50;
|
||||||
|
|
||||||
autoScroll.value = nearBottom
|
autoScroll.value = nearBottom;
|
||||||
|
|
||||||
if (nearTop && !loading.value && !scrollTimeout) {
|
if (nearTop && !loading.value && !scrollTimeout) {
|
||||||
scrollTimeout = setTimeout(async () => {
|
scrollTimeout = setTimeout(async () => {
|
||||||
const previousHeight = container.scrollHeight
|
const previousHeight = container.scrollHeight;
|
||||||
await fetchLogs()
|
await fetchLogs();
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const newHeight = container.scrollHeight
|
const newHeight = container.scrollHeight;
|
||||||
container.scrollTop += newHeight - previousHeight
|
container.scrollTop += newHeight - previousHeight;
|
||||||
})
|
});
|
||||||
scrollTimeout = null
|
scrollTimeout = null;
|
||||||
}, 300)
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const scrollToBottom = (fast = false) => {
|
const scrollToBottom = (fast = false) => {
|
||||||
autoScroll.value = true
|
autoScroll.value = true;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (bottomMarker.value) {
|
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) => {
|
||||||
if ('log_lines' !== event.event) {
|
if ('log_lines' !== event.event) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.data) {
|
if (!event.data) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload: log_line | null = null
|
let payload: log_line | null = null;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data) as log_line
|
payload = JSON.parse(event.data) as log_line;
|
||||||
} catch {
|
} catch {
|
||||||
payload = null
|
payload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logs.value.push(payload)
|
logs.value.push(payload);
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (autoScroll.value && bottomMarker.value) {
|
if (autoScroll.value && bottomMarker.value) {
|
||||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
bottomMarker.value.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const startLogStream = async () => {
|
const startLogStream = async () => {
|
||||||
sseController.value?.abort()
|
sseController.value?.abort();
|
||||||
const controller = new AbortController()
|
const controller = new AbortController();
|
||||||
sseController.value = controller
|
sseController.value = controller;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchEventSource(uri('/api/logs/stream'), {
|
await fetchEventSource(uri('/api/logs/stream'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onopen: async (response) => {
|
onopen: async (response) => {
|
||||||
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());
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const text = await response.text()
|
const text = await response.text();
|
||||||
if (text) {
|
if (text) {
|
||||||
message = text
|
message = text;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
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,
|
||||||
onerror: (error) => {
|
onerror: (error) => {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
console.error('Log stream error:', error)
|
console.error('Log stream error:', error);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.error('Log stream error:', error)
|
console.error('Log stream error:', error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (controller === sseController.value) {
|
if (controller === sseController.value) {
|
||||||
sseController.value = null
|
sseController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchLogs()
|
await fetchLogs();
|
||||||
await startLogStream()
|
await startLogStream();
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
sseController.value?.abort()
|
sseController.value?.abort();
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
document.querySelector('body')?.setAttribute('style', `opacity: ${bg_opacity.value}`);
|
||||||
}
|
}
|
||||||
if (scrollTimeout) clearTimeout(scrollTimeout)
|
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||||
})
|
});
|
||||||
|
|
||||||
useHead({ title: 'Logs' })
|
useHead({ title: 'Logs' });
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
<span class="title is-4">
|
<span class="title is-4">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<template v-if="toggleForm">
|
<template v-if="toggleForm">
|
||||||
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }" /></span>
|
<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>
|
<span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -16,9 +18,17 @@
|
||||||
</span>
|
</span>
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
<div class="is-pulled-right" v-if="!toggleForm">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && notifications && notifications.length > 0">
|
<p
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
class="control has-icons-left"
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -30,25 +40,44 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
|
<button
|
||||||
v-tooltip="'Add new notification target.'">
|
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 class="icon"><i class="fas fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Notification</span>
|
<span v-if="!isMobile">New Notification</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
|
<button
|
||||||
:class="{ 'is-loading': sendingTest }" :disabled="!notifications.length || sendingTest">
|
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 class="icon"><i class="fas fa-paper-plane" /></span>
|
||||||
<span v-if="!isMobile">Send Test</span>
|
<span v-if="!isMobile">Send Test</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
v-tooltip.bottom="'Change display style'"
|
||||||
|
class="button has-tooltip-bottom"
|
||||||
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -56,8 +85,12 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button class="button is-info" @click="loadContent(page)" :class="{ 'is-loading': isLoading }"
|
<button
|
||||||
:disabled="isLoading || notifications.length < 1">
|
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 class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -72,22 +105,42 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
<Pager
|
||||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
:page="paging.page"
|
||||||
|
:last_page="paging.total_pages"
|
||||||
|
:isLoading="isLoading"
|
||||||
|
@navigate="
|
||||||
|
async (newPage) => {
|
||||||
|
page = newPage;
|
||||||
|
await loadContent(newPage);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="toggleForm">
|
<div class="column is-12" v-if="toggleForm">
|
||||||
<NotificationForm :addInProgress="addInProgress" :reference="targetRef" :item="target"
|
<NotificationForm
|
||||||
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
|
:addInProgress="addInProgress"
|
||||||
|
:reference="targetRef"
|
||||||
|
:item="target"
|
||||||
|
@cancel="resetForm(true)"
|
||||||
|
@submit="updateItem"
|
||||||
|
:allowedEvents="allowedEvents"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0"
|
||||||
|
>
|
||||||
<template v-if="'list' === display_style">
|
<template v-if="'list' === display_style">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 850px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 850px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="80%">
|
<th width="80%">
|
||||||
|
|
@ -119,8 +172,13 @@
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
||||||
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
<span
|
||||||
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
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" />
|
<i class="fa-solid fa-power-off" />
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
|
@ -130,19 +188,28 @@
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)">
|
<button
|
||||||
|
class="button is-info is-small is-fullwidth"
|
||||||
|
@click="exportItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)">
|
<button
|
||||||
|
class="button is-warning is-small is-fullwidth"
|
||||||
|
@click="editItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)">
|
<button
|
||||||
|
class="button is-danger is-small is-fullwidth"
|
||||||
|
@click="deleteItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -166,13 +233,22 @@
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control" @click="toggleEnabled(item)">
|
<div class="control" @click="toggleEnabled(item)">
|
||||||
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
<span
|
||||||
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
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" />
|
<i class="fa-solid fa-power-off" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
|
<a
|
||||||
|
class="has-text-info"
|
||||||
|
v-tooltip="'Export target.'"
|
||||||
|
@click.prevent="exportItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,13 +267,13 @@
|
||||||
</p>
|
</p>
|
||||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||||
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span>
|
<span>Headers: {{ item.request.headers.map((h) => h.key).join(', ') }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(item);">
|
<button class="button is-warning is-fullwidth" @click="editItem(item)">
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -214,47 +290,65 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline"
|
<div
|
||||||
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)">
|
class="columns is-multiline"
|
||||||
|
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||||
Loading data. Please wait...
|
Loading data. Please wait...
|
||||||
</Message>
|
</Message>
|
||||||
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
|
<Message
|
||||||
@close="query = ''">
|
title="No Results"
|
||||||
<p>No results found for the query: <code>{{ query }}</code>.</p>
|
class="is-warning"
|
||||||
|
icon="fas fa-search"
|
||||||
|
v-else-if="query"
|
||||||
|
:useClose="true"
|
||||||
|
@close="query = ''"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No results found for the query: <code>{{ query }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
<p>Please try a different search term.</p>
|
<p>Please try a different search term.</p>
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
|
<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
|
No notification targets found. Click on the
|
||||||
Notification</strong> button to add your first notification target.
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
|
<strong>New Notification</strong> button to add your first notification target.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!toggleForm && filteredTargets && filteredTargets.length > 0">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!toggleForm && filteredTargets && filteredTargets.length > 0"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message class="is-info" :body_class="'pl-0'">
|
<Message class="is-info" :body_class="'pl-0'">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
When you export notification target, We remove <code>Authorization</code> header key by default,
|
When you export notification target, We remove <code>Authorization</code> header key
|
||||||
However this might not be enough to remove credentials from the exported data. it's your responsibility
|
by default, However this might not be enough to remove credentials from the exported
|
||||||
to ensure that the exported data does not contain any sensitive information for sharing.
|
data. it's your responsibility to ensure that the exported data does not contain any
|
||||||
|
sensitive information for sharing.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
When you set the request type as <code>Form</code>, the event data will be JSON encoded and sent as
|
When you set the request type as <code>Form</code>, the event data will be JSON
|
||||||
<code>...&data_key=json_string</code>, only the <code>data</code> field will be JSON encoded.
|
encoded and sent as <code>...&data_key=json_string</code>, only the
|
||||||
The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are.
|
<code>data</code> field will be JSON encoded. The other keys <code>id</code>,
|
||||||
</li>
|
<code>event</code> and <code>created_at</code> will be sent as they are.
|
||||||
<li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If you have selected specific presets or events, this will take priority, For example, if you limited
|
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with
|
||||||
the
|
the request.
|
||||||
target to <code>default</code> preset and selected <code>ALL</code> events, only events that reference
|
</li>
|
||||||
the
|
<li>
|
||||||
<code>default</code> preset will be sent to that target. Like wise, if you have limited both events and
|
If you have selected specific presets or events, this will take priority, For example,
|
||||||
presets, then ONLY events that satisfy both conditions will be sent to that target. Only the
|
if you limited the target to <code>default</code> preset and selected
|
||||||
|
<code>ALL</code> events, only events that reference the <code>default</code> preset
|
||||||
|
will be sent to that target. Like wise, if you have limited both events and presets,
|
||||||
|
then ONLY 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>
|
||||||
|
|
@ -265,29 +359,29 @@
|
||||||
</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 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 type { ImportedItem } from '~/types'
|
import type { ImportedItem } from '~/types';
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
const display_style = useStorage<string>('notification_display_style', 'cards')
|
const display_style = useStorage<string>('notification_display_style', 'cards');
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
const notificationsStore = useNotifications()
|
const notificationsStore = useNotifications();
|
||||||
const notifications = notificationsStore.notifications
|
const notifications = notificationsStore.notifications;
|
||||||
const paging = notificationsStore.pagination
|
const paging = notificationsStore.pagination;
|
||||||
const allowedEvents = notificationsStore.events
|
const allowedEvents = notificationsStore.events;
|
||||||
const isLoading = notificationsStore.isLoading
|
const isLoading = notificationsStore.isLoading;
|
||||||
const addInProgress = notificationsStore.addInProgress
|
const addInProgress = notificationsStore.addInProgress;
|
||||||
const lastError = notificationsStore.lastError
|
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 toggleForm = ref(false);
|
||||||
const sendingTest = ref(false)
|
const sendingTest = ref(false);
|
||||||
|
|
||||||
const defaultState = (): notification => ({
|
const defaultState = (): notification => ({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -295,127 +389,137 @@ const defaultState = (): notification => ({
|
||||||
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 target = ref<notification>(defaultState());
|
||||||
const query = ref<string>('')
|
const query = ref<string>('');
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false);
|
||||||
|
|
||||||
const filteredTargets = computed<notification[]>(() => {
|
const filteredTargets = computed<notification[]>(() => {
|
||||||
const q = query.value?.toLowerCase()
|
const q = query.value?.toLowerCase();
|
||||||
const items = notifications.value.map(item => ({ ...item })) as notification[]
|
const items = notifications.value.map((item) => ({ ...item })) as notification[];
|
||||||
if (!q) return items
|
if (!q) return items;
|
||||||
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()))
|
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()));
|
||||||
})
|
});
|
||||||
|
|
||||||
watch(toggleFilter, (val) => {
|
watch(toggleFilter, (val) => {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const loadContent = async (pageNumber = page.value) => {
|
const loadContent = async (pageNumber = page.value) => {
|
||||||
await notificationsStore.loadNotifications(pageNumber)
|
await notificationsStore.loadNotifications(pageNumber);
|
||||||
}
|
};
|
||||||
|
|
||||||
const resetForm = (closeForm = false) => {
|
const resetForm = (closeForm = false) => {
|
||||||
target.value = defaultState()
|
target.value = defaultState();
|
||||||
targetRef.value = undefined
|
targetRef.value = undefined;
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false
|
toggleForm.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteItem = async (item: notification) => {
|
const deleteItem = async (item: notification) => {
|
||||||
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Notification target not found.')
|
toast.error('Notification target not found.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await notificationsStore.deleteNotification(item.id)
|
await notificationsStore.deleteNotification(item.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleEnabled = async (item: notification) => {
|
const toggleEnabled = async (item: notification) => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
toast.error('Notification target not found.')
|
toast.error('Notification target not found.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await notificationsStore.patchNotification(item.id, { enabled: !item.enabled })
|
await notificationsStore.patchNotification(item.id, { enabled: !item.enabled });
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateItem = async ({ reference, item }: { reference: number | undefined, item: notification }) => {
|
const updateItem = async ({
|
||||||
|
reference,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
reference: number | undefined;
|
||||||
|
item: notification;
|
||||||
|
}) => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
await notificationsStore.updateNotification(reference, item)
|
await notificationsStore.updateNotification(reference, item);
|
||||||
} else {
|
} else {
|
||||||
await notificationsStore.createNotification(item)
|
await notificationsStore.createNotification(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lastError.value) {
|
if (!lastError.value) {
|
||||||
resetForm(true)
|
resetForm(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const editItem = (item: notification) => {
|
const editItem = (item: notification) => {
|
||||||
target.value = JSON.parse(JSON.stringify(item)) as notification
|
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
||||||
targetRef.value = item.id ?? undefined
|
targetRef.value = item.id ?? undefined;
|
||||||
toggleForm.value = true
|
toggleForm.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const join_events = (events: Array<string>) => !events || events.length < 1 ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
|
const join_events = (events: Array<string>) =>
|
||||||
const join_presets = (presets: Array<string>) => !presets || presets.length < 1 ? 'ALL' : presets.map(e => ucFirst(e)).join(', ')
|
!events || events.length < 1 ? 'ALL' : events.map((e) => ucFirst(e)).join(', ');
|
||||||
|
const join_presets = (presets: Array<string>) =>
|
||||||
|
!presets || presets.length < 1 ? 'ALL' : presets.map((e) => ucFirst(e)).join(', ');
|
||||||
|
|
||||||
const sendTest = async () => {
|
const sendTest = async () => {
|
||||||
if (true !== (await box.confirm('Send test notification?'))) {
|
if (true !== (await box.confirm('Send test notification?'))) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendingTest.value = true
|
sendingTest.value = true;
|
||||||
const response = await request('/api/notifications/test', { method: 'POST' })
|
const response = await request('/api/notifications/test', { method: 'POST' });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
const message = await parse_api_error(data)
|
const message = await parse_api_error(data);
|
||||||
toast.error(`Failed to send test notification. ${message}`)
|
toast.error(`Failed to send test notification. ${message}`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Test notification sent.')
|
toast.success('Test notification sent.');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
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 {
|
||||||
sendingTest.value = false
|
sendingTest.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => await notificationsStore.loadNotifications(page.value))
|
onMounted(async () => await notificationsStore.loadNotifications(page.value));
|
||||||
|
|
||||||
const exportItem = async (item: notification) => {
|
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',
|
||||||
_version: '1.0',
|
_version: '1.0',
|
||||||
}
|
};
|
||||||
|
|
||||||
const keys = ['id', 'raw']
|
const keys = ['id', 'raw'];
|
||||||
keys.forEach(k => {
|
keys.forEach((k) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
||||||
const { [k]: _, ...rest } = data as any
|
const { [k]: _, ...rest } = data as any;
|
||||||
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(h => 'authorization' !== h.key.toLowerCase())
|
data.request.headers = data.request.headers.filter(
|
||||||
|
(h) => 'authorization' !== h.key.toLowerCase(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyText(encode(data))
|
copyText(encode(data));
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
<span class="title is-4">
|
<span class="title is-4">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<template v-if="toggleForm">
|
<template v-if="toggleForm">
|
||||||
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }" /></span>
|
<span class="icon"
|
||||||
|
><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }"
|
||||||
|
/></span>
|
||||||
<span>{{ presetRef ? `Edit - ${prettyName(preset.name || '')}` : 'Add' }}</span>
|
<span>{{ presetRef ? `Edit - ${prettyName(preset.name || '')}` : 'Add' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -17,8 +19,13 @@
|
||||||
<div class="is-pulled-right" v-if="!toggleForm">
|
<div class="is-pulled-right" v-if="!toggleForm">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
|
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input
|
||||||
placeholder="Filter displayed content">
|
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>
|
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -30,19 +37,33 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"
|
<button
|
||||||
v-tooltip.bottom="'Toggle add form'">
|
class="button is-primary"
|
||||||
|
@click="
|
||||||
|
resetForm(false);
|
||||||
|
toggleForm = !toggleForm;
|
||||||
|
"
|
||||||
|
v-tooltip.bottom="'Toggle add form'"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-add" /></span>
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Preset</span>
|
<span v-if="!isMobile">New Preset</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
v-tooltip.bottom="'Change display style'"
|
||||||
|
class="button has-tooltip-bottom"
|
||||||
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -50,8 +71,13 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
<button
|
||||||
:disabled="isLoading" v-if="presets && presets.length > 0">
|
class="button is-info"
|
||||||
|
@click="reloadContent()"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
:disabled="isLoading"
|
||||||
|
v-if="presets && presets.length > 0"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -59,26 +85,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
<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
|
<span class="subtitle"
|
||||||
download.</span>
|
>Presets are pre-defined command options for yt-dlp that you want to apply to given
|
||||||
|
download.</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns" v-if="toggleForm">
|
<div class="columns" v-if="toggleForm">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true)"
|
<PresetForm
|
||||||
@submit="updateItem" :presets="presets" />
|
:addInProgress="addInProgress"
|
||||||
|
:reference="presetRef"
|
||||||
|
:preset="preset"
|
||||||
|
@cancel="resetForm(true)"
|
||||||
|
@submit="updateItem"
|
||||||
|
:presets="presets"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="!toggleForm">
|
<template v-if="!toggleForm">
|
||||||
<div class="columns is-multiline" v-if="!isLoading && presetsNoDefault && presetsNoDefault.length > 0">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="!isLoading && presetsNoDefault && presetsNoDefault.length > 0"
|
||||||
|
>
|
||||||
<template v-if="'list' === display_style">
|
<template v-if="'list' === display_style">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 650px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 650px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="80%">Preset</th>
|
<th width="80%">Preset</th>
|
||||||
|
|
@ -109,19 +148,28 @@
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)">
|
<button
|
||||||
|
class="button is-info is-small is-fullwidth"
|
||||||
|
@click="exportItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)">
|
<button
|
||||||
|
class="button is-warning is-small is-fullwidth"
|
||||||
|
@click="editItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)">
|
<button
|
||||||
|
class="button is-danger is-small is-fullwidth"
|
||||||
|
@click="deleteItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -139,9 +187,13 @@
|
||||||
<div class="column is-6" v-for="item in filteredPresets" :key="item.id">
|
<div class="column is-6" v-for="item in filteredPresets" :key="item.id">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
<div class="card is-flex is-full-height is-flex-direction-column">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-block is-clickable"
|
<div
|
||||||
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }" @click="toggleExpand(item.id, 'title')"
|
class="card-header-title is-block is-clickable"
|
||||||
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'" v-text="prettyName(item.name)" />
|
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }"
|
||||||
|
@click="toggleExpand(item.id, 'title')"
|
||||||
|
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'"
|
||||||
|
v-text="prettyName(item.name)"
|
||||||
|
/>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control" v-if="item.priority > 0">
|
<div class="control" v-if="item.priority > 0">
|
||||||
|
|
@ -154,7 +206,11 @@
|
||||||
<span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span>
|
<span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="has-text-info" v-tooltip="'Export preset'" @click="exportItem(item)">
|
<button
|
||||||
|
class="has-text-info"
|
||||||
|
v-tooltip="'Export preset'"
|
||||||
|
@click="exportItem(item)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,34 +225,65 @@
|
||||||
<span>Priority: {{ item.priority }}</span>
|
<span>Priority: {{ item.priority }}</span>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }"
|
<p
|
||||||
v-if="item.folder" @click="toggleExpand(item.id, 'folder')"
|
:class="{
|
||||||
:title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'">
|
'is-text-overflow': !isExpanded(item.id, 'folder'),
|
||||||
|
'is-clickable': true,
|
||||||
|
}"
|
||||||
|
v-if="item.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>
|
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||||
<span>{{ calcPath(item.folder) }}</span>
|
<span>{{ calcPath(item.folder) }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'template'), 'is-clickable': true }"
|
<p
|
||||||
v-if="item.template" @click="toggleExpand(item.id, 'template')"
|
:class="{
|
||||||
:title="!isExpanded(item.id, 'template') ? 'Click to expand' : 'Click to collapse'">
|
'is-text-overflow': !isExpanded(item.id, 'template'),
|
||||||
|
'is-clickable': true,
|
||||||
|
}"
|
||||||
|
v-if="item.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>
|
<span class="icon"><i class="fa-solid fa-file" /></span>
|
||||||
<span>{{ item.template }}</span>
|
<span>{{ item.template }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'cli'), 'is-clickable': true }" v-if="item.cli"
|
<p
|
||||||
|
:class="{
|
||||||
|
'is-text-overflow': !isExpanded(item.id, 'cli'),
|
||||||
|
'is-clickable': true,
|
||||||
|
}"
|
||||||
|
v-if="item.cli"
|
||||||
@click="toggleExpand(item.id, 'cli')"
|
@click="toggleExpand(item.id, 'cli')"
|
||||||
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'">
|
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>{{ item.cli }}</span>
|
<span>{{ item.cli }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'description'), 'is-clickable': true }"
|
<p
|
||||||
v-if="item.description" @click="toggleExpand(item.id, 'description')"
|
:class="{
|
||||||
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'">
|
'is-text-overflow': !isExpanded(item.id, 'description'),
|
||||||
|
'is-clickable': true,
|
||||||
|
}"
|
||||||
|
v-if="item.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>
|
<span class="icon"><i class="fa-solid fa-d" /></span>
|
||||||
<span>{{ item.description }}</span>
|
<span>{{ item.description }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content content m-1 p-1 is-overflow-auto" style="max-height: 300px;"
|
<div
|
||||||
v-if="item?.toggle_description">
|
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 class="is-pre-wrap">{{ item.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
|
|
@ -218,15 +305,26 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline"
|
<div
|
||||||
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)">
|
class="columns is-multiline"
|
||||||
|
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)"
|
||||||
|
>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||||
Loading data. Please wait...
|
Loading data. Please wait...
|
||||||
</Message>
|
</Message>
|
||||||
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true"
|
<Message
|
||||||
@close="query = ''">
|
title="No Results"
|
||||||
<p>No results found for the query: <code>{{ query }}</code>.</p>
|
class="is-warning"
|
||||||
|
icon="fas fa-search"
|
||||||
|
v-else-if="query"
|
||||||
|
:useClose="true"
|
||||||
|
@close="query = ''"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No results found for the query: <code>{{ query }}</code
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
<p>Please try a different search term.</p>
|
<p>Please try a different search term.</p>
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
|
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
|
||||||
|
|
@ -239,8 +337,8 @@
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message class="is-info">
|
<Message class="is-info">
|
||||||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||||
When you <b>export</b> preset, it doesn't include the <code>cookies</code> field contents for security
|
When you <b>export</b> preset, it doesn't include the <code>cookies</code> field
|
||||||
reasons.
|
contents for security reasons.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -249,142 +347,145 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { 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 };
|
||||||
|
|
||||||
const presetsStore = usePresets()
|
const presetsStore = usePresets();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const box = useConfirm()
|
const box = useConfirm();
|
||||||
|
|
||||||
const display_style = useStorage<string>('preset_display_style', 'cards')
|
const display_style = useStorage<string>('preset_display_style', 'cards');
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||||
|
|
||||||
const query = ref<string>('')
|
const query = ref<string>('');
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false);
|
||||||
|
|
||||||
const presets = presetsStore.presets as Ref<PresetWithUI[]>
|
const presets = presetsStore.presets as Ref<PresetWithUI[]>;
|
||||||
const preset = ref<Partial<Preset>>({})
|
const preset = ref<Partial<Preset>>({});
|
||||||
const presetRef = ref<number | null>(null)
|
const presetRef = ref<number | null>(null);
|
||||||
const toggleForm = ref(false)
|
const toggleForm = ref(false);
|
||||||
const isLoading = presetsStore.isLoading
|
const isLoading = presetsStore.isLoading;
|
||||||
const addInProgress = presetsStore.addInProgress
|
const addInProgress = presetsStore.addInProgress;
|
||||||
const remove_keys = ['raw', 'toggle_description']
|
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((t) => !t.default));
|
||||||
|
|
||||||
const filteredPresets = computed<PresetWithUI[]>(() => {
|
const filteredPresets = computed<PresetWithUI[]>(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const q = query.value?.toLowerCase();
|
||||||
if (!q) return presetsNoDefault.value;
|
if (!q) return presetsNoDefault.value;
|
||||||
return presetsNoDefault.value.filter((item: PresetWithUI) => deepIncludes(item, q, new WeakSet()));
|
return presetsNoDefault.value.filter((item: PresetWithUI) =>
|
||||||
|
deepIncludes(item, q, new WeakSet()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleExpand = (itemId: number | string | undefined, field: string) => {
|
const toggleExpand = (itemId: number | string | undefined, field: string) => {
|
||||||
if (itemId === undefined || itemId === null) return
|
if (itemId === undefined || itemId === null) return;
|
||||||
const key = String(itemId)
|
const key = String(itemId);
|
||||||
|
|
||||||
if (!expandedItems.value[key]) {
|
if (!expandedItems.value[key]) {
|
||||||
expandedItems.value[key] = new Set()
|
expandedItems.value[key] = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expandedItems.value[key].has(field)) {
|
if (expandedItems.value[key].has(field)) {
|
||||||
expandedItems.value[key].delete(field)
|
expandedItems.value[key].delete(field);
|
||||||
} else {
|
} else {
|
||||||
expandedItems.value[key].add(field)
|
expandedItems.value[key].add(field);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
|
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
|
||||||
if (itemId === undefined || itemId === null) return false
|
if (itemId === undefined || itemId === null) return false;
|
||||||
const key = String(itemId)
|
const key = String(itemId);
|
||||||
return expandedItems.value[key]?.has(field) ?? false
|
return expandedItems.value[key]?.has(field) ?? false;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
watch(toggleFilter, (val) => {
|
watch(toggleFilter, (val) => {
|
||||||
if (!val) {
|
if (!val) {
|
||||||
query.value = ''
|
query.value = '';
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const reloadContent = async () => {
|
const reloadContent = async () => {
|
||||||
await presetsStore.loadPresets(1, 1000)
|
await presetsStore.loadPresets(1, 1000);
|
||||||
}
|
};
|
||||||
|
|
||||||
const resetForm = (closeForm = false) => {
|
const resetForm = (closeForm = false) => {
|
||||||
preset.value = {}
|
preset.value = {};
|
||||||
presetRef.value = null
|
presetRef.value = null;
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false
|
toggleForm.value = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteItem = async (item: Preset) => {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.id) {
|
if (item.id) {
|
||||||
await presetsStore.deletePreset(item.id)
|
await presetsStore.deletePreset(item.id);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateItem = async ({
|
const updateItem = async ({
|
||||||
reference,
|
reference,
|
||||||
preset: item,
|
preset: item,
|
||||||
}: {
|
}: {
|
||||||
reference: number | null
|
reference: number | null;
|
||||||
preset: Preset
|
preset: Preset;
|
||||||
}) => {
|
}) => {
|
||||||
item = cleanObject(item, remove_keys) as Preset
|
item = cleanObject(item, remove_keys) as Preset;
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const updated = await presetsStore.updatePreset(reference, item)
|
const updated = await presetsStore.updatePreset(reference, item);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
resetForm(true)
|
resetForm(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const created = await presetsStore.createPreset(item)
|
const created = await presetsStore.createPreset(item);
|
||||||
if (created) {
|
if (created) {
|
||||||
resetForm(true)
|
resetForm(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const filterItem = (item: Preset) => {
|
const filterItem = (item: Preset) => {
|
||||||
const rest = cleanObject(item, remove_keys)
|
const rest = cleanObject(item, remove_keys);
|
||||||
if ('default' in rest) {
|
if ('default' in rest) {
|
||||||
delete rest.default
|
delete rest.default;
|
||||||
}
|
}
|
||||||
return JSON.stringify(rest, null, 2)
|
return JSON.stringify(rest, null, 2);
|
||||||
}
|
};
|
||||||
|
|
||||||
const editItem = (item: Preset) => {
|
const editItem = (item: Preset) => {
|
||||||
preset.value = JSON.parse(filterItem(item))
|
preset.value = JSON.parse(filterItem(item));
|
||||||
presetRef.value = item.id ?? null
|
presetRef.value = item.id ?? null;
|
||||||
toggleForm.value = true
|
toggleForm.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
onMounted(async () => await reloadContent())
|
onMounted(async () => await reloadContent());
|
||||||
|
|
||||||
const exportItem = (item: Preset) => {
|
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(([key, value]) => !excludedKeys.includes(key) && value)
|
Object.entries(JSON.parse(JSON.stringify(item))).filter(
|
||||||
)
|
([key, value]) => !excludedKeys.includes(key) && value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
userData['_type'] = 'preset'
|
userData['_type'] = 'preset';
|
||||||
userData['_version'] = '2.6'
|
userData['_version'] = '2.6';
|
||||||
|
|
||||||
copyText(encode(userData))
|
copyText(encode(userData));
|
||||||
}
|
};
|
||||||
|
|
||||||
const calcPath = (path?: string): string => {
|
const calcPath = (path?: string): string => {
|
||||||
const loc = config.app.download_path || '/downloads'
|
const loc = config.app.download_path || '/downloads';
|
||||||
return path ? loc + '/' + sTrim(path, '/') : loc
|
return path ? loc + '/' + sTrim(path, '/') : loc;
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template></template>
|
<template></template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', true)
|
const simpleMode = useStorage<boolean>('simple_mode', true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
simpleMode.value = true
|
simpleMode.value = true;
|
||||||
await nextTick()
|
await nextTick();
|
||||||
await navigateTo('/')
|
await navigateTo('/');
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -16,27 +16,37 @@
|
||||||
</span>
|
</span>
|
||||||
<div class="is-pulled-right" v-if="!isEditorOpen">
|
<div class="is-pulled-right" v-if="!isEditorOpen">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()">
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
@click="isEditorOpen ? closeEditor() : openCreate()"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Definition</span>
|
<span v-if="!isMobile">New Definition</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button @click="() => inspect = true" class="button is-warning">
|
<button @click="() => (inspect = true)" class="button is-warning">
|
||||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||||
<span v-if="!isMobile">Inspect</span>
|
<span v-if="!isMobile">Inspect</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
<button
|
||||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
v-tooltip.bottom="'Change display style'"
|
||||||
|
class="button has-tooltip-bottom"
|
||||||
|
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid"
|
<i
|
||||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
class="fa-solid"
|
||||||
|
:class="{
|
||||||
|
'fa-table': display_style !== 'list',
|
||||||
|
'fa-table-list': display_style === 'list',
|
||||||
|
}"
|
||||||
|
/></span>
|
||||||
<span v-if="!isMobile">
|
<span v-if="!isMobile">
|
||||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -44,8 +54,11 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="async () => await loadDefinitions()"
|
<button
|
||||||
:class="{ 'is-loading': isLoading }">
|
class="button is-info"
|
||||||
|
@click="async () => await loadDefinitions()"
|
||||||
|
:class="{ 'is-loading': isLoading }"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -63,8 +76,10 @@
|
||||||
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
|
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
<table
|
||||||
style="min-width: 850px; table-layout: fixed;">
|
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
|
style="min-width: 850px; table-layout: fixed"
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="has-text-centered is-unselectable">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<th width="40%">Name</th>
|
<th width="40%">Name</th>
|
||||||
|
|
@ -76,13 +91,25 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="definition in definitions" :key="definition.id">
|
<tr v-for="definition in definitions" :key="definition.id">
|
||||||
<td class="is-vcentered">
|
<td class="is-vcentered">
|
||||||
<div class="is-text-overflow">{{ definition.name || '(Unnamed definition)' }}</div>
|
<div class="is-text-overflow">
|
||||||
|
{{ definition.name || '(Unnamed definition)' }}
|
||||||
|
</div>
|
||||||
<div class="is-size-7">
|
<div class="is-size-7">
|
||||||
<span class="icon-text is-clickable" @click="toggle(definition)"
|
<span
|
||||||
v-tooltip="'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'">
|
class="icon-text is-clickable"
|
||||||
|
@click="toggle(definition)"
|
||||||
|
v-tooltip="
|
||||||
|
'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'
|
||||||
|
"
|
||||||
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fa-solid fa-power-off"
|
<i
|
||||||
:class="{ 'has-text-success': definition.enabled, 'has-text-danger': !definition.enabled }" />
|
class="fa-solid fa-power-off"
|
||||||
|
:class="{
|
||||||
|
'has-text-success': definition.enabled,
|
||||||
|
'has-text-danger': !definition.enabled,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -90,26 +117,41 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
||||||
<td class="is-vcentered has-text-centered">
|
<td class="is-vcentered has-text-centered">
|
||||||
<span class="has-tooltip" :date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
<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-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-rtime="definition.updated_at" />
|
v-rtime="definition.updated_at"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-small is-info" type="button" @click="exportDefinition(definition)">
|
<button
|
||||||
|
class="button is-small is-info"
|
||||||
|
type="button"
|
||||||
|
@click="exportDefinition(definition)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
<span v-if="!isMobile">Export</span>
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-small is-warning" type="button" @click="openEdit(definition)">
|
<button
|
||||||
|
class="button is-small is-warning"
|
||||||
|
type="button"
|
||||||
|
@click="openEdit(definition)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
<span v-if="!isMobile">Edit</span>
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-small is-danger" type="button" @click="remove(definition)">
|
<button
|
||||||
|
class="button is-small is-danger"
|
||||||
|
type="button"
|
||||||
|
@click="remove(definition)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span v-if="!isMobile">Delete</span>
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -123,7 +165,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen">
|
<div
|
||||||
|
class="columns is-multiline"
|
||||||
|
v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen"
|
||||||
|
>
|
||||||
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
|
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
|
|
@ -133,13 +178,22 @@
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control" @click="toggle(definition)">
|
<div class="control" @click="toggle(definition)">
|
||||||
<span class="icon" :class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
|
<span
|
||||||
v-tooltip="`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`">
|
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" />
|
<i class="fa-solid fa-power-off" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="has-text-info" v-tooltip="'Export'" @click="exportDefinition(definition)">
|
<button
|
||||||
|
class="has-text-info"
|
||||||
|
v-tooltip="'Export'"
|
||||||
|
@click="exportDefinition(definition)"
|
||||||
|
>
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,10 +211,14 @@
|
||||||
<p>
|
<p>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
||||||
<span>Updated: <span class="has-tooltip"
|
<span
|
||||||
|
>Updated:
|
||||||
|
<span
|
||||||
|
class="has-tooltip"
|
||||||
: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-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-rtime="definition.updated_at" />
|
v-rtime="definition.updated_at"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -193,47 +251,58 @@
|
||||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||||
Loading data. Please wait...
|
Loading data. Please wait...
|
||||||
</Message>
|
</Message>
|
||||||
<Message v-if="!isLoading && !isEditorOpen" title="No definitions" class="is-warning"
|
<Message
|
||||||
icon="fas fa-exclamation-circle">
|
v-if="!isLoading && !isEditorOpen"
|
||||||
There are no task definitions. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New
|
title="No definitions"
|
||||||
Definition</strong> button to create your first task definition.
|
class="is-warning"
|
||||||
|
icon="fas fa-exclamation-circle"
|
||||||
|
>
|
||||||
|
There are no task definitions. Click the
|
||||||
|
<span class="icon"><i class="fas fa-add" /></span> <strong>New Definition</strong> button
|
||||||
|
to create your first task definition.
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns" v-if="isEditorOpen">
|
<div class="columns" v-if="isEditorOpen">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition"
|
<TaskDefinitionEditor
|
||||||
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading"
|
:title="editorTitle"
|
||||||
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor"
|
:document="workingDefinition"
|
||||||
@import-existing="importExistingDefinition" />
|
:initial-show-import="'create' === editorMode"
|
||||||
|
:available-definitions="definitions"
|
||||||
|
:loading="editorLoading"
|
||||||
|
:submitting="editorSubmitting"
|
||||||
|
@submit="submitDefinition"
|
||||||
|
@cancel="closeEditor"
|
||||||
|
@import-existing="importExistingDefinition"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal v-if="inspect" @close="() => inspect = false" :contentClass="`modal-content-max`">
|
<Modal v-if="inspect" @close="() => (inspect = false)" :contentClass="`modal-content-max`">
|
||||||
<TaskInspect />
|
<TaskInspect />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</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 } from 'vue';
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
|
|
||||||
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue'
|
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 { useNotification } from '~/composables/useNotification';
|
||||||
import { copyText, encode } from '~/utils'
|
import { copyText, encode } from '~/utils';
|
||||||
import { useMediaQuery } from '~/composables/useMediaQuery'
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TaskDefinitionDetailed,
|
TaskDefinitionDetailed,
|
||||||
TaskDefinitionDocument,
|
TaskDefinitionDocument,
|
||||||
TaskDefinitionSummary,
|
TaskDefinitionSummary,
|
||||||
} from '~/types/task_definitions'
|
} from '~/types/task_definitions';
|
||||||
|
|
||||||
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
||||||
name: 'New Definition',
|
name: 'New Definition',
|
||||||
|
|
@ -252,74 +321,74 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
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 loadDefinitions = taskDefs.loadDefinitions
|
const loadDefinitions = taskDefs.loadDefinitions;
|
||||||
const getDefinition = taskDefs.getDefinition
|
const getDefinition = taskDefs.getDefinition;
|
||||||
const createDefinition = taskDefs.createDefinition
|
const createDefinition = taskDefs.createDefinition;
|
||||||
const updateDefinition = taskDefs.updateDefinition
|
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(() => definitionsRef.value);
|
||||||
|
|
||||||
const { confirmDialog } = useDialog()
|
const { confirmDialog } = useDialog();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
const isEditorOpen = ref<boolean>(false)
|
const isEditorOpen = ref<boolean>(false);
|
||||||
const editorMode = ref<'create' | 'edit'>('create')
|
const editorMode = ref<'create' | 'edit'>('create');
|
||||||
const editorLoading = ref<boolean>(false)
|
const editorLoading = ref<boolean>(false);
|
||||||
const editorSubmitting = ref<boolean>(false)
|
const editorSubmitting = ref<boolean>(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<boolean>(false);
|
||||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid')
|
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
||||||
|
|
||||||
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
||||||
if ('edit' !== editorMode.value || !workingId.value) {
|
if ('edit' !== editorMode.value || !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<string>(() => {
|
||||||
return 'create' === editorMode.value
|
return 'create' === editorMode.value
|
||||||
? 'Create Task Definition'
|
? 'Create Task Definition'
|
||||||
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`
|
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
|
||||||
})
|
});
|
||||||
|
|
||||||
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 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
|
isEditorOpen.value = true;
|
||||||
editorLoading.value = false
|
editorLoading.value = false;
|
||||||
editorSubmitting.value = false
|
editorSubmitting.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
editorMode.value = 'edit'
|
editorMode.value = 'edit';
|
||||||
workingId.value = summary.id
|
workingId.value = summary.id;
|
||||||
workingDefinition.value = null
|
workingDefinition.value = null;
|
||||||
editorLoading.value = true
|
editorLoading.value = true;
|
||||||
editorSubmitting.value = false
|
editorSubmitting.value = false;
|
||||||
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
|
isEditorOpen.value = false;
|
||||||
editorLoading.value = false
|
editorLoading.value = false;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document: TaskDefinitionDocument = {
|
const document: TaskDefinitionDocument = {
|
||||||
|
|
@ -328,17 +397,17 @@ const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
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
|
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.')
|
toast.error('Failed to load task definition for import.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document: TaskDefinitionDocument = {
|
const document: TaskDefinitionDocument = {
|
||||||
|
|
@ -347,89 +416,89 @@ const importExistingDefinition = async (id: number): Promise<void> => {
|
||||||
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)),
|
||||||
}
|
};
|
||||||
|
|
||||||
editorMode.value = 'create'
|
editorMode.value = 'create';
|
||||||
workingId.value = null
|
workingId.value = null;
|
||||||
workingDefinition.value = document
|
workingDefinition.value = document;
|
||||||
isEditorOpen.value = true
|
isEditorOpen.value = true;
|
||||||
editorLoading.value = false
|
editorLoading.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const closeEditor = (): void => {
|
const closeEditor = (): void => {
|
||||||
if (editorSubmitting.value) {
|
if (editorSubmitting.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isEditorOpen.value = false
|
isEditorOpen.value = false;
|
||||||
workingDefinition.value = null
|
workingDefinition.value = null;
|
||||||
workingId.value = null
|
workingId.value = null;
|
||||||
editorLoading.value = false
|
editorLoading.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
||||||
editorSubmitting.value = true
|
editorSubmitting.value = true;
|
||||||
try {
|
try {
|
||||||
if ('create' === editorMode.value) {
|
if ('create' === editorMode.value) {
|
||||||
const created = await createDefinition(definition)
|
const created = await createDefinition(definition);
|
||||||
if (created) {
|
if (created) {
|
||||||
isEditorOpen.value = false
|
isEditorOpen.value = false;
|
||||||
workingDefinition.value = null
|
workingDefinition.value = null;
|
||||||
workingId.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
|
isEditorOpen.value = false;
|
||||||
workingDefinition.value = null
|
workingDefinition.value = null;
|
||||||
workingId.value = null
|
workingId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
editorSubmitting.value = false;
|
||||||
}
|
}
|
||||||
finally {
|
};
|
||||||
editorSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 'is-danger',
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!result.status) {
|
if (!result.status) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDefinition(summary.id)
|
await deleteDefinition(summary.id);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
await toggleEnabled(summary.id, !summary.enabled)
|
await toggleEnabled(summary.id, !summary.enabled);
|
||||||
}
|
};
|
||||||
|
|
||||||
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
const detailed = await getDefinition(summary.id)
|
const detailed = await getDefinition(summary.id);
|
||||||
if (!detailed) {
|
if (!detailed) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return copyText(encode({
|
return copyText(
|
||||||
_type: 'task_definition',
|
encode({
|
||||||
_version: '2.0',
|
_type: 'task_definition',
|
||||||
name: detailed.name,
|
_version: '2.0',
|
||||||
priority: detailed.priority,
|
name: detailed.name,
|
||||||
enabled: detailed.enabled,
|
priority: detailed.priority,
|
||||||
match_url: detailed.match_url,
|
enabled: detailed.enabled,
|
||||||
definition: detailed.definition,
|
match_url: detailed.match_url,
|
||||||
}))
|
definition: detailed.definition,
|
||||||
}
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!definitions.value.length) {
|
if (!definitions.value.length) {
|
||||||
await loadDefinitions()
|
await loadDefinitions();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ const on_mounted = (el: HTMLElement) => {
|
||||||
|
|
||||||
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
|
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
scrolledToBottom = (el.scrollHeight - el.scrollTop) - el.clientHeight <= 80;
|
scrolledToBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 80;
|
||||||
};
|
};
|
||||||
events_handlers[index] = handler;
|
events_handlers[index] = handler;
|
||||||
el.addEventListener(ev as string, handler, { passive: true } as AddEventListenerOptions);
|
el.addEventListener(ev as string, handler, { passive: true } as AddEventListenerOptions);
|
||||||
|
|
@ -19,7 +19,7 @@ const on_mounted = (el: HTMLElement) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(el, { childList: true, subtree: true });
|
observer.observe(el, { childList: true, subtree: true });
|
||||||
}
|
};
|
||||||
const on_unmounted = (el: HTMLElement) => {
|
const on_unmounted = (el: HTMLElement) => {
|
||||||
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
|
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
|
||||||
const handler = events_handlers[index];
|
const handler = events_handlers[index];
|
||||||
|
|
@ -28,9 +28,9 @@ const on_unmounted = (el: HTMLElement) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
};
|
||||||
|
|
||||||
export default defineNuxtPlugin(nuxtApp => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
nuxtApp.vueApp.directive('autoscroll', {
|
nuxtApp.vueApp.directive('autoscroll', {
|
||||||
mounted: on_mounted,
|
mounted: on_mounted,
|
||||||
unmounted: on_unmounted,
|
unmounted: on_unmounted,
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,73 @@
|
||||||
import moment from 'moment'
|
import moment from 'moment';
|
||||||
|
|
||||||
type RTimeElement = HTMLElement & { _next_timer?: number }
|
type RTimeElement = HTMLElement & { _next_timer?: number };
|
||||||
|
|
||||||
const parseInterval = (arg: string | undefined): number => {
|
const parseInterval = (arg: string | undefined): number => {
|
||||||
if (!arg) {
|
if (!arg) {
|
||||||
return 60 * 1000
|
return 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = arg.match(/^(\d+)([smhd])$/)
|
const match = arg.match(/^(\d+)([smhd])$/);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return 60 * 1000
|
return 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, numStr, unit] = match
|
const [, numStr, unit] = match;
|
||||||
const num = parseInt(String(numStr), 10)
|
const num = parseInt(String(numStr), 10);
|
||||||
|
|
||||||
switch (unit) {
|
switch (unit) {
|
||||||
case 'd': return num * 24 * 3600 * 1000
|
case 'd':
|
||||||
case 'h': return num * 3600 * 1000
|
return num * 24 * 3600 * 1000;
|
||||||
case 'm': return num * 60 * 1000
|
case 'h':
|
||||||
case 's': return num * 1000
|
return num * 3600 * 1000;
|
||||||
default: return 60 * 1000
|
case 'm':
|
||||||
|
return num * 60 * 1000;
|
||||||
|
case 's':
|
||||||
|
return num * 1000;
|
||||||
|
default:
|
||||||
|
return 60 * 1000;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default defineNuxtPlugin(nuxtApp => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
nuxtApp.vueApp.directive('rtime', {
|
nuxtApp.vueApp.directive('rtime', {
|
||||||
mounted(el: RTimeElement, binding) {
|
mounted(el: RTimeElement, binding) {
|
||||||
const intervalMs = parseInterval(binding.arg)
|
const intervalMs = parseInterval(binding.arg);
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const val = binding.value
|
const val = binding.value;
|
||||||
if (Number.isFinite(val)) {
|
if (Number.isFinite(val)) {
|
||||||
el.textContent = moment.unix(val as number).fromNow()
|
el.textContent = moment.unix(val as number).fromNow();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
el.textContent = moment(val).fromNow()
|
el.textContent = moment(val).fromNow();
|
||||||
}
|
};
|
||||||
|
|
||||||
update()
|
update();
|
||||||
el._next_timer = window.setInterval(update, intervalMs)
|
el._next_timer = window.setInterval(update, intervalMs);
|
||||||
},
|
},
|
||||||
updated(el: RTimeElement, binding) {
|
updated(el: RTimeElement, binding) {
|
||||||
if (binding.oldValue !== binding.value) {
|
if (binding.oldValue !== binding.value) {
|
||||||
if (null != el._next_timer) clearInterval(el._next_timer)
|
if (null != el._next_timer) clearInterval(el._next_timer);
|
||||||
|
|
||||||
const intervalMs = parseInterval(binding.arg)
|
const intervalMs = parseInterval(binding.arg);
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const val = binding.value
|
const val = binding.value;
|
||||||
if (Number.isFinite(val)) {
|
if (Number.isFinite(val)) {
|
||||||
el.textContent = moment.unix(val as number).fromNow()
|
el.textContent = moment.unix(val as number).fromNow();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
el.textContent = moment(val).fromNow()
|
el.textContent = moment(val).fromNow();
|
||||||
}
|
};
|
||||||
|
|
||||||
update()
|
update();
|
||||||
el._next_timer = window.setInterval(update, intervalMs)
|
el._next_timer = window.setInterval(update, intervalMs);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeUnmount(el: RTimeElement) {
|
beforeUnmount(el: RTimeElement) {
|
||||||
if (null != el._next_timer) clearInterval(el._next_timer)
|
if (null != el._next_timer) clearInterval(el._next_timer);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {}
|
return {};
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { defineNuxtPlugin } from '#app'
|
import { defineNuxtPlugin } from '#app';
|
||||||
import Toast, { type PluginOptions } from 'vue-toastification'
|
import Toast, { type PluginOptions } from 'vue-toastification';
|
||||||
import 'vue-toastification/dist/index.css'
|
import 'vue-toastification/dist/index.css';
|
||||||
|
|
||||||
export default defineNuxtPlugin(nuxtApp => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
nuxtApp.vueApp.use(Toast, {
|
nuxtApp.vueApp.use(Toast, {
|
||||||
transition: "Vue-Toastification__bounce",
|
transition: 'Vue-Toastification__bounce',
|
||||||
//position: "bottom-right",
|
//position: "bottom-right",
|
||||||
maxToasts: 5,
|
maxToasts: 5,
|
||||||
newestOnTop: true,
|
newestOnTop: true,
|
||||||
} as PluginOptions)
|
} as PluginOptions);
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,138 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
|
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>YTPTube Loading...</title>
|
<title>YTPTube Loading...</title>
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html,
|
||||||
margin: 0;
|
body {
|
||||||
padding: 0;
|
margin: 0;
|
||||||
height: 100%;
|
padding: 0;
|
||||||
display: flex;
|
height: 100%;
|
||||||
align-items: center;
|
display: flex;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
background-color: #fff;
|
justify-content: center;
|
||||||
color: #111;
|
background-color: #fff;
|
||||||
user-select: none;
|
color: #111;
|
||||||
overflow: hidden;
|
user-select: none;
|
||||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
overflow: hidden;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
}
|
transition:
|
||||||
@media (prefers-color-scheme: dark) {
|
background-color 0.3s ease,
|
||||||
html, body {
|
color 0.3s ease;
|
||||||
background-color: #121212;
|
}
|
||||||
color: #eee;
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.loader-container {
|
.loader-container {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.ytptube-loading {
|
|
||||||
width: 420px;
|
|
||||||
height: 105px;
|
|
||||||
stroke: #FF0000;
|
|
||||||
stroke-width: 4.5px;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
fill: none;
|
|
||||||
stroke-dasharray: 700;
|
|
||||||
stroke-dashoffset: 700;
|
|
||||||
animation: stroke-move 3s linear infinite;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
svg.ytptube-loading {
|
svg.ytptube-loading {
|
||||||
stroke: #FF4444;
|
width: 420px;
|
||||||
|
height: 105px;
|
||||||
|
stroke: #ff0000;
|
||||||
|
stroke-width: 4.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
fill: none;
|
||||||
|
stroke-dasharray: 700;
|
||||||
|
stroke-dashoffset: 700;
|
||||||
|
animation: stroke-move 3s linear infinite;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stroke-move {
|
@media (prefers-color-scheme: dark) {
|
||||||
100% {
|
svg.ytptube-loading {
|
||||||
stroke-dashoffset: -700;
|
stroke: #ff4444;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
@keyframes stroke-move {
|
||||||
margin: 20px auto 0;
|
100% {
|
||||||
width: 64px;
|
stroke-dashoffset: -700;
|
||||||
height: 64px;
|
}
|
||||||
border: 8px solid transparent;
|
}
|
||||||
border-top-color: #FF0000;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1.2s linear infinite;
|
|
||||||
filter: drop-shadow(0 0 6px rgba(255,0,0,0.8));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.spinner {
|
.spinner {
|
||||||
border-top-color: #FF4444;
|
margin: 20px auto 0;
|
||||||
filter: drop-shadow(0 0 8px #FF4444);
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-top-color: #ff0000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@media (prefers-color-scheme: dark) {
|
||||||
to { transform: rotate(360deg); }
|
.spinner {
|
||||||
}
|
border-top-color: #ff4444;
|
||||||
|
filter: drop-shadow(0 0 8px #ff4444);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
@keyframes spin {
|
||||||
margin-top: 14px;
|
to {
|
||||||
font-size: 1rem;
|
transform: rotate(360deg);
|
||||||
color: inherit;
|
}
|
||||||
opacity: 0.8;
|
}
|
||||||
user-select: none;
|
|
||||||
font-weight: 600;
|
.subtitle {
|
||||||
letter-spacing: 0.1em;
|
margin-top: 14px;
|
||||||
}
|
font-size: 1rem;
|
||||||
</style>
|
color: inherit;
|
||||||
</head>
|
opacity: 0.8;
|
||||||
<body>
|
user-select: none;
|
||||||
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
font-weight: 600;
|
||||||
<svg class="ytptube-loading" viewBox="0 0 420 105" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
letter-spacing: 0.1em;
|
||||||
<!-- Y -->
|
}
|
||||||
<path d="M10 10 L25 37.5 L10 65" />
|
</style>
|
||||||
<path d="M25 37.5 L40 10" />
|
</head>
|
||||||
<!-- T -->
|
<body>
|
||||||
<path d="M50 10 L90 10" />
|
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
||||||
<path d="M70 10 L70 65" />
|
<svg
|
||||||
<!-- P -->
|
class="ytptube-loading"
|
||||||
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
viewBox="0 0 420 105"
|
||||||
<!-- T -->
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M160 10 L200 10" />
|
aria-hidden="true"
|
||||||
<path d="M180 10 L180 65" />
|
focusable="false"
|
||||||
<!-- U -->
|
>
|
||||||
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
<!-- Y -->
|
||||||
<!-- B -->
|
<path d="M10 10 L25 37.5 L10 65" />
|
||||||
<path d="M270 10 L270 65" />
|
<path d="M25 37.5 L40 10" />
|
||||||
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
<!-- T -->
|
||||||
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
<path d="M50 10 L90 10" />
|
||||||
<!-- E -->
|
<path d="M70 10 L70 65" />
|
||||||
<path d="M320 10 L320 65" />
|
<!-- P -->
|
||||||
<path d="M320 10 L360 10" />
|
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
||||||
<path d="M320 37.5 L350 37.5" />
|
<!-- T -->
|
||||||
<path d="M320 65 L360 65" />
|
<path d="M160 10 L200 10" />
|
||||||
</svg>
|
<path d="M180 10 L180 65" />
|
||||||
<div class="spinner"></div>
|
<!-- U -->
|
||||||
<div class="subtitle">Loading, please wait...</div>
|
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
||||||
</div>
|
<!-- B -->
|
||||||
</body>
|
<path d="M270 10 L270 65" />
|
||||||
|
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
||||||
|
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
||||||
|
<!-- E -->
|
||||||
|
<path d="M320 10 L320 65" />
|
||||||
|
<path d="M320 10 L360 10" />
|
||||||
|
<path d="M320 37.5 L350 37.5" />
|
||||||
|
<path d="M320 65 L360 65" />
|
||||||
|
</svg>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="subtitle">Loading, please wait...</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core';
|
||||||
import type { ConfigState } from '~/types/config';
|
import type { ConfigState } from '~/types/config';
|
||||||
import type { DLField } from '~/types/dl_fields';
|
import type { DLField } from '~/types/dl_fields';
|
||||||
import type { Preset } from '~/types/presets';
|
import type { Preset } from '~/types/presets';
|
||||||
|
|
@ -39,15 +39,15 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
{
|
{
|
||||||
'name': 'default',
|
name: 'default',
|
||||||
'description': 'Default preset',
|
description: 'Default preset',
|
||||||
'folder': '',
|
folder: '',
|
||||||
'template': '',
|
template: '',
|
||||||
'cookies': '',
|
cookies: '',
|
||||||
'cli': '',
|
cli: '',
|
||||||
'default': true,
|
default: true,
|
||||||
'priority': 0
|
priority: 0,
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
dl_fields: [],
|
dl_fields: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
|
|
@ -61,12 +61,12 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
if (state.is_loading) {
|
if (state.is_loading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
|
|
||||||
if (state.is_loaded && !force && last_reload > 0) {
|
if (state.is_loaded && !force && last_reload > 0) {
|
||||||
const age = (now - last_reload) / 1000
|
const age = (now - last_reload) / 1000;
|
||||||
if (age < CONFIG_TTL) {
|
if (age < CONFIG_TTL) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,126 +95,133 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
last_reload = now;
|
last_reload = now;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Failed to load configuration: ${e}`);
|
console.error(`Failed to load configuration: ${e}`);
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
state.is_loading = false;
|
state.is_loading = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const add = (key: string, value: any) => {
|
const add = (key: string, value: any) => {
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
|
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||||
state[parentKey][subKey] = value;
|
state[parentKey][subKey] = value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(state as any)[key] = value
|
(state as any)[key] = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const get = (key: string, defaultValue: any = null): any => {
|
const get = (key: string, defaultValue: any = null): any => {
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
|
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||||
const parent = state[parentKey] as any
|
const parent = state[parentKey] as any;
|
||||||
return parent?.[subKey] ?? defaultValue
|
return parent?.[subKey] ?? defaultValue;
|
||||||
}
|
}
|
||||||
return (state as any)[key] ?? defaultValue
|
return (state as any)[key] ?? defaultValue;
|
||||||
}
|
};
|
||||||
const isLoaded = () => state.is_loaded
|
const isLoaded = () => state.is_loaded;
|
||||||
|
|
||||||
const update = add
|
const update = add;
|
||||||
|
|
||||||
const getAll = (): ConfigState => state
|
const getAll = (): ConfigState => state;
|
||||||
|
|
||||||
const setAll = (data: Record<string, any>) => {
|
const setAll = (data: Record<string, any>) => {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
|
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||||
const parent = state[parentKey] as any
|
const parent = state[parentKey] as any;
|
||||||
parent[subKey] = data[key]
|
parent[subKey] = data[key];
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
(state as any)[key] = data[key]
|
(state as any)[key] = data[key];
|
||||||
})
|
});
|
||||||
|
|
||||||
state.is_loaded = true
|
state.is_loaded = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
|
||||||
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets']
|
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
|
||||||
|
|
||||||
if (!supportedFeatures.includes(feature)) {
|
if (!supportedFeatures.includes(feature)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('presets' === feature) {
|
if ('presets' === feature) {
|
||||||
const item = data as Preset
|
const item = data as Preset;
|
||||||
const current = get(feature, []) as Array<Preset>
|
const current = get(feature, []) as Array<Preset>;
|
||||||
|
|
||||||
if ('create' === action) {
|
if ('create' === action) {
|
||||||
current.push(item)
|
current.push(item);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('delete' === action) {
|
if ('delete' === action) {
|
||||||
const index = current.findIndex(i => i.id === item.id)
|
const index = current.findIndex((i) => i.id === item.id);
|
||||||
if (-1 !== index) {
|
if (-1 !== index) {
|
||||||
current.splice(index, 1)
|
current.splice(index, 1);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('update' === action) {
|
if ('update' === action) {
|
||||||
const target = current.find(i => i.id === item.id)
|
const target = current.find((i) => i.id === item.id);
|
||||||
if (target) {
|
if (target) {
|
||||||
Object.assign(target, item)
|
Object.assign(target, item);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('dl_fields' === feature) {
|
if ('dl_fields' === feature) {
|
||||||
const item = data as DLField
|
const item = data as DLField;
|
||||||
const current = get(feature, []) as Array<DLField>
|
const current = get(feature, []) as Array<DLField>;
|
||||||
|
|
||||||
if ('create' === action) {
|
if ('create' === action) {
|
||||||
current.push(item)
|
current.push(item);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('delete' === action) {
|
if ('delete' === action) {
|
||||||
const index = current.findIndex(i => i.id === item.id)
|
const index = current.findIndex((i) => i.id === item.id);
|
||||||
if (-1 !== index) {
|
if (-1 !== index) {
|
||||||
current.splice(index, 1)
|
current.splice(index, 1);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('update' === action) {
|
if ('update' === action) {
|
||||||
const target = current.find(i => i.id === item.id)
|
const target = current.find((i) => i.id === item.id);
|
||||||
if (target) {
|
if (target) {
|
||||||
Object.assign(target, item)
|
Object.assign(target, item);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if ('replace' === action) {
|
if ('replace' === action) {
|
||||||
state.dl_fields = data as Array<DLField>
|
state.dl_fields = data as Array<DLField>;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state), add, get, update, getAll, setAll, isLoaded, patch, loadConfig
|
...toRefs(state),
|
||||||
|
add,
|
||||||
|
get,
|
||||||
|
update,
|
||||||
|
getAll,
|
||||||
|
setAll,
|
||||||
|
isLoaded,
|
||||||
|
patch,
|
||||||
|
loadConfig,
|
||||||
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
|
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
|
||||||
add: typeof add
|
add: typeof add;
|
||||||
get: typeof get
|
get: typeof get;
|
||||||
update: typeof update
|
update: typeof update;
|
||||||
getAll: typeof getAll
|
getAll: typeof getAll;
|
||||||
setAll: typeof setAll
|
setAll: typeof setAll;
|
||||||
patch: typeof patch
|
patch: typeof patch;
|
||||||
isLoaded: typeof isLoaded
|
isLoaded: typeof isLoaded;
|
||||||
loadConfig: typeof loadConfig
|
loadConfig: typeof loadConfig;
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,83 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia';
|
||||||
import { useStorage } from '@vueuse/core'
|
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: 'is-danger', icon: 'fas fa-triangle-exclamation' },
|
||||||
'warning': { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
|
warning: { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
|
||||||
'success': { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
|
success: { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
|
||||||
'info': { level: 0, color: 'is-info', icon: 'fas fa-circle-info' }
|
info: { level: 0, color: 'is-info', icon: 'fas fa-circle-info' },
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notifications', () => {
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
const notifications = useStorage<notification[]>('notifications', [])
|
const notifications = useStorage<notification[]>('notifications', []);
|
||||||
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||||
|
|
||||||
const severityLevel = computed<notificationType | null>(() => {
|
const severityLevel = computed<notificationType | null>(() => {
|
||||||
const unread = notifications.value.filter(n => !n.seen)
|
const unread = notifications.value.filter((n) => !n.seen);
|
||||||
if (0 === unread.length) {
|
if (0 === unread.length) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return unread.reduce((h, n) => _map[n.level].level > _map[h.level].level ? n : h).level
|
return unread.reduce((h, n) => (_map[n.level].level > _map[h.level].level ? n : h)).level;
|
||||||
})
|
});
|
||||||
|
|
||||||
const severityColor = computed<string>(() => {
|
const severityColor = computed<string>(() => {
|
||||||
const level = severityLevel.value
|
const level = severityLevel.value;
|
||||||
return level ? _map[level].color : ''
|
return level ? _map[level].color : '';
|
||||||
})
|
});
|
||||||
|
|
||||||
const severityIcon = computed<string>(() => {
|
const severityIcon = computed<string>(() => {
|
||||||
const level = severityLevel.value
|
const level = severityLevel.value;
|
||||||
return level ? _map[level].icon : ''
|
return level ? _map[level].icon : '';
|
||||||
})
|
});
|
||||||
|
|
||||||
const sortedNotifications = computed<notification[]>(() => {
|
const sortedNotifications = computed<notification[]>(() => {
|
||||||
return [...notifications.value].sort((a, b) => {
|
return [...notifications.value].sort((a, b) => {
|
||||||
const severityDiff = _map[b.level].level - _map[a.level].level
|
const severityDiff = _map[b.level].level - _map[a.level].level;
|
||||||
if (0 !== severityDiff) {
|
if (0 !== severityDiff) {
|
||||||
return severityDiff
|
return severityDiff;
|
||||||
}
|
}
|
||||||
return (new Date(b.created).getTime()) - (new Date(a.created).getTime())
|
return new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||||
const id = Array.from(
|
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
|
||||||
window.crypto.getRandomValues(new Uint8Array(14 / 2)),
|
dec.toString(16).padStart(2, '0'),
|
||||||
(dec: any) => dec.toString(16).padStart(2, "0")
|
).join('');
|
||||||
).join('')
|
|
||||||
|
|
||||||
notifications.value.unshift({
|
notifications.value.unshift({
|
||||||
id: id,
|
id: id,
|
||||||
message,
|
message,
|
||||||
level,
|
level,
|
||||||
seen: seen,
|
seen: seen,
|
||||||
created: new Date()
|
created: new Date(),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (notifications.value.length > 99) {
|
if (notifications.value.length > 99) {
|
||||||
notifications.value.length = 99
|
notifications.value.length = 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
return id
|
return id;
|
||||||
}
|
};
|
||||||
|
|
||||||
const clear = () => notifications.value = []
|
const clear = () => (notifications.value = []);
|
||||||
const markAllRead = () => notifications.value.forEach(n => n.seen = true)
|
const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
|
||||||
|
|
||||||
const markRead = (id: string) => {
|
const markRead = (id: string) => {
|
||||||
const n = notifications.value.find(n => n.id === id)
|
const n = notifications.value.find((n) => n.id === id);
|
||||||
if (!n) {
|
if (!n) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
n.seen = true
|
n.seen = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const get = (id: string): notification | undefined => notifications.value.find(n => n.id === id)
|
const get = (id: string): notification | undefined =>
|
||||||
|
notifications.value.find((n) => n.id === id);
|
||||||
|
|
||||||
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id)
|
const remove = (id: string) =>
|
||||||
|
(notifications.value = notifications.value.filter((n) => n.id !== id));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications,
|
notifications,
|
||||||
|
|
@ -90,6 +91,6 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||||
markAllRead,
|
markAllRead,
|
||||||
clear,
|
clear,
|
||||||
markRead,
|
markRead,
|
||||||
remove
|
remove,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,383 +1,404 @@
|
||||||
import { ref, readonly } from 'vue'
|
import { ref, readonly } from 'vue';
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia';
|
||||||
import type { StoreItem } from "~/types/store";
|
import type { StoreItem } from '~/types/store';
|
||||||
import type {
|
import type {
|
||||||
ConfigUpdatePayload,
|
ConfigUpdatePayload,
|
||||||
WebSocketClientEmits,
|
WebSocketClientEmits,
|
||||||
WebSocketEnvelope,
|
WebSocketEnvelope,
|
||||||
WSEP as WSEP
|
WSEP as WSEP,
|
||||||
} from "~/types/sockets";
|
} from '~/types/sockets';
|
||||||
|
|
||||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||||
|
|
||||||
type SocketHandler = (...args: unknown[]) => void
|
type SocketHandler = (...args: unknown[]) => void;
|
||||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>
|
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||||
type KnownEvent = keyof WSEP
|
type KnownEvent = keyof WSEP;
|
||||||
|
|
||||||
export const useSocketStore = defineStore('socket', () => {
|
export const useSocketStore = defineStore('socket', () => {
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig();
|
||||||
const config = useConfigStore()
|
const config = useConfigStore();
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
const socket = ref<WebSocket | null>(null)
|
const socket = ref<WebSocket | null>(null);
|
||||||
const isConnected = ref<boolean>(false)
|
const isConnected = ref<boolean>(false);
|
||||||
const connectionStatus = ref<connectionStatus>('disconnected')
|
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null);
|
||||||
const error_count = ref<number>(0)
|
const error_count = ref<number>(0);
|
||||||
const wasHidden = ref<boolean>(false)
|
const wasHidden = ref<boolean>(false);
|
||||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null)
|
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||||
const manualDisconnect = ref<boolean>(false)
|
const manualDisconnect = ref<boolean>(false);
|
||||||
const reconnectAttempts = ref<number>(0)
|
const reconnectAttempts = ref<number>(0);
|
||||||
|
|
||||||
const handlers = new Map<string, HandlerRegistry>()
|
const handlers = new Map<string, HandlerRegistry>();
|
||||||
|
|
||||||
const emit = <K extends keyof WebSocketClientEmits>(event: K, data: WebSocketClientEmits[K]): void => {
|
const emit = <K extends keyof WebSocketClientEmits>(
|
||||||
|
event: K,
|
||||||
|
data: WebSocketClientEmits[K],
|
||||||
|
): void => {
|
||||||
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
socket.value.send(JSON.stringify({ event, data }))
|
socket.value.send(JSON.stringify({ event, data }));
|
||||||
}
|
};
|
||||||
|
|
||||||
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void
|
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void;
|
||||||
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void
|
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void;
|
||||||
function on<K extends KnownEvent>(
|
function on<K extends KnownEvent>(
|
||||||
event: K | K[],
|
event: K | K[],
|
||||||
callback: (event: K, payload: WSEP[K]) => void,
|
callback: (event: K, payload: WSEP[K]) => void,
|
||||||
withEvent: true
|
withEvent: true,
|
||||||
): void
|
): void;
|
||||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void
|
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||||
const events = Array.isArray(event) ? event : [event]
|
const events = Array.isArray(event) ? event : [event];
|
||||||
events.forEach((eventName) => {
|
events.forEach((eventName) => {
|
||||||
if (!handlers.has(eventName)) {
|
if (!handlers.has(eventName)) {
|
||||||
handlers.set(eventName, new Map())
|
handlers.set(eventName, new Map());
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = handlers.get(eventName) as HandlerRegistry
|
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||||
const handler = true === withEvent
|
const handler =
|
||||||
? (payload: unknown) => callback(eventName, payload)
|
true === withEvent
|
||||||
: (payload: unknown) => callback(payload)
|
? (payload: unknown) => callback(eventName, payload)
|
||||||
|
: (payload: unknown) => callback(payload);
|
||||||
|
|
||||||
registry.set(callback, handler)
|
registry.set(callback, handler);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void
|
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void;
|
||||||
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void
|
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void;
|
||||||
function off(event: string | string[], callback?: SocketHandler): void
|
function off(event: string | string[], callback?: SocketHandler): void;
|
||||||
function off(event: string | string[], callback?: SocketHandler): void {
|
function off(event: string | string[], callback?: SocketHandler): void {
|
||||||
const events = Array.isArray(event) ? event : [event]
|
const events = Array.isArray(event) ? event : [event];
|
||||||
events.forEach((eventName) => {
|
events.forEach((eventName) => {
|
||||||
const registry = handlers.get(eventName)
|
const registry = handlers.get(eventName);
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!callback) {
|
if (!callback) {
|
||||||
registry.clear()
|
registry.clear();
|
||||||
handlers.delete(eventName)
|
handlers.delete(eventName);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.delete(callback)
|
registry.delete(callback);
|
||||||
if (0 === registry.size) {
|
if (0 === registry.size) {
|
||||||
handlers.delete(eventName)
|
handlers.delete(eventName);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSessionId = (): string | null => null
|
const getSessionId = (): string | null => null;
|
||||||
|
|
||||||
const dispatch = (eventName: string, payload: unknown): void => {
|
const dispatch = (eventName: string, payload: unknown): void => {
|
||||||
const registry = handlers.get(eventName)
|
const registry = handlers.get(eventName);
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.forEach((handler) => handler(payload))
|
registry.forEach((handler) => handler(payload));
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
wasHidden.value = true
|
wasHidden.value = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === wasHidden.value && false === isConnected.value) {
|
if (true === wasHidden.value && false === isConnected.value) {
|
||||||
if (null !== reconnectTimeout.value) {
|
if (null !== reconnectTimeout.value) {
|
||||||
clearTimeout(reconnectTimeout.value)
|
clearTimeout(reconnectTimeout.value);
|
||||||
reconnectTimeout.value = null
|
reconnectTimeout.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectTimeout.value = setTimeout(() => {
|
reconnectTimeout.value = setTimeout(() => {
|
||||||
if (false === isConnected.value) {
|
if (false === isConnected.value) {
|
||||||
console.debug('[SocketStore] Page visible after background, reconnecting...')
|
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||||
reconnect()
|
reconnect();
|
||||||
}
|
}
|
||||||
reconnectTimeout.value = null
|
reconnectTimeout.value = null;
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
wasHidden.value = false
|
wasHidden.value = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const setupVisibilityListener = () => {
|
const setupVisibilityListener = () => {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const cleanupVisibilityListener = () => {
|
const cleanupVisibilityListener = () => {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
}
|
}
|
||||||
if (null !== reconnectTimeout.value) {
|
if (null !== reconnectTimeout.value) {
|
||||||
clearTimeout(reconnectTimeout.value)
|
clearTimeout(reconnectTimeout.value);
|
||||||
reconnectTimeout.value = null
|
reconnectTimeout.value = null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const scheduleReconnect = () => {
|
const scheduleReconnect = () => {
|
||||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reconnectAttempts.value >= 50) {
|
if (reconnectAttempts.value >= 50) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (null !== reconnectTimeout.value) {
|
if (null !== reconnectTimeout.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectTimeout.value = setTimeout(() => {
|
reconnectTimeout.value = setTimeout(() => {
|
||||||
reconnectAttempts.value += 1
|
reconnectAttempts.value += 1;
|
||||||
reconnectTimeout.value = null
|
reconnectTimeout.value = null;
|
||||||
connect()
|
connect();
|
||||||
}, 5000)
|
}, 5000);
|
||||||
}
|
};
|
||||||
|
|
||||||
const reconnect = () => {
|
const reconnect = () => {
|
||||||
if (true === isConnected.value) {
|
if (true === isConnected.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
connect()
|
connect();
|
||||||
connectionStatus.value = 'connecting'
|
connectionStatus.value = 'connecting';
|
||||||
}
|
};
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
manualDisconnect.value = true
|
manualDisconnect.value = true;
|
||||||
if (null === socket.value) {
|
if (null === socket.value) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
socket.value.close()
|
socket.value.close();
|
||||||
socket.value = null
|
socket.value = null;
|
||||||
isConnected.value = false
|
isConnected.value = false;
|
||||||
connectionStatus.value = 'disconnected'
|
connectionStatus.value = 'disconnected';
|
||||||
cleanupVisibilityListener()
|
cleanupVisibilityListener();
|
||||||
}
|
};
|
||||||
|
|
||||||
const buildWsUrl = (): string => {
|
const buildWsUrl = (): string => {
|
||||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '')
|
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||||
const wsPath = `${basePath}/ws?_=${Date.now()}`
|
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||||
const configuredBase = runtimeConfig.public.wss?.trim()
|
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||||
|
|
||||||
if (configuredBase) {
|
if (configuredBase) {
|
||||||
return new URL(wsPath, configuredBase).toString()
|
return new URL(wsPath, configuredBase).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws'
|
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString()
|
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||||
}
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
manualDisconnect.value = false
|
manualDisconnect.value = false;
|
||||||
connectionStatus.value = 'connecting'
|
connectionStatus.value = 'connecting';
|
||||||
|
|
||||||
socket.value = new WebSocket(buildWsUrl())
|
socket.value = new WebSocket(buildWsUrl());
|
||||||
|
|
||||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||||
window.ws = socket.value
|
window.ws = socket.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.value.addEventListener('open', () => {
|
socket.value.addEventListener('open', () => {
|
||||||
isConnected.value = true
|
isConnected.value = true;
|
||||||
connectionStatus.value = 'connected'
|
connectionStatus.value = 'connected';
|
||||||
error.value = null
|
error.value = null;
|
||||||
error_count.value = 0
|
error_count.value = 0;
|
||||||
reconnectAttempts.value = 0
|
reconnectAttempts.value = 0;
|
||||||
dispatch('connect', null)
|
dispatch('connect', null);
|
||||||
})
|
});
|
||||||
|
|
||||||
socket.value.addEventListener('close', () => {
|
socket.value.addEventListener('close', () => {
|
||||||
isConnected.value = false
|
isConnected.value = false;
|
||||||
connectionStatus.value = 'disconnected'
|
connectionStatus.value = 'disconnected';
|
||||||
error.value = 'Disconnected from server.'
|
error.value = 'Disconnected from server.';
|
||||||
dispatch('disconnect', null)
|
dispatch('disconnect', null);
|
||||||
scheduleReconnect()
|
scheduleReconnect();
|
||||||
})
|
});
|
||||||
|
|
||||||
socket.value.addEventListener('error', () => {
|
socket.value.addEventListener('error', () => {
|
||||||
isConnected.value = false
|
isConnected.value = false;
|
||||||
connectionStatus.value = 'disconnected'
|
connectionStatus.value = 'disconnected';
|
||||||
error.value = 'Connection error: Unknown error'
|
error.value = 'Connection error: Unknown error';
|
||||||
error_count.value += 1
|
error_count.value += 1;
|
||||||
dispatch('connect_error', { message: 'Unknown error' })
|
dispatch('connect_error', { message: 'Unknown error' });
|
||||||
scheduleReconnect()
|
scheduleReconnect();
|
||||||
})
|
});
|
||||||
|
|
||||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
let payload: WebSocketEnvelope | null = null
|
let payload: WebSocketEnvelope | null = null;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data)
|
payload = JSON.parse(event.data);
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!payload?.event || 'string' != typeof payload.event) {
|
if (!payload?.event || 'string' != typeof payload.event) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = payload.data
|
let data = payload.data;
|
||||||
if ('string' === typeof data) {
|
if ('string' === typeof data) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(data)
|
data = JSON.parse(data);
|
||||||
} catch {
|
} catch {
|
||||||
data = payload.data
|
data = payload.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(payload.event, data)
|
dispatch(payload.event, data);
|
||||||
})
|
});
|
||||||
|
|
||||||
setupVisibilityListener()
|
setupVisibilityListener();
|
||||||
}
|
};
|
||||||
|
|
||||||
on('connect', () => config.loadConfig(false))
|
on('connect', () => config.loadConfig(false));
|
||||||
|
|
||||||
on('connected', () => {
|
on('connected', () => {
|
||||||
error.value = null
|
error.value = null;
|
||||||
config.loadConfig(false)
|
config.loadConfig(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
on('item_added', (data: WSEP['item_added']) => {
|
on('item_added', (data: WSEP['item_added']) => {
|
||||||
stateStore.add('queue', data.data._id, data.data)
|
stateStore.add('queue', data.data._id, data.data);
|
||||||
toast.success(`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`)
|
toast.success(
|
||||||
})
|
`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
on(['log_info', 'log_success', 'log_warning', 'log_error'], (event, data: WSEP['log_info']) => {
|
on(
|
||||||
const message = 'string' === typeof data?.message
|
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||||
? data.message
|
(event, data: WSEP['log_info']) => {
|
||||||
: String((data?.data as Record<string, unknown>)?.message ?? '')
|
const message =
|
||||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {}
|
'string' === typeof data?.message
|
||||||
switch (event) {
|
? data.message
|
||||||
case 'log_info':
|
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||||
toast.info(message, extra)
|
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||||
break
|
switch (event) {
|
||||||
case 'log_success':
|
case 'log_info':
|
||||||
toast.success(message, extra)
|
toast.info(message, extra);
|
||||||
break
|
break;
|
||||||
case 'log_warning':
|
case 'log_success':
|
||||||
toast.warning(message, extra)
|
toast.success(message, extra);
|
||||||
break
|
break;
|
||||||
case 'log_error':
|
case 'log_warning':
|
||||||
toast.error(message, extra)
|
toast.warning(message, extra);
|
||||||
break
|
break;
|
||||||
}
|
case 'log_error':
|
||||||
}, true)
|
toast.error(message, extra);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||||
const id = data.data._id
|
const id = data.data._id;
|
||||||
|
|
||||||
if (true !== stateStore.has('queue', id)) {
|
if (true !== stateStore.has('queue', id)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`)
|
toast.warning(
|
||||||
|
`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (true === stateStore.has('queue', id)) {
|
if (true === stateStore.has('queue', id)) {
|
||||||
stateStore.remove('queue', id)
|
stateStore.remove('queue', id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||||
const id = data.data._id
|
const id = data.data._id;
|
||||||
|
|
||||||
if (true !== stateStore.has('history', id)) {
|
if (true !== stateStore.has('history', id)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
stateStore.remove('history', id)
|
stateStore.remove('history', id);
|
||||||
})
|
});
|
||||||
|
|
||||||
on('item_updated', (data: WSEP['item_updated']) => {
|
on('item_updated', (data: WSEP['item_updated']) => {
|
||||||
const id = data.data._id
|
const id = data.data._id;
|
||||||
|
|
||||||
if (true === stateStore.has('history', id)) {
|
if (true === stateStore.has('history', id)) {
|
||||||
stateStore.update('history', id, data.data)
|
stateStore.update('history', id, data.data);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === stateStore.has('queue', id)) {
|
if (true === stateStore.has('queue', id)) {
|
||||||
stateStore.update('queue', id, data.data)
|
stateStore.update('queue', id, data.data);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
on('item_moved', (data: WSEP['item_moved']) => {
|
on('item_moved', (data: WSEP['item_moved']) => {
|
||||||
const to = data.data.to
|
const to = data.data.to;
|
||||||
const id = data.data.item._id
|
const id = data.data.item._id;
|
||||||
|
|
||||||
if ('queue' === to) {
|
if ('queue' === to) {
|
||||||
if (true === stateStore.has('history', id)) {
|
if (true === stateStore.has('history', id)) {
|
||||||
stateStore.remove('history', id)
|
stateStore.remove('history', id);
|
||||||
}
|
}
|
||||||
stateStore.add('queue', id, data.data.item)
|
stateStore.add('queue', id, data.data.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('history' === to) {
|
if ('history' === to) {
|
||||||
if (true === stateStore.has('queue', id)) {
|
if (true === stateStore.has('queue', id)) {
|
||||||
stateStore.remove('queue', id)
|
stateStore.remove('queue', id);
|
||||||
}
|
}
|
||||||
stateStore.add('history', id, data.data.item)
|
stateStore.add('history', id, data.data.item);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
on(['paused', 'resumed'], (event, data: WSEP['paused']) => {
|
on(
|
||||||
const pausedState = Boolean(data.data.paused)
|
['paused', 'resumed'],
|
||||||
config.update('paused', pausedState)
|
(event, data: WSEP['paused']) => {
|
||||||
|
const pausedState = Boolean(data.data.paused);
|
||||||
|
config.update('paused', pausedState);
|
||||||
|
|
||||||
if ('resumed' === event) {
|
if ('resumed' === event) {
|
||||||
toast.success('Download queue resumed.')
|
toast.success('Download queue resumed.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.warning('Download queue paused.', { timeout: 10000 })
|
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||||
}, true)
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
on('config_update', (data: WSEP['config_update']) => {
|
on('config_update', (data: WSEP['config_update']) => {
|
||||||
const configUpdate = data.data as ConfigUpdatePayload
|
const configUpdate = data.data as ConfigUpdatePayload;
|
||||||
if (!configUpdate) {
|
if (!configUpdate) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data)
|
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connect, reconnect, disconnect,
|
connect,
|
||||||
on, off, emit,
|
reconnect,
|
||||||
|
disconnect,
|
||||||
|
on,
|
||||||
|
off,
|
||||||
|
emit,
|
||||||
isConnected,
|
isConnected,
|
||||||
getSessionId,
|
getSessionId,
|
||||||
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
||||||
error: readonly(error) as Readonly<Ref<string | null>>,
|
error: readonly(error) as Readonly<Ref<string | null>>,
|
||||||
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia';
|
||||||
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';
|
||||||
import { request } from '~/utils'
|
import { request } from '~/utils';
|
||||||
|
|
||||||
type StateType = 'queue' | 'history'
|
type StateType = 'queue' | 'history';
|
||||||
type KeyType = string
|
type KeyType = string;
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
queue: Record<KeyType, StoreItem>
|
queue: Record<KeyType, StoreItem>;
|
||||||
history: Record<KeyType, StoreItem>
|
history: Record<KeyType, StoreItem>;
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
total: number
|
total: number;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
has_next: boolean
|
has_next: boolean;
|
||||||
has_prev: boolean
|
has_prev: boolean;
|
||||||
isLoaded: boolean
|
isLoaded: boolean;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStateStore = defineStore('state', () => {
|
export const useStateStore = defineStore('state', () => {
|
||||||
|
|
@ -35,110 +35,121 @@ export const useStateStore = defineStore('state', () => {
|
||||||
isLoaded: false,
|
isLoaded: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||||
if ('history' === type && state.pagination.total > 0) {
|
if ('history' === type && state.pagination.total > 0) {
|
||||||
state.pagination.total += 1
|
state.pagination.total += 1;
|
||||||
}
|
}
|
||||||
state[type][key] = value
|
state[type][key] = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
|
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||||
state[type][key] = value
|
state[type][key] = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
const remove = (type: StateType, key: KeyType): void => {
|
const remove = (type: StateType, key: KeyType): void => {
|
||||||
if (!state[type][key]) {
|
if (!state[type][key]) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('history' === type && state.pagination.total > 0) {
|
if ('history' === type && state.pagination.total > 0) {
|
||||||
state.pagination.total -= 1
|
state.pagination.total -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { [key]: _, ...rest } = state[type]
|
const { [key]: _, ...rest } = state[type];
|
||||||
state[type] = rest
|
state[type] = rest;
|
||||||
}
|
};
|
||||||
|
|
||||||
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
|
const get = (
|
||||||
return state[type][key] || defaultValue
|
type: StateType,
|
||||||
}
|
key: KeyType,
|
||||||
|
defaultValue: StoreItem | null = null,
|
||||||
|
): StoreItem | null => {
|
||||||
|
return state[type][key] || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
const has = (type: StateType, key: KeyType): boolean => {
|
const has = (type: StateType, key: KeyType): boolean => {
|
||||||
return !!state[type][key]
|
return !!state[type][key];
|
||||||
}
|
};
|
||||||
|
|
||||||
const clearAll = (type: StateType): void => {
|
const clearAll = (type: StateType): void => {
|
||||||
state[type] = {}
|
state[type] = {};
|
||||||
if ('queue' === type) {
|
if ('queue' === type) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pagination.total = 0
|
state.pagination.total = 0;
|
||||||
state.pagination.page = 1
|
state.pagination.page = 1;
|
||||||
state.pagination.total_pages = 0
|
state.pagination.total_pages = 0;
|
||||||
state.pagination.has_next = false
|
state.pagination.has_next = false;
|
||||||
state.pagination.has_prev = false
|
state.pagination.has_prev = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||||
state[type] = data
|
state[type] = data;
|
||||||
}
|
};
|
||||||
|
|
||||||
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
||||||
if (true === has(fromType, key)) {
|
if (true === has(fromType, key)) {
|
||||||
remove(fromType, key)
|
remove(fromType, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem)
|
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem);
|
||||||
}
|
};
|
||||||
|
|
||||||
const count = (type: StateType): number => {
|
const count = (type: StateType): number => {
|
||||||
if ('history' === type && state.pagination.total > 0) {
|
if ('history' === type && state.pagination.total > 0) {
|
||||||
return state.pagination.total
|
return state.pagination.total;
|
||||||
}
|
}
|
||||||
return Object.keys(state[type]).length
|
return Object.keys(state[type]).length;
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false, status?: string): Promise<void> => {
|
const loadPaginated = async (
|
||||||
|
type: StateType,
|
||||||
|
page: number = 1,
|
||||||
|
per_page: number = 50,
|
||||||
|
order: 'ASC' | 'DESC' = 'DESC',
|
||||||
|
append: boolean = false,
|
||||||
|
status?: string,
|
||||||
|
): Promise<void> => {
|
||||||
if ('history' !== type) {
|
if ('history' !== type) {
|
||||||
throw new Error('Pagination is only supported for history type');
|
throw new Error('Pagination is only supported for history type');
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pagination.isLoading = true
|
state.pagination.isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
type: 'done',
|
type: 'done',
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
per_page: per_page.toString(),
|
per_page: per_page.toString(),
|
||||||
order
|
order,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
params.status = status
|
params.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = new URLSearchParams(params)
|
const search = new URLSearchParams(params);
|
||||||
|
|
||||||
const response = await request(`/api/history?${search}`)
|
const response = await request(`/api/history?${search}`);
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.pagination) {
|
if (data.pagination) {
|
||||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false, }
|
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
|
||||||
const items: Record<KeyType, StoreItem> = {}
|
const items: Record<KeyType, StoreItem> = {};
|
||||||
for (const item of data.items || []) {
|
for (const item of data.items || []) {
|
||||||
items[item._id] = item
|
items[item._id] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
state[type] = append ? { ...state[type], ...items } : items
|
state[type] = append ? { ...state[type], ...items } : items;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load ${type} page ${page}:`, error)
|
console.error(`Failed to load ${type} page ${page}:`, error);
|
||||||
state.pagination.isLoading = false
|
state.pagination.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
|
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
|
||||||
if ('history' !== type) {
|
if ('history' !== type) {
|
||||||
|
|
@ -146,11 +157,11 @@ export const useStateStore = defineStore('state', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.pagination.has_next || state.pagination.isLoading) {
|
if (!state.pagination.has_next || state.pagination.isLoading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append)
|
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append);
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
||||||
if ('history' !== type) {
|
if ('history' !== type) {
|
||||||
|
|
@ -158,31 +169,31 @@ export const useStateStore = defineStore('state', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page)
|
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page);
|
||||||
}
|
};
|
||||||
|
|
||||||
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
||||||
if ('history' !== type) {
|
if ('history' !== type) {
|
||||||
throw new Error('Pagination is only supported for history type');
|
throw new Error('Pagination is only supported for history type');
|
||||||
}
|
}
|
||||||
if (!state.pagination.isLoaded) {
|
if (!state.pagination.isLoaded) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadPaginated(type, state.pagination.page, state.pagination.per_page)
|
await loadPaginated(type, state.pagination.page, state.pagination.per_page);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getPagination = () => state.pagination
|
const getPagination = () => state.pagination;
|
||||||
|
|
||||||
const setHistoryCount = (count: number) => {
|
const setHistoryCount = (count: number) => {
|
||||||
state.pagination.total = count
|
state.pagination.total = count;
|
||||||
if (count > 0 && !state.pagination.isLoaded) {
|
if (count > 0 && !state.pagination.isLoaded) {
|
||||||
state.pagination.isLoaded = false
|
state.pagination.isLoaded = false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load queue data from REST API.
|
* Load queue data from REST API.
|
||||||
|
|
@ -192,19 +203,19 @@ export const useStateStore = defineStore('state', () => {
|
||||||
*/
|
*/
|
||||||
const loadQueue = async (): Promise<void> => {
|
const loadQueue = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/history/live')
|
const response = await request('/api/history/live');
|
||||||
const data = await response.json() as {
|
const data = (await response.json()) as {
|
||||||
"queue": Record<KeyType, StoreItem>,
|
queue: Record<KeyType, StoreItem>;
|
||||||
"history_count": number,
|
history_count: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
state.queue = data.queue || {}
|
state.queue = data.queue || {};
|
||||||
setHistoryCount(data.history_count)
|
setHistoryCount(data.history_count);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load queue:', error)
|
console.error('Failed to load queue:', error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a download using WebSocket if connected, fallback to REST API.
|
* Add a download using WebSocket if connected, fallback to REST API.
|
||||||
|
|
@ -213,37 +224,37 @@ export const useStateStore = defineStore('state', () => {
|
||||||
* @returns Promise that resolves when download is added
|
* @returns Promise that resolves when download is added
|
||||||
*/
|
*/
|
||||||
const addDownload = async (data: item_request): Promise<void> => {
|
const addDownload = async (data: item_request): Promise<void> => {
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
socket.emit('add_url', data)
|
socket.emit('add_url', data);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/history/', {
|
const response = await request('/api/history/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json();
|
||||||
toast.error(error.error || 'Failed to add download')
|
toast.error(error.error || 'Failed to add download');
|
||||||
throw new Error(error.error || 'Failed to add download')
|
throw new Error(error.error || 'Failed to add download');
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Download added successfully')
|
toast.success('Download added successfully');
|
||||||
await loadQueue()
|
await loadQueue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add download:', error)
|
console.error('Failed to add download:', error);
|
||||||
if (error instanceof Error && !error.message.includes('Failed to add download')) {
|
if (error instanceof Error && !error.message.includes('Failed to add download')) {
|
||||||
toast.error('Failed to add download')
|
toast.error('Failed to add download');
|
||||||
}
|
}
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start one or more downloads using WebSocket if connected, fallback to REST API.
|
* Start one or more downloads using WebSocket if connected, fallback to REST API.
|
||||||
|
|
@ -252,47 +263,47 @@ export const useStateStore = defineStore('state', () => {
|
||||||
* @returns Promise that resolves when items are started
|
* @returns Promise that resolves when items are started
|
||||||
*/
|
*/
|
||||||
const startItems = async (ids: string[]): Promise<void> => {
|
const startItems = async (ids: string[]): Promise<void> => {
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
ids.forEach(id => socket.emit('item_start', id))
|
ids.forEach((id) => socket.emit('item_start', id));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/history/start', {
|
const response = await request('/api/history/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids })
|
body: JSON.stringify({ ids }),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json();
|
||||||
toast.error(error.error || 'Failed to start items')
|
toast.error(error.error || 'Failed to start items');
|
||||||
throw new Error(error.error || 'Failed to start items')
|
throw new Error(error.error || 'Failed to start items');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if ('started' === result[id]) {
|
if ('started' === result[id]) {
|
||||||
const item = get('queue', id)
|
const item = get('queue', id);
|
||||||
if (item) {
|
if (item) {
|
||||||
update('queue', id, { ...item, auto_start: true })
|
update('queue', id, { ...item, auto_start: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`)
|
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start items:', error)
|
console.error('Failed to start items:', error);
|
||||||
if (error instanceof Error && !error.message.includes('Failed to start items')) {
|
if (error instanceof Error && !error.message.includes('Failed to start items')) {
|
||||||
toast.error('Failed to start items')
|
toast.error('Failed to start items');
|
||||||
}
|
}
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
|
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
|
||||||
|
|
@ -301,47 +312,47 @@ export const useStateStore = defineStore('state', () => {
|
||||||
* @returns Promise that resolves when items are paused
|
* @returns Promise that resolves when items are paused
|
||||||
*/
|
*/
|
||||||
const pauseItems = async (ids: string[]): Promise<void> => {
|
const pauseItems = async (ids: string[]): Promise<void> => {
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
ids.forEach(id => socket.emit('item_pause', id))
|
ids.forEach((id) => socket.emit('item_pause', id));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/history/pause', {
|
const response = await request('/api/history/pause', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids })
|
body: JSON.stringify({ ids }),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json();
|
||||||
toast.error(error.error || 'Failed to pause items')
|
toast.error(error.error || 'Failed to pause items');
|
||||||
throw new Error(error.error || 'Failed to pause items')
|
throw new Error(error.error || 'Failed to pause items');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if ('paused' === result[id]) {
|
if ('paused' === result[id]) {
|
||||||
const item = get('queue', id)
|
const item = get('queue', id);
|
||||||
if (item) {
|
if (item) {
|
||||||
update('queue', id, { ...item, auto_start: false })
|
update('queue', id, { ...item, auto_start: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`)
|
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pause items:', error)
|
console.error('Failed to pause items:', error);
|
||||||
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
|
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
|
||||||
toast.error('Failed to pause items')
|
toast.error('Failed to pause items');
|
||||||
}
|
}
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
|
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
|
||||||
|
|
@ -350,19 +361,19 @@ export const useStateStore = defineStore('state', () => {
|
||||||
* @returns Promise that resolves when items are cancelled
|
* @returns Promise that resolves when items are cancelled
|
||||||
*/
|
*/
|
||||||
const cancelItems = async (ids: string[]): Promise<void> => {
|
const cancelItems = async (ids: string[]): Promise<void> => {
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore();
|
||||||
const toast = useNotification()
|
const toast = useNotification();
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
ids.forEach(id => socket.emit('item_cancel', id))
|
ids.forEach((id) => socket.emit('item_cancel', id));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsToMove: Record<string, StoreItem> = {}
|
const itemsToMove: Record<string, StoreItem> = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const item = get('queue', id)
|
const item = get('queue', id);
|
||||||
if (item) {
|
if (item) {
|
||||||
itemsToMove[id] = { ...item }
|
itemsToMove[id] = { ...item };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,34 +381,34 @@ export const useStateStore = defineStore('state', () => {
|
||||||
const response = await request('/api/history/cancel', {
|
const response = await request('/api/history/cancel', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ids })
|
body: JSON.stringify({ ids }),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json();
|
||||||
toast.error(error.error || 'Failed to cancel items')
|
toast.error(error.error || 'Failed to cancel items');
|
||||||
throw new Error(error.error || 'Failed to cancel items')
|
throw new Error(error.error || 'Failed to cancel items');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if ('ok' === result[id] && itemsToMove[id]) {
|
if ('ok' === result[id] && itemsToMove[id]) {
|
||||||
remove('queue', id)
|
remove('queue', id);
|
||||||
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem
|
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
|
||||||
add('history', id, cancelledItem)
|
add('history', id, cancelledItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`)
|
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cancel items:', error)
|
console.error('Failed to cancel items:', error);
|
||||||
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
|
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
|
||||||
toast.error('Failed to cancel items')
|
toast.error('Failed to cancel items');
|
||||||
}
|
}
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove items using WebSocket if connected, fallback to REST API.
|
* Remove items using WebSocket if connected, fallback to REST API.
|
||||||
|
|
@ -407,23 +418,27 @@ export const useStateStore = defineStore('state', () => {
|
||||||
* @param removeFile - Whether to remove files from disk (default: false)
|
* @param removeFile - Whether to remove files from disk (default: false)
|
||||||
* @returns Promise that resolves when items are removed
|
* @returns Promise that resolves when items are removed
|
||||||
*/
|
*/
|
||||||
const removeItems = async (type: StateType, ids: string[], removeFile: boolean = false): Promise<void> => {
|
const removeItems = async (
|
||||||
const socket = useSocketStore()
|
type: StateType,
|
||||||
const toast = useNotification()
|
ids: string[],
|
||||||
|
removeFile: boolean = false,
|
||||||
|
): Promise<void> => {
|
||||||
|
const socket = useSocketStore();
|
||||||
|
const toast = useNotification();
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
ids.forEach(id => socket.emit('item_delete', { id, remove_file: removeFile }))
|
ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteItems(type, { ids, removeFile })
|
await deleteItems(type, { ids, removeFile });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove items:', error)
|
console.error('Failed to remove items:', error);
|
||||||
toast.error('Failed to remove items')
|
toast.error('Failed to remove items');
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete items by specific IDs or status filter.
|
* Delete items by specific IDs or status filter.
|
||||||
|
|
@ -438,55 +453,55 @@ export const useStateStore = defineStore('state', () => {
|
||||||
*/
|
*/
|
||||||
const deleteItems = async (
|
const deleteItems = async (
|
||||||
type: StateType,
|
type: StateType,
|
||||||
options: { ids?: string[]; status?: string; removeFile?: boolean } = {}
|
options: { ids?: string[]; status?: string; removeFile?: boolean } = {},
|
||||||
): Promise<number> => {
|
): Promise<number> => {
|
||||||
const { ids, status, removeFile = true } = options
|
const { ids, status, removeFile = true } = options;
|
||||||
|
|
||||||
if (!ids && !status) {
|
if (!ids && !status) {
|
||||||
throw new Error('Either ids or status filter must be provided')
|
throw new Error('Either ids or status filter must be provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
type: type === 'queue' ? 'queue' : 'done',
|
type: type === 'queue' ? 'queue' : 'done',
|
||||||
remove_file: removeFile
|
remove_file: removeFile,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (ids) {
|
if (ids) {
|
||||||
body.ids = ids
|
body.ids = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
body.status = status
|
body.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request('/api/history', {
|
const response = await request('/api/history', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = await response.json() as {
|
const result = (await response.json()) as {
|
||||||
items: Record<string, string>,
|
items: Record<string, string>;
|
||||||
deleted: number,
|
deleted: number;
|
||||||
error?: string,
|
error?: string;
|
||||||
message?: string,
|
message?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (result.error || result.message || !response.ok) {
|
if (result.error || result.message || !response.ok) {
|
||||||
throw new Error(result.error || result.message || 'Failed to delete items.')
|
throw new Error(result.error || result.message || 'Failed to delete items.');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of Object.keys(result.items)) {
|
for (const id of Object.keys(result.items)) {
|
||||||
remove(type, id)
|
remove(type, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.deleted
|
return result.deleted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to delete items:`, error)
|
console.error(`Failed to delete items:`, error);
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
|
|
@ -512,5 +527,5 @@ export const useStateStore = defineStore('state', () => {
|
||||||
cancelItems,
|
cancelItems,
|
||||||
removeItems,
|
removeItems,
|
||||||
deleteItems,
|
deleteItems,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
|
||||||
9
ui/app/types/autocomplete.d.ts
vendored
9
ui/app/types/autocomplete.d.ts
vendored
|
|
@ -1,9 +1,8 @@
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string
|
value: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoCompleteOptions = Option[];
|
||||||
|
|
||||||
type AutoCompleteOptions = Option[]
|
export type { Option, AutoCompleteOptions };
|
||||||
|
|
||||||
export type { Option, AutoCompleteOptions }
|
|
||||||
|
|
|
||||||
26
ui/app/types/changelogs.d.ts
vendored
26
ui/app/types/changelogs.d.ts
vendored
|
|
@ -1,18 +1,18 @@
|
||||||
type changelogs = changeset[]
|
type changelogs = changeset[];
|
||||||
|
|
||||||
type changeset = {
|
type changeset = {
|
||||||
tag: string
|
tag: string;
|
||||||
full_sha: string
|
full_sha: string;
|
||||||
date: string
|
date: string;
|
||||||
commits: Commit[]
|
commits: Commit[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type Commit = {
|
type Commit = {
|
||||||
sha: string
|
sha: string;
|
||||||
full_sha: string
|
full_sha: string;
|
||||||
message: string
|
message: string;
|
||||||
author: string
|
author: string;
|
||||||
date: string
|
date: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type {changelogs, changeset, Commit}
|
export type { changelogs, changeset, Commit };
|
||||||
|
|
|
||||||
40
ui/app/types/conditions.d.ts
vendored
40
ui/app/types/conditions.d.ts
vendored
|
|
@ -1,31 +1,31 @@
|
||||||
export interface Condition {
|
export interface Condition {
|
||||||
id?: number
|
id?: number;
|
||||||
name: string
|
name: string;
|
||||||
filter: string
|
filter: string;
|
||||||
cli: string
|
cli: string;
|
||||||
extras: Record<string, unknown>
|
extras: Record<string, unknown>;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
priority: number
|
priority: number;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pagination {
|
export interface Pagination {
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
total: number
|
total: number;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
has_next: boolean
|
has_next: boolean;
|
||||||
has_prev: boolean
|
has_prev: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionTestRequest {
|
export interface ConditionTestRequest {
|
||||||
url: string
|
url: string;
|
||||||
condition: string
|
condition: string;
|
||||||
preset?: string
|
preset?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionTestResponse {
|
export interface ConditionTestResponse {
|
||||||
status: boolean
|
status: boolean;
|
||||||
condition: string
|
condition: string;
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
ui/app/types/config.d.ts
vendored
76
ui/app/types/config.d.ts
vendored
|
|
@ -1,77 +1,77 @@
|
||||||
import type { Preset } from "./presets";
|
import type { Preset } from './presets';
|
||||||
import type { YTDLPOption } from './ytdlp';
|
import type { YTDLPOption } from './ytdlp';
|
||||||
import type { DLField } from "./dl_fields"
|
import type { DLField } from './dl_fields';
|
||||||
|
|
||||||
type AppConfig = {
|
type AppConfig = {
|
||||||
/** Path where downloaded files will be saved */
|
/** Path where downloaded files will be saved */
|
||||||
download_path: string
|
download_path: string;
|
||||||
/** Indicates if files should be removed after download */
|
/** Indicates if files should be removed after download */
|
||||||
remove_files: boolean
|
remove_files: boolean;
|
||||||
/** Indicates if the UI should update the title with the current download status */
|
/** Indicates if the UI should update the title with the current download status */
|
||||||
ui_update_title: boolean
|
ui_update_title: boolean;
|
||||||
/** Output template for yt-dlp, e.g. "%(title)s.%(ext)s" */
|
/** Output template for yt-dlp, e.g. "%(title)s.%(ext)s" */
|
||||||
output_template: string
|
output_template: string;
|
||||||
/** yt-dlp version, e.g. "2023.10.01" */
|
/** yt-dlp version, e.g. "2023.10.01" */
|
||||||
ytdlp_version: string
|
ytdlp_version: string;
|
||||||
/** Maximum number of concurrent download workers */
|
/** Maximum number of concurrent download workers */
|
||||||
max_workers: number
|
max_workers: number;
|
||||||
/** Maximum number of concurrent workers per extractor */
|
/** Maximum number of concurrent workers per extractor */
|
||||||
max_workers_per_extractor: number
|
max_workers_per_extractor: number;
|
||||||
/** Default preset name, e.g. "default" */
|
/** Default preset name, e.g. "default" */
|
||||||
default_preset: string
|
default_preset: string;
|
||||||
/** Instance title for the app, null if not set */
|
/** Instance title for the app, null if not set */
|
||||||
instance_title: string | null
|
instance_title: string | null;
|
||||||
/** Indicates if the console is enabled */
|
/** Indicates if the console is enabled */
|
||||||
console_enabled: boolean
|
console_enabled: boolean;
|
||||||
/** Indicates if the file browser control is enabled */
|
/** Indicates if the file browser control is enabled */
|
||||||
browser_control_enabled: boolean
|
browser_control_enabled: boolean;
|
||||||
/** Indicates if file logging is enabled */
|
/** Indicates if file logging is enabled */
|
||||||
file_logging: boolean
|
file_logging: boolean;
|
||||||
/** Indicates if the app is in simple mode */
|
/** Indicates if the app is in simple mode */
|
||||||
simple_mode: boolean
|
simple_mode: boolean;
|
||||||
/** Indicates if the app is running in a native environment (e.g., Electron) */
|
/** Indicates if the app is running in a native environment (e.g., Electron) */
|
||||||
is_native: boolean
|
is_native: boolean;
|
||||||
/** App version in format "1.0.0" */
|
/** App version in format "1.0.0" */
|
||||||
app_version: string
|
app_version: string;
|
||||||
/** App version in semantic versioning format, e.g. "1.0.0" */
|
/** App version in semantic versioning format, e.g. "1.0.0" */
|
||||||
app_commit_sha: string
|
app_commit_sha: string;
|
||||||
/** App build date in ISO format, e.g. "2023-10-01T12:00:00Z" */
|
/** App build date in ISO format, e.g. "2023-10-01T12:00:00Z" */
|
||||||
app_build_date: string
|
app_build_date: string;
|
||||||
/** App branch name, e.g. "main" or "develop" */
|
/** App branch name, e.g. "main" or "develop" */
|
||||||
app_branch: string
|
app_branch: string;
|
||||||
/** When the app started */
|
/** When the app started */
|
||||||
started: number,
|
started: number;
|
||||||
/** Application environment, e.g. "production", "development" */
|
/** Application environment, e.g. "production", "development" */
|
||||||
app_env: "production" | "development"
|
app_env: 'production' | 'development';
|
||||||
/** Default number of items per page for pagination */
|
/** Default number of items per page for pagination */
|
||||||
default_pagination: number
|
default_pagination: number;
|
||||||
/** Indicates if the app should check for updates */
|
/** Indicates if the app should check for updates */
|
||||||
check_for_updates: boolean
|
check_for_updates: boolean;
|
||||||
/** New version available, empty string if none */
|
/** New version available, empty string if none */
|
||||||
new_version: string
|
new_version: string;
|
||||||
/** New yt-dlp version available, empty string if none */
|
/** New yt-dlp version available, empty string if none */
|
||||||
yt_new_version: string
|
yt_new_version: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ConfigState = {
|
type ConfigState = {
|
||||||
/** Show or hide the download form */
|
/** Show or hide the download form */
|
||||||
showForm: RemovableRef<boolean>
|
showForm: RemovableRef<boolean>;
|
||||||
/** Application configuration */
|
/** Application configuration */
|
||||||
app: AppConfig
|
app: AppConfig;
|
||||||
/** List of presets */
|
/** List of presets */
|
||||||
presets: Array<Preset>
|
presets: Array<Preset>;
|
||||||
/** List of custom download fields */
|
/** List of custom download fields */
|
||||||
dl_fields: Array<DLField>
|
dl_fields: Array<DLField>;
|
||||||
/** List of folders where files can be saved */
|
/** List of folders where files can be saved */
|
||||||
folders: Array<string>
|
folders: Array<string>;
|
||||||
/** List of yt-dlp options */
|
/** List of yt-dlp options */
|
||||||
ytdlp_options: Array<YTDLPOption>
|
ytdlp_options: Array<YTDLPOption>;
|
||||||
/** Indicates if downloads are currently paused */
|
/** Indicates if downloads are currently paused */
|
||||||
paused: boolean
|
paused: boolean;
|
||||||
/** Indicates if the configuration has been loaded */
|
/** Indicates if the configuration has been loaded */
|
||||||
is_loaded: boolean
|
is_loaded: boolean;
|
||||||
/** Indicates if the configuration is currently loading */
|
/** Indicates if the configuration is currently loading */
|
||||||
is_loading: boolean
|
is_loading: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { AppConfig, ConfigState }
|
export type { AppConfig, ConfigState };
|
||||||
|
|
|
||||||
6
ui/app/types/dl_fields.d.ts
vendored
6
ui/app/types/dl_fields.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
type DLFieldType = "string" | "text" | "bool";
|
type DLFieldType = 'string' | 'text' | 'bool';
|
||||||
|
|
||||||
type DLField = {
|
type DLField = {
|
||||||
/** The id of the field */
|
/** The id of the field */
|
||||||
|
|
@ -27,7 +27,7 @@ type DLField = {
|
||||||
|
|
||||||
/** Additional options for the field */
|
/** Additional options for the field */
|
||||||
extras: Record<string, any>;
|
extras: Record<string, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request payload for creating/updating DLField
|
* Request payload for creating/updating DLField
|
||||||
|
|
@ -39,6 +39,6 @@ type DLFieldRequest = {
|
||||||
kind: DLFieldType;
|
kind: DLFieldType;
|
||||||
value?: string;
|
value?: string;
|
||||||
extras?: Record<string, any>;
|
extras?: Record<string, any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { DLField, DLFieldRequest, DLFieldType };
|
export type { DLField, DLFieldRequest, DLFieldType };
|
||||||
|
|
|
||||||
48
ui/app/types/filebrowser.d.ts
vendored
48
ui/app/types/filebrowser.d.ts
vendored
|
|
@ -1,30 +1,30 @@
|
||||||
type Pagination = {
|
type Pagination = {
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
total: number
|
total: number;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
has_next: boolean
|
has_next: boolean;
|
||||||
has_prev: boolean
|
has_prev: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
type FileItem = {
|
type FileItem = {
|
||||||
type: 'file' | 'dir' | 'link'
|
type: 'file' | 'dir' | 'link';
|
||||||
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string
|
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string;
|
||||||
name: string
|
name: string;
|
||||||
path: string
|
path: string;
|
||||||
size: number
|
size: number;
|
||||||
mime: string
|
mime: string;
|
||||||
mtime: string
|
mtime: string;
|
||||||
ctime: string
|
ctime: string;
|
||||||
is_dir: boolean
|
is_dir: boolean;
|
||||||
is_file: boolean
|
is_file: boolean;
|
||||||
is_symlink: boolean
|
is_symlink: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
type FileBrowserResponse = {
|
type FileBrowserResponse = {
|
||||||
path: string
|
path: string;
|
||||||
contents: FileItem[]
|
contents: FileItem[];
|
||||||
pagination: Pagination
|
pagination: Pagination;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { FileItem, FileBrowserResponse, Pagination }
|
export type { FileItem, FileBrowserResponse, Pagination };
|
||||||
|
|
|
||||||
4
ui/app/types/globals.d.ts
vendored
4
ui/app/types/globals.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
export { }
|
export {};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
ws?: WebSocket
|
ws?: WebSocket;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
ui/app/types/index.d.ts
vendored
26
ui/app/types/index.d.ts
vendored
|
|
@ -1,19 +1,19 @@
|
||||||
type ImportedItem = {
|
type ImportedItem = {
|
||||||
_type: string,
|
_type: string;
|
||||||
_version: string
|
_version: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type version_check = {
|
type version_check = {
|
||||||
app: {
|
app: {
|
||||||
status: 'up_to_date' | 'update_available' | 'error',
|
status: 'up_to_date' | 'update_available' | 'error';
|
||||||
current_version: string,
|
current_version: string;
|
||||||
new_version: string
|
new_version: string;
|
||||||
},
|
};
|
||||||
ytdlp: {
|
ytdlp: {
|
||||||
status: 'up_to_date' | 'update_available' | 'error',
|
status: 'up_to_date' | 'update_available' | 'error';
|
||||||
current_version: string,
|
current_version: string;
|
||||||
new_version: string
|
new_version: string;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export { ImportedItem, version_check }
|
export { ImportedItem, version_check };
|
||||||
|
|
|
||||||
20
ui/app/types/item.d.ts
vendored
20
ui/app/types/item.d.ts
vendored
|
|
@ -1,20 +1,20 @@
|
||||||
export type item_request = {
|
export type item_request = {
|
||||||
/** Unique identifier for the item */
|
/** Unique identifier for the item */
|
||||||
id?: string|null,
|
id?: string | null;
|
||||||
/** URL of the item to download */
|
/** URL of the item to download */
|
||||||
url: string,
|
url: string;
|
||||||
/** Preset to use for the download */
|
/** Preset to use for the download */
|
||||||
preset?: string,
|
preset?: string;
|
||||||
/** Where to save the downloaded item */
|
/** Where to save the downloaded item */
|
||||||
folder?: string,
|
folder?: string;
|
||||||
/** Output template for the downloaded item */
|
/** Output template for the downloaded item */
|
||||||
template?: string,
|
template?: string;
|
||||||
/** Additional command line options for yt-dlp */
|
/** Additional command line options for yt-dlp */
|
||||||
cli?: string,
|
cli?: string;
|
||||||
/** Cookies file for the download */
|
/** Cookies file for the download */
|
||||||
cookies?: string,
|
cookies?: string;
|
||||||
/** Auto start the download */
|
/** Auto start the download */
|
||||||
auto_start?: boolean,
|
auto_start?: boolean;
|
||||||
/** Extras data for the item */
|
/** Extras data for the item */
|
||||||
extras?: Record<string, any>,
|
extras?: Record<string, any>;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
type log_line = {
|
type log_line = {
|
||||||
id: number,
|
id: number;
|
||||||
line: string,
|
line: string;
|
||||||
datetime?: string,
|
datetime?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
export type { log_line };
|
export type { log_line };
|
||||||
|
|
|
||||||
26
ui/app/types/popover.d.ts
vendored
26
ui/app/types/popover.d.ts
vendored
|
|
@ -1,16 +1,16 @@
|
||||||
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'
|
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
|
||||||
export type PopoverTrigger = 'hover' | 'click' | 'focus'
|
export type PopoverTrigger = 'hover' | 'click' | 'focus';
|
||||||
|
|
||||||
export interface PopoverProps {
|
export interface PopoverProps {
|
||||||
title?: string
|
title?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
placement?: PopoverPlacement
|
placement?: PopoverPlacement;
|
||||||
trigger?: PopoverTrigger
|
trigger?: PopoverTrigger;
|
||||||
offset?: number
|
offset?: number;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
closeOnClickOutside?: boolean
|
closeOnClickOutside?: boolean;
|
||||||
minWidth?: number
|
minWidth?: number;
|
||||||
maxWidth?: number
|
maxWidth?: number;
|
||||||
maxHeight?: number
|
maxHeight?: number;
|
||||||
showDelay?: number
|
showDelay?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
ui/app/types/presets.d.ts
vendored
38
ui/app/types/presets.d.ts
vendored
|
|
@ -1,35 +1,35 @@
|
||||||
type Preset = {
|
type Preset = {
|
||||||
/** Unique identifier for the preset */
|
/** Unique identifier for the preset */
|
||||||
id?: number
|
id?: number;
|
||||||
/** Preset name, e.g. "default" */
|
/** Preset name, e.g. "default" */
|
||||||
name: string
|
name: string;
|
||||||
/** Optional description for the preset */
|
/** Optional description for the preset */
|
||||||
description: string
|
description: string;
|
||||||
/** Folder where files will be saved, e.g. "/downloads" */
|
/** Folder where files will be saved, e.g. "/downloads" */
|
||||||
folder: string
|
folder: string;
|
||||||
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */
|
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */
|
||||||
template: string
|
template: string;
|
||||||
/** Cookies for the preset, e.g. "cookies.txt" */
|
/** Cookies for the preset, e.g. "cookies.txt" */
|
||||||
cookies: string
|
cookies: string;
|
||||||
/** Additional command line options for yt-dlp */
|
/** Additional command line options for yt-dlp */
|
||||||
cli: string
|
cli: string;
|
||||||
/** Indicates if this is the default preset */
|
/** Indicates if this is the default preset */
|
||||||
default: boolean
|
default: boolean;
|
||||||
/** Priority for sorting. Higher priority presets appear first */
|
/** Priority for sorting. Higher priority presets appear first */
|
||||||
priority: number
|
priority: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request payload for creating/updating preset
|
* Request payload for creating/updating preset
|
||||||
*/
|
*/
|
||||||
type PresetRequest = {
|
type PresetRequest = {
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
folder?: string
|
folder?: string;
|
||||||
template?: string
|
template?: string;
|
||||||
cookies?: string
|
cookies?: string;
|
||||||
cli?: string
|
cli?: string;
|
||||||
priority?: number
|
priority?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { Preset, PresetRequest }
|
export type { Preset, PresetRequest };
|
||||||
|
|
|
||||||
44
ui/app/types/responses.d.ts
vendored
44
ui/app/types/responses.d.ts
vendored
|
|
@ -1,38 +1,38 @@
|
||||||
export type error_response = {
|
export type error_response = {
|
||||||
/** The error message */
|
/** The error message */
|
||||||
error: string,
|
error: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type convert_args_response = {
|
export type convert_args_response = {
|
||||||
/** The converted CLI args */
|
/** The converted CLI args */
|
||||||
opts?: Record<string, any>,
|
opts?: Record<string, any>;
|
||||||
/** The output template if was provided */
|
/** The output template if was provided */
|
||||||
output_template?: string,
|
output_template?: string;
|
||||||
/** The download path if was provided */
|
/** The download path if was provided */
|
||||||
download_path?: string,
|
download_path?: string;
|
||||||
/** The download format if was provided */
|
/** The download format if was provided */
|
||||||
format?: string,
|
format?: string;
|
||||||
/** The removed options from the original CLI args if any. */
|
/** The removed options from the original CLI args if any. */
|
||||||
removed_options?: Array<string>,
|
removed_options?: Array<string>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Pagination = {
|
export type Pagination = {
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
total: number
|
total: number;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
has_next: boolean
|
has_next: boolean;
|
||||||
has_prev: boolean
|
has_prev: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Paginated<T> = {
|
export type Paginated<T> = {
|
||||||
items: Array<T>
|
items: Array<T>;
|
||||||
pagination: Pagination
|
pagination: Pagination;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface APIResponse<T = unknown> {
|
export interface APIResponse<T = unknown> {
|
||||||
success: boolean
|
success: boolean;
|
||||||
error: string | null
|
error: string | null;
|
||||||
detail: unknown
|
detail: unknown;
|
||||||
data?: T
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
ui/app/types/sockets.d.ts
vendored
88
ui/app/types/sockets.d.ts
vendored
|
|
@ -1,58 +1,58 @@
|
||||||
import type { StoreItem } from "./store"
|
import type { StoreItem } from './store';
|
||||||
|
|
||||||
export type Event = {
|
export type Event = {
|
||||||
id: string
|
id: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
event: string
|
event: string;
|
||||||
title: string | null
|
title: string | null;
|
||||||
message: string | null
|
message: string | null;
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type EventPayload<T> = Event & { data: T }
|
export type EventPayload<T> = Event & { data: T };
|
||||||
|
|
||||||
export type WebSocketEnvelope<T = unknown> = {
|
export type WebSocketEnvelope<T = unknown> = {
|
||||||
event: string
|
event: string;
|
||||||
data: T
|
data: T;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type WSEP = {
|
export type WSEP = {
|
||||||
connect: null
|
connect: null;
|
||||||
disconnect: null
|
disconnect: null;
|
||||||
connect_error: { message?: string }
|
connect_error: { message?: string };
|
||||||
connected: EventPayload<{ sid: string }>
|
connected: EventPayload<{ sid: string }>;
|
||||||
item_added: EventPayload<StoreItem>
|
item_added: EventPayload<StoreItem>;
|
||||||
item_updated: EventPayload<StoreItem>
|
item_updated: EventPayload<StoreItem>;
|
||||||
item_cancelled: EventPayload<StoreItem>
|
item_cancelled: EventPayload<StoreItem>;
|
||||||
item_deleted: EventPayload<StoreItem>
|
item_deleted: EventPayload<StoreItem>;
|
||||||
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>
|
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>;
|
||||||
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>
|
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
|
||||||
paused: EventPayload<{ paused?: boolean }>
|
paused: EventPayload<{ paused?: boolean }>;
|
||||||
resumed: EventPayload<{ paused?: boolean }>
|
resumed: EventPayload<{ paused?: boolean }>;
|
||||||
log_info: EventPayload<Record<string, unknown>>
|
log_info: EventPayload<Record<string, unknown>>;
|
||||||
log_success: EventPayload<Record<string, unknown>>
|
log_success: EventPayload<Record<string, unknown>>;
|
||||||
log_warning: EventPayload<Record<string, unknown>>
|
log_warning: EventPayload<Record<string, unknown>>;
|
||||||
log_error: EventPayload<Record<string, unknown>>
|
log_error: EventPayload<Record<string, unknown>>;
|
||||||
config_update: EventPayload<ConfigUpdatePayload>
|
config_update: EventPayload<ConfigUpdatePayload>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type WebSocketClientEmits = {
|
export type WebSocketClientEmits = {
|
||||||
add_url: Record<string, unknown>
|
add_url: Record<string, unknown>;
|
||||||
item_cancel: string
|
item_cancel: string;
|
||||||
item_delete: { id: string; remove_file?: boolean }
|
item_delete: { id: string; remove_file?: boolean };
|
||||||
item_start: string | string[]
|
item_start: string | string[];
|
||||||
item_pause: string | string[]
|
item_pause: string | string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace'
|
type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace';
|
||||||
|
|
||||||
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions'
|
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions';
|
||||||
|
|
||||||
type ConfigUpdatePayload<T = unknown> = {
|
type ConfigUpdatePayload<T = unknown> = {
|
||||||
feature: ConfigFeature
|
feature: ConfigFeature;
|
||||||
action: ConfigUpdateAction
|
action: ConfigUpdateAction;
|
||||||
data: T | Array<T>
|
data: T | Array<T>;
|
||||||
meta?: Record<string, unknown>
|
meta?: Record<string, unknown>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { ConfigUpdateAction, ConfigFeature, ConfigUpdatePayload }
|
export type { ConfigUpdateAction, ConfigFeature, ConfigUpdatePayload };
|
||||||
|
|
|
||||||
124
ui/app/types/store.d.ts
vendored
124
ui/app/types/store.d.ts
vendored
|
|
@ -1,113 +1,123 @@
|
||||||
type ItemStatus = 'started' | 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | 'skip' | null;
|
type ItemStatus =
|
||||||
|
| 'started'
|
||||||
|
| 'finished'
|
||||||
|
| 'preparing'
|
||||||
|
| 'error'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'downloading'
|
||||||
|
| 'postprocessing'
|
||||||
|
| 'not_live'
|
||||||
|
| 'skip'
|
||||||
|
| null;
|
||||||
|
|
||||||
type SideCar = {
|
type SideCar = {
|
||||||
file: string
|
file: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type sideCarSubtitle = SideCar & {
|
type sideCarSubtitle = SideCar & {
|
||||||
lang: string
|
lang: string;
|
||||||
name: string
|
name: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type StoreItem = {
|
type StoreItem = {
|
||||||
/** Unique identifier for the item */
|
/** Unique identifier for the item */
|
||||||
_id: string
|
_id: string;
|
||||||
/** Error message if available */
|
/** Error message if available */
|
||||||
error: string | null
|
error: string | null;
|
||||||
/** Item source id */
|
/** Item source id */
|
||||||
id: string
|
id: string;
|
||||||
/** Title of the item */
|
/** Title of the item */
|
||||||
title: string
|
title: string;
|
||||||
/** Description of the item */
|
/** Description of the item */
|
||||||
description: string
|
description: string;
|
||||||
/** URL of the item */
|
/** URL of the item */
|
||||||
url: string
|
url: string;
|
||||||
/** Preset used for the item */
|
/** Preset used for the item */
|
||||||
preset: string
|
preset: string;
|
||||||
/** Folder where the item is saved */
|
/** Folder where the item is saved */
|
||||||
folder: string
|
folder: string;
|
||||||
/** Download directory */
|
/** Download directory */
|
||||||
download_dir: string
|
download_dir: string;
|
||||||
/** Temporary directory for the item */
|
/** Temporary directory for the item */
|
||||||
temp_dir: string
|
temp_dir: string;
|
||||||
/** Status of the item */
|
/** Status of the item */
|
||||||
status: ItemStatus
|
status: ItemStatus;
|
||||||
/** If the item has cookies */
|
/** If the item has cookies */
|
||||||
cookies: string
|
cookies: string;
|
||||||
/** If the item has custom output_template */
|
/** If the item has custom output_template */
|
||||||
template: string
|
template: string;
|
||||||
/** If the item has custom output_template for chapters */
|
/** If the item has custom output_template for chapters */
|
||||||
template_chapter: string
|
template_chapter: string;
|
||||||
/** When the item was created */
|
/** When the item was created */
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
/** If the item is a live stream */
|
/** If the item is a live stream */
|
||||||
is_live: boolean
|
is_live: boolean;
|
||||||
/** ISO 8601 formatted start time of the item */
|
/** ISO 8601 formatted start time of the item */
|
||||||
datetime: string
|
datetime: string;
|
||||||
/** Live stream start time if available */
|
/** Live stream start time if available */
|
||||||
live_in: string | null
|
live_in: string | null;
|
||||||
/** File size of the item if available */
|
/** File size of the item if available */
|
||||||
file_size: number | null
|
file_size: number | null;
|
||||||
/** Custom yt-dlp command line arguments */
|
/** Custom yt-dlp command line arguments */
|
||||||
cli: string
|
cli: string;
|
||||||
/** If the item is auto-started */
|
/** If the item is auto-started */
|
||||||
auto_start: boolean
|
auto_start: boolean;
|
||||||
/** Options for the item */
|
/** Options for the item */
|
||||||
options: Record<string, unknown>
|
options: Record<string, unknown>;
|
||||||
/** Sidecar associated with the item. */
|
/** Sidecar associated with the item. */
|
||||||
sidecar: {
|
sidecar: {
|
||||||
Unknown?: Array<SideCar>
|
Unknown?: Array<SideCar>;
|
||||||
subtitle?: Array<sideCarSubtitle>
|
subtitle?: Array<sideCarSubtitle>;
|
||||||
image?: Array<SideCar>
|
image?: Array<SideCar>;
|
||||||
},
|
};
|
||||||
/** Extras for the item */
|
/** Extras for the item */
|
||||||
extras: {
|
extras: {
|
||||||
/** Which channel the item belongs to */
|
/** Which channel the item belongs to */
|
||||||
channel?: string
|
channel?: string;
|
||||||
/** The video duration if available */
|
/** The video duration if available */
|
||||||
duration?: number | null
|
duration?: number | null;
|
||||||
/** The video release date if available */
|
/** The video release date if available */
|
||||||
release_in?: string
|
release_in?: string;
|
||||||
/** The video thumbnail URL if available */
|
/** The video thumbnail URL if available */
|
||||||
thumbnail?: string
|
thumbnail?: string;
|
||||||
/** The uploader of the item if available */
|
/** The uploader of the item if available */
|
||||||
uploader?: string
|
uploader?: string;
|
||||||
/** Uploader name if available */
|
/** Uploader name if available */
|
||||||
is_audio?: boolean
|
is_audio?: boolean;
|
||||||
/** If the item has audio stream */
|
/** If the item has audio stream */
|
||||||
is_video?: boolean
|
is_video?: boolean;
|
||||||
/** If the item has video stream */
|
/** If the item has video stream */
|
||||||
live_in?: string
|
live_in?: string;
|
||||||
/** Live stream start time if available */
|
/** Live stream start time if available */
|
||||||
is_premiere?: boolean
|
is_premiere?: boolean;
|
||||||
/** If the item is a premiere */
|
/** If the item is a premiere */
|
||||||
}
|
};
|
||||||
/** The item temporary filename */
|
/** The item temporary filename */
|
||||||
tmpfilename?: string | null
|
tmpfilename?: string | null;
|
||||||
/** The item filename */
|
/** The item filename */
|
||||||
filename?: string | null
|
filename?: string | null;
|
||||||
/** Actual file size of the item if available */
|
/** Actual file size of the item if available */
|
||||||
total_bytes?: number | null
|
total_bytes?: number | null;
|
||||||
/** Estimated total bytes for the item if available */
|
/** Estimated total bytes for the item if available */
|
||||||
total_bytes_estimate?: number | null
|
total_bytes_estimate?: number | null;
|
||||||
/** Downloaded bytes of the item if available */
|
/** Downloaded bytes of the item if available */
|
||||||
downloaded_bytes?: number | null
|
downloaded_bytes?: number | null;
|
||||||
/** Message attached with the item if available */
|
/** Message attached with the item if available */
|
||||||
msg?: string | null
|
msg?: string | null;
|
||||||
/** Progress percentage of the item if available */
|
/** Progress percentage of the item if available */
|
||||||
percent?: number | null
|
percent?: number | null;
|
||||||
/** Speed of the item download if available */
|
/** Speed of the item download if available */
|
||||||
speed?: number | null
|
speed?: number | null;
|
||||||
/** Time remaining for the item download if available */
|
/** Time remaining for the item download if available */
|
||||||
eta?: number | null
|
eta?: number | null;
|
||||||
/** If the item can be archived */
|
/** If the item can be archived */
|
||||||
is_archivable?: boolean
|
is_archivable?: boolean;
|
||||||
/** If the item is archived */
|
/** If the item is archived */
|
||||||
is_archived?: boolean
|
is_archived?: boolean;
|
||||||
/** Item archive ID */
|
/** Item archive ID */
|
||||||
archive_id?: string | null
|
archive_id?: string | null;
|
||||||
/** Postprocessor running for the item if available */
|
/** Postprocessor running for the item if available */
|
||||||
postprocessor?: string | null
|
postprocessor?: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { ItemStatus, StoreItem }
|
export type { ItemStatus, StoreItem };
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,114 @@
|
||||||
// --- Task Definition Schema Types ---
|
// --- Task Definition Schema Types ---
|
||||||
import type { Paginated } from '~/types/responses'
|
import type { Paginated } from '~/types/responses';
|
||||||
|
|
||||||
export type EngineType = 'httpx' | 'selenium'
|
export type EngineType = 'httpx' | 'selenium';
|
||||||
|
|
||||||
export interface EngineOptions {
|
export interface EngineOptions {
|
||||||
url?: string
|
url?: string;
|
||||||
browser?: 'chrome'
|
browser?: 'chrome';
|
||||||
arguments?: Array<string> | string
|
arguments?: Array<string> | string;
|
||||||
wait_for?: WaitForSelector
|
wait_for?: WaitForSelector;
|
||||||
wait_timeout?: number
|
wait_timeout?: number;
|
||||||
page_load_timeout?: number
|
page_load_timeout?: number;
|
||||||
[key: string]: unknown
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EngineConfig {
|
export interface EngineConfig {
|
||||||
type?: EngineType
|
type?: EngineType;
|
||||||
options?: EngineOptions
|
options?: EngineOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HttpMethod = 'GET' | 'POST'
|
export type HttpMethod = 'GET' | 'POST';
|
||||||
|
|
||||||
export type StringMap = Record<string, string | number | boolean | null>
|
export type StringMap = Record<string, string | number | boolean | null>;
|
||||||
|
|
||||||
export interface RequestConfig {
|
export interface RequestConfig {
|
||||||
method?: HttpMethod
|
method?: HttpMethod;
|
||||||
url?: string
|
url?: string;
|
||||||
headers?: StringMap
|
headers?: StringMap;
|
||||||
params?: StringMap
|
params?: StringMap;
|
||||||
data?: StringMap | string | null
|
data?: StringMap | string | null;
|
||||||
json_data?: object | Array<unknown> | string | number | boolean | null
|
json_data?: object | Array<unknown> | string | number | boolean | null;
|
||||||
timeout?: number
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResponseType = 'html' | 'json'
|
export type ResponseType = 'html' | 'json';
|
||||||
|
|
||||||
export interface ResponseConfig {
|
export interface ResponseConfig {
|
||||||
type?: ResponseType
|
type?: ResponseType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath'
|
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath';
|
||||||
|
|
||||||
export interface PostFilter {
|
export interface PostFilter {
|
||||||
filter: string
|
filter: string;
|
||||||
value?: string
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractionRule {
|
export interface ExtractionRule {
|
||||||
type: ExtractionType
|
type: ExtractionType;
|
||||||
expression: string
|
expression: string;
|
||||||
attribute?: string
|
attribute?: string;
|
||||||
post_filter?: PostFilter
|
post_filter?: PostFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerFields {
|
export interface ContainerFields {
|
||||||
link: ExtractionRule
|
link: ExtractionRule;
|
||||||
[field: string]: ExtractionRule
|
[field: string]: ExtractionRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath'
|
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath';
|
||||||
|
|
||||||
export interface Container {
|
export interface Container {
|
||||||
type?: ContainerSelectorType
|
type?: ContainerSelectorType;
|
||||||
selector?: string
|
selector?: string;
|
||||||
expression?: string
|
expression?: string;
|
||||||
fields: ContainerFields
|
fields: ContainerFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WaitForSelector {
|
export interface WaitForSelector {
|
||||||
type?: 'css' | 'xpath'
|
type?: 'css' | 'xpath';
|
||||||
expression?: string
|
expression?: string;
|
||||||
value?: string
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseConfig {
|
export interface ParseConfig {
|
||||||
items?: Container
|
items?: Container;
|
||||||
link?: ExtractionRule
|
link?: ExtractionRule;
|
||||||
[field: string]: ExtractionRule | Container | undefined
|
[field: string]: ExtractionRule | Container | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskDefinitionConfig {
|
export interface TaskDefinitionConfig {
|
||||||
parse: ParseConfig
|
parse: ParseConfig;
|
||||||
engine?: EngineConfig
|
engine?: EngineConfig;
|
||||||
request?: RequestConfig
|
request?: RequestConfig;
|
||||||
response?: ResponseConfig
|
response?: ResponseConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskDefinitionDocument {
|
export interface TaskDefinitionDocument {
|
||||||
name: string
|
name: string;
|
||||||
match_url: string[]
|
match_url: string[];
|
||||||
priority?: number
|
priority?: number;
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
definition: TaskDefinitionConfig
|
definition: TaskDefinitionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskDefinitionSummary = {
|
export type TaskDefinitionSummary = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
priority: number
|
priority: number;
|
||||||
match_url: ReadonlyArray<string>
|
match_url: ReadonlyArray<string>;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type TaskDefinitionDetailed = TaskDefinitionSummary & {
|
export type TaskDefinitionDetailed = TaskDefinitionSummary & {
|
||||||
definition: TaskDefinitionConfig
|
definition: TaskDefinitionConfig;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type TaskDefinitionList = Paginated<TaskDefinitionSummary>
|
export type TaskDefinitionList = Paginated<TaskDefinitionSummary>;
|
||||||
|
|
||||||
export type TaskDefinitionErrorResponse = {
|
export type TaskDefinitionErrorResponse = {
|
||||||
error: string,
|
error: string;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
16
ui/app/types/task_inspect.d.ts
vendored
16
ui/app/types/task_inspect.d.ts
vendored
|
|
@ -1,20 +1,20 @@
|
||||||
// Types for api/tasks/inspect
|
// Types for api/tasks/inspect
|
||||||
|
|
||||||
export interface TaskInspectRequest {
|
export interface TaskInspectRequest {
|
||||||
url: string;
|
url: string;
|
||||||
preset?: string;
|
preset?: string;
|
||||||
handler?: string;
|
handler?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskInspectSuccess {
|
export interface TaskInspectSuccess {
|
||||||
// The structure depends on TaskResult, but at minimum:
|
// The structure depends on TaskResult, but at minimum:
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskInspectError {
|
export interface TaskInspectError {
|
||||||
error: string;
|
error: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectError;
|
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectError;
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,119 @@
|
||||||
import type { Paginated } from '~/types/responses'
|
import type { Paginated } from '~/types/responses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Task interface matching backend Task schema.
|
* Main Task interface matching backend Task schema.
|
||||||
*/
|
*/
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id?: number
|
id?: number;
|
||||||
name: string
|
name: string;
|
||||||
url: string
|
url: string;
|
||||||
folder?: string
|
folder?: string;
|
||||||
preset?: string
|
preset?: string;
|
||||||
timer?: string
|
timer?: string;
|
||||||
template?: string
|
template?: string;
|
||||||
cli?: string
|
cli?: string;
|
||||||
auto_start?: boolean
|
auto_start?: boolean;
|
||||||
handler_enabled?: boolean
|
handler_enabled?: boolean;
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
created_at?: string
|
created_at?: string;
|
||||||
updated_at?: string
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partial Task interface for PATCH operations.
|
* Partial Task interface for PATCH operations.
|
||||||
*/
|
*/
|
||||||
export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & {
|
export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & {
|
||||||
name?: string
|
name?: string;
|
||||||
url?: string
|
url?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paginated list of tasks response.
|
* Paginated list of tasks response.
|
||||||
*/
|
*/
|
||||||
export type TaskList = Paginated<Task>
|
export type TaskList = Paginated<Task>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task handler inspect request.
|
* Task handler inspect request.
|
||||||
*/
|
*/
|
||||||
export interface TaskInspectRequest {
|
export interface TaskInspectRequest {
|
||||||
url: string
|
url: string;
|
||||||
preset?: string
|
preset?: string;
|
||||||
handler?: string
|
handler?: string;
|
||||||
static_only?: boolean
|
static_only?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task handler inspect response - success.
|
* Task handler inspect response - success.
|
||||||
*/
|
*/
|
||||||
export interface TaskInspectSuccess {
|
export interface TaskInspectSuccess {
|
||||||
matched: true
|
matched: true;
|
||||||
handler: string
|
handler: string;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task handler inspect response - failure.
|
* Task handler inspect response - failure.
|
||||||
*/
|
*/
|
||||||
export interface TaskInspectFailure {
|
export interface TaskInspectFailure {
|
||||||
matched: false
|
matched: false;
|
||||||
message: string
|
message: string;
|
||||||
error: string
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task handler inspect response (union type).
|
* Task handler inspect response (union type).
|
||||||
*/
|
*/
|
||||||
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure
|
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task metadata response.
|
* Task metadata response.
|
||||||
*/
|
*/
|
||||||
export interface TaskMetadataResponse {
|
export interface TaskMetadataResponse {
|
||||||
id: string
|
id: string;
|
||||||
id_type: string | null
|
id_type: string | null;
|
||||||
title: string | null
|
title: string | null;
|
||||||
description: string
|
description: string;
|
||||||
uploader: string
|
uploader: string;
|
||||||
tags: Array<string>
|
tags: Array<string>;
|
||||||
year: number | null
|
year: number | null;
|
||||||
thumbnails: Record<string, string>
|
thumbnails: Record<string, string>;
|
||||||
json_file?: string
|
json_file?: string;
|
||||||
nfo_file?: string
|
nfo_file?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exported task for import/export functionality.
|
* Exported task for import/export functionality.
|
||||||
*/
|
*/
|
||||||
export interface ExportedTask extends Omit<Task, 'id' | 'created_at' | 'updated_at' | 'in_progress'> {
|
export interface ExportedTask extends Omit<
|
||||||
_type: string
|
Task,
|
||||||
_version: string
|
'id' | 'created_at' | 'updated_at' | 'in_progress'
|
||||||
|
> {
|
||||||
|
_type: string;
|
||||||
|
_version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic error response.
|
* Generic error response.
|
||||||
*/
|
*/
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error: string
|
error: string;
|
||||||
detail?: unknown
|
detail?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy alias for backward compatibility (deprecated).
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
* @deprecated Use Task instead
|
* @deprecated Use Task instead
|
||||||
*/
|
*/
|
||||||
export type task_item = Task
|
export type task_item = Task;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy alias for backward compatibility (deprecated).
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
* @deprecated Use ExportedTask instead
|
* @deprecated Use ExportedTask instead
|
||||||
*/
|
*/
|
||||||
export type exported_task = ExportedTask
|
export type exported_task = ExportedTask;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy alias for backward compatibility (deprecated).
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
* @deprecated Use ErrorResponse instead
|
* @deprecated Use ErrorResponse instead
|
||||||
*/
|
*/
|
||||||
export type error_response = ErrorResponse
|
export type error_response = ErrorResponse;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue