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/**"
|
||||
|
||||
env:
|
||||
PNPM_VERSION: 10
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
|
|
@ -43,49 +43,56 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Prepare frontend (nuxt prepare)
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm nuxt prepare
|
||||
run: bun nuxt prepare
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ui
|
||||
run: pnpm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run frontend type checking
|
||||
working-directory: ui
|
||||
run: pnpm run typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run frontend tsc
|
||||
working-directory: ui
|
||||
run: pnpm run lint:tsc
|
||||
run: bun run lint:tsc
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: ui
|
||||
run: pnpm run test:ci
|
||||
run: bun run test:ci
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
run: pnpm run generate
|
||||
run: bun run generate
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
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' }}
|
||||
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||
PNPM_VERSION: 10
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
|
|
@ -77,57 +77,64 @@ jobs:
|
|||
- name: Run Python tests
|
||||
run: uv run pytest app/tests/ -v --tb=short
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Prepare frontend (nuxt prepare)
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: development
|
||||
run: pnpm nuxt prepare
|
||||
run: bun nuxt prepare
|
||||
|
||||
- name: Run frontend linting
|
||||
working-directory: ui
|
||||
run: pnpm run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run frontend type checking
|
||||
working-directory: ui
|
||||
run: pnpm run typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Run frontend tsc
|
||||
working-directory: ui
|
||||
run: pnpm run lint:tsc
|
||||
run: bun run lint:tsc
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: ui
|
||||
run: pnpm run test:ci
|
||||
run: bun run test:ci
|
||||
|
||||
- name: Remove dev dependencies
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
run: bun install --frozen-lockfile --production --ignore-scripts
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: pnpm run generate
|
||||
run: bun run generate
|
||||
|
||||
- name: 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:
|
||||
PYTHON_VERSION: 3.11
|
||||
PNPM_VERSION: 10
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
|
|
@ -85,24 +85,31 @@ jobs:
|
|||
uv venv --system-site-packages --relocatable
|
||||
uv sync --link-mode=copy --active --extra installer
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Cache bun installation
|
||||
id: cache-bun
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ui
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm install --production --prefer-offline --frozen-lockfile
|
||||
pnpm run generate
|
||||
bun install --production --prefer-offline --frozen-lockfile
|
||||
bun run generate
|
||||
|
||||
- name: Clean build outputs (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
|
|
|
|||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -10,7 +10,7 @@
|
|||
"--port",
|
||||
"8082"
|
||||
],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeExecutable": "bun",
|
||||
"type": "node",
|
||||
"cwd": "${workspaceFolder}/ui",
|
||||
"env": {
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
"run",
|
||||
"generate"
|
||||
],
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeExecutable": "bun",
|
||||
"type": "node",
|
||||
"cwd": "${workspaceFolder}/ui",
|
||||
"console": "internalConsole",
|
||||
|
|
|
|||
273
.vscode/settings.json
vendored
273
.vscode/settings.json
vendored
|
|
@ -2,218 +2,9 @@
|
|||
"files.exclude": {
|
||||
"**/__pycache__": true
|
||||
},
|
||||
"autopep8.args": [
|
||||
"--max-line-length",
|
||||
"120",
|
||||
"--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"
|
||||
],
|
||||
"editor.rulers": [120],
|
||||
"cSpell.enabled": false,
|
||||
"css.styleSheets": ["ui/app/assets/css/*.css"],
|
||||
"search.exclude": {
|
||||
"ui/.nuxt": true,
|
||||
"ui/.output": true,
|
||||
|
|
@ -221,51 +12,23 @@
|
|||
"ui/exported": true,
|
||||
"ui/node_modules": true,
|
||||
"ui/.out": true,
|
||||
"**/.venv": true,
|
||||
"**/.venv": true
|
||||
},
|
||||
"eslint.format.enable": true,
|
||||
"eslint.format.enable": false,
|
||||
"eslint.ignoreUntitled": true,
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/tasks/*.json"
|
||||
],
|
||||
"url": "./app/schema/task_definition.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/config/conditions.json"
|
||||
],
|
||||
"url": "./app/schema/conditions.json"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
"python.testing.pytestArgs": ["app"],
|
||||
"oxc.path.oxfmt": "./ui/node_modules/.bin/oxfmt",
|
||||
"oxc.fmt.configPath": "./ui/.oxfmtrc.json",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
# syntax=docker/dockerfile:1.4
|
||||
FROM node:lts-alpine AS node_builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY ui ./
|
||||
ENV NODE_ENV=production
|
||||
RUN if [ ! -f "/app/exported/index.html" ]; then \
|
||||
npm install -g pnpm && \
|
||||
NODE_ENV=production pnpm install --frozen-lockfile --prod --ignore-scripts && \
|
||||
pnpm run generate; \
|
||||
npm install -g bun && \
|
||||
NODE_ENV=production bun install --frozen-lockfile --production && \
|
||||
bun run generate; \
|
||||
else echo "Skipping UI build, already built."; fi
|
||||
|
||||
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;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background-color: #F5F5F5;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.progress,
|
||||
|
|
@ -292,7 +292,7 @@ hr {
|
|||
}
|
||||
|
||||
.transparent-bg {
|
||||
opacity: 0.90;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.bg-fanart {
|
||||
|
|
@ -357,7 +357,7 @@ div.is-centered {
|
|||
}
|
||||
|
||||
.is-overflow-visible {
|
||||
overflow: visible !important
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.modal-content-max {
|
||||
|
|
|
|||
|
|
@ -6,25 +6,29 @@
|
|||
<div class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span class="icon"
|
||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||
/></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header-icon" v-if="reference">
|
||||
<button type="button" @click="showImport = !showImport">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}"
|
||||
/></span>
|
||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
|
||||
<div class="columns is-multiline is-mobile">
|
||||
|
||||
<div class="column is-12" v-if="showImport || !reference">
|
||||
<label class="label is-inline" for="import_string">
|
||||
<span class="icon"><i class="fa-solid fa-file-import" /></span>
|
||||
|
|
@ -33,11 +37,22 @@
|
|||
|
||||
<div class="field has-addons">
|
||||
<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 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>Import</span>
|
||||
</button>
|
||||
|
|
@ -56,8 +71,14 @@
|
|||
Name
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"
|
||||
placeholder="For the problematic channel or video name.">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
:disabled="addInProgress"
|
||||
placeholder="For the problematic channel or video name."
|
||||
/>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -73,8 +94,13 @@
|
|||
Enabled
|
||||
</label>
|
||||
<div class="control is-unselectable">
|
||||
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
||||
class="switch is-success" />
|
||||
<input
|
||||
id="enabled"
|
||||
type="checkbox"
|
||||
v-model="form.enabled"
|
||||
:disabled="addInProgress"
|
||||
class="switch is-success"
|
||||
/>
|
||||
<label for="enabled" class="is-unselectable">
|
||||
{{ form.enabled ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -93,8 +119,15 @@
|
|||
Priority
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="number" class="input" id="priority" v-model.number="form.priority"
|
||||
:disabled="addInProgress" min="0" placeholder="0">
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
id="priority"
|
||||
v-model.number="form.priority"
|
||||
:disabled="addInProgress"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -109,19 +142,28 @@
|
|||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
Condition 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
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress"
|
||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="filter"
|
||||
v-model="form.filter"
|
||||
:disabled="addInProgress"
|
||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
|
||||
/>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
|
||||
support.</span>
|
||||
<span
|
||||
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
|
||||
<code>||</code> support.</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -132,15 +174,22 @@
|
|||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
<TextareaAutocomplete
|
||||
id="cli_options"
|
||||
v-model="form.cli"
|
||||
:options="ytDlpOpt"
|
||||
:disabled="addInProgress"
|
||||
/>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
|
||||
options are supported
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
|
||||
>some are ignored</NuxtLink
|
||||
>. Use with caution.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -154,18 +203,38 @@
|
|||
</label>
|
||||
<div class="content">
|
||||
<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">
|
||||
<input type="text" class="input" :value="key" @input="updateExtraKey($event, key)"
|
||||
:disabled="addInProgress" placeholder="key_name">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:value="key"
|
||||
@input="updateExtraKey($event, key)"
|
||||
:disabled="addInProgress"
|
||||
placeholder="key_name"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" :value="value" @input="updateExtraValue(key, $event)"
|
||||
:disabled="addInProgress" placeholder="value">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:value="value"
|
||||
@input="updateExtraValue(key, $event)"
|
||||
:disabled="addInProgress"
|
||||
placeholder="value"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="removeExtra(key)"
|
||||
:disabled="addInProgress">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-danger"
|
||||
@click="removeExtra(key)"
|
||||
:disabled="addInProgress"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -174,36 +243,60 @@
|
|||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<input type="text" class="input" v-model="newExtraKey" :disabled="addInProgress"
|
||||
placeholder="new_key" @keyup.enter="addExtra">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="newExtraKey"
|
||||
:disabled="addInProgress"
|
||||
placeholder="new_key"
|
||||
@keyup.enter="addExtra"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" v-model="newExtraValue" :disabled="addInProgress"
|
||||
placeholder="new_value" @keyup.enter="addExtra">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="newExtraValue"
|
||||
:disabled="addInProgress"
|
||||
placeholder="new_value"
|
||||
@keyup.enter="addExtra"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-primary" @click="addExtra"
|
||||
:disabled="addInProgress || !newExtraKey || !newExtraValue">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
@click="addExtra"
|
||||
:disabled="addInProgress || !newExtraKey || !newExtraValue"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help ">
|
||||
<span class="help">
|
||||
<div class="message is-info">
|
||||
<div class="message-body content pl-0 is-small pt-1">
|
||||
<ul>
|
||||
<li>For advanced users only. This feature is meant to be expanded later.</li>
|
||||
<li>Keys must be lowercase with underscores (e.g., custom_field).</li>
|
||||
<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>
|
||||
For advanced users only. This feature is meant to be expanded later.
|
||||
</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>Keys must be lowercase with underscores (e.g., custom_field).</li>
|
||||
<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>
|
||||
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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -219,8 +312,13 @@
|
|||
Description
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress"
|
||||
placeholder="Describe what this condition does" />
|
||||
<textarea
|
||||
class="textarea"
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
:disabled="addInProgress"
|
||||
placeholder="Describe what this condition does"
|
||||
/>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -228,22 +326,30 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer mt-auto">
|
||||
<div class="card-footer-item">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="addForm">
|
||||
<button
|
||||
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>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
||||
type="button">
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -267,12 +373,23 @@
|
|||
<div class="field-body">
|
||||
<div class="field is-grouped">
|
||||
<div class="control is-expanded">
|
||||
<input type="url" class="input " id="url" v-model="test_data.url"
|
||||
:disabled="test_data.in_progress" placeholder="https://..." required>
|
||||
<input
|
||||
type="url"
|
||||
class="input"
|
||||
id="url"
|
||||
v-model="test_data.url"
|
||||
:disabled="test_data.in_progress"
|
||||
placeholder="https://..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit" :disabled="test_data.in_progress"
|
||||
:class="{ 'is-loading': test_data.in_progress }">
|
||||
<button
|
||||
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>Test</span>
|
||||
</button>
|
||||
|
|
@ -291,33 +408,49 @@
|
|||
Condition Filter
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="test_data.in_progress"
|
||||
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'" required>
|
||||
<input
|
||||
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>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code>
|
||||
support.</span>
|
||||
<span
|
||||
>yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
|
||||
<code>||</code> support.</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="is-bold" :class="{
|
||||
'has-text-success': true === logic_test,
|
||||
'has-text-danger': false === logic_test,
|
||||
}">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-check': true === logic_test,
|
||||
'fa-xmark': false === logic_test,
|
||||
'fa-question': null === logic_test,
|
||||
}" /></span>
|
||||
<span
|
||||
class="is-bold"
|
||||
:class="{
|
||||
'has-text-success': true === logic_test,
|
||||
'has-text-danger': false === logic_test,
|
||||
}"
|
||||
>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-check': true === logic_test,
|
||||
'fa-xmark': false === logic_test,
|
||||
'fa-question': null === logic_test,
|
||||
}"
|
||||
/></span>
|
||||
Filter Status:
|
||||
<template v-if="null === test_data?.data?.status">Not tested</template>
|
||||
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<pre style="height:60vh;"><code>{{ show_data() }}</code></pre>
|
||||
<pre style="height: 60vh"><code>{{ show_data() }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -331,310 +464,323 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import 'assets/css/bulma-switch.css';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import type { Condition } from '~/types/conditions'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import type { Condition } from '~/types/conditions';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import type { ImportedItem } from '~/types';
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'submit', payload: { reference: number | null | undefined, item: Condition }): void
|
||||
}>()
|
||||
(e: 'cancel'): void;
|
||||
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
reference?: number | null
|
||||
item: Condition
|
||||
addInProgress?: boolean
|
||||
}>()
|
||||
reference?: number | null;
|
||||
item: Condition;
|
||||
addInProgress?: boolean;
|
||||
}>();
|
||||
|
||||
const toast = useNotification()
|
||||
const showImport = useStorage('showImport', false)
|
||||
const box = useConfirm()
|
||||
const config = useConfigStore()
|
||||
const toast = useNotification();
|
||||
const showImport = useStorage('showImport', false);
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
|
||||
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)))
|
||||
const import_string = ref('')
|
||||
const newExtraKey = ref('')
|
||||
const newExtraValue = ref('')
|
||||
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)));
|
||||
const import_string = ref('');
|
||||
const newExtraKey = ref('');
|
||||
const newExtraValue = ref('');
|
||||
const test_data = ref<{
|
||||
show: boolean,
|
||||
url: string,
|
||||
in_progress: boolean,
|
||||
changed: boolean,
|
||||
data: { status: boolean | null, data: Record<string, any> }
|
||||
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
|
||||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
show: boolean;
|
||||
url: string;
|
||||
in_progress: boolean;
|
||||
changed: boolean;
|
||||
data: { status: boolean | null; data: Record<string, any> };
|
||||
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } });
|
||||
const showOptions = ref<boolean>(false);
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||
|
||||
if (!form.extras) {
|
||||
form.extras = {}
|
||||
form.extras = {};
|
||||
}
|
||||
|
||||
if (form.enabled === undefined) {
|
||||
form.enabled = true
|
||||
form.enabled = true;
|
||||
}
|
||||
|
||||
if (form.priority === undefined) {
|
||||
form.priority = 0
|
||||
form.priority = 0;
|
||||
}
|
||||
|
||||
if (form.description === undefined) {
|
||||
form.description = ''
|
||||
form.description = '';
|
||||
}
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
(ytDlpOpt.value = newOptions
|
||||
.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 required: (keyof Condition)[] = ['name', 'filter']
|
||||
const required: (keyof Condition)[] = ['name', 'filter'];
|
||||
|
||||
for (const key of required) {
|
||||
if (!form[key]) {
|
||||
toast.error(`The ${key} field is required.`)
|
||||
return
|
||||
toast.error(`The ${key} field is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.')
|
||||
return
|
||||
toast.error('Command options for yt-dlp or at least one extra option is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.cli && '' !== form.cli.trim()) {
|
||||
const options = await convertOptions(form.cli)
|
||||
const options = await convertOptions(form.cli);
|
||||
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) {
|
||||
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> => {
|
||||
try {
|
||||
const response = await convertCliOptions(args)
|
||||
return response.opts
|
||||
const response = await convertCliOptions(args);
|
||||
return response.opts;
|
||||
} catch (e: any) {
|
||||
toast.error(e.message)
|
||||
toast.error(e.message);
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const run_test = async (): Promise<void> => {
|
||||
if (!test_data.value.url) {
|
||||
toast.error('The URL is required for testing.', { force: true })
|
||||
return
|
||||
toast.error('The URL is required for testing.', { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(test_data.value.url)
|
||||
new URL(test_data.value.url);
|
||||
} catch {
|
||||
toast.error('The URL is invalid.', { force: true })
|
||||
return
|
||||
toast.error('The URL is invalid.', { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
test_data.value.in_progress = true
|
||||
test_data.value.data.status = false
|
||||
test_data.value.in_progress = true;
|
||||
test_data.value.data.status = false;
|
||||
|
||||
try {
|
||||
const response = await request('/api/conditions/test', {
|
||||
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) {
|
||||
toast.error(json.message || json.error || 'Unknown error', { force: true })
|
||||
return
|
||||
toast.error(json.message || json.error || 'Unknown error', { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
test_data.value.data = json
|
||||
test_data.value.changed = false
|
||||
test_data.value.data = json;
|
||||
test_data.value.changed = false;
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to test condition. ${error.message}`)
|
||||
toast.error(`Failed to test condition. ${error.message}`);
|
||||
} finally {
|
||||
test_data.value.in_progress = false
|
||||
test_data.value.in_progress = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importItem = async (): Promise<void> => {
|
||||
const val = import_string.value.trim()
|
||||
const val = import_string.value.trim();
|
||||
if (!val) {
|
||||
toast.error('The import string is required.')
|
||||
return
|
||||
toast.error('The import string is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = decode(val) as Condition & ImportedItem
|
||||
const item = decode(val) as Condition & ImportedItem;
|
||||
|
||||
if (!item._type || item._type !== 'condition') {
|
||||
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
|
||||
return
|
||||
toast.error(
|
||||
`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?'))) {
|
||||
return
|
||||
if (
|
||||
(form.filter || form.cli || Object.keys(form.extras).length > 0) &&
|
||||
!(await box.confirm('Overwrite the current form fields?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.name) {
|
||||
form.name = item.name
|
||||
form.name = item.name;
|
||||
}
|
||||
|
||||
if (item.filter) {
|
||||
form.filter = item.filter
|
||||
form.filter = item.filter;
|
||||
}
|
||||
|
||||
if (item.cli) {
|
||||
form.cli = item.cli
|
||||
form.cli = item.cli;
|
||||
}
|
||||
|
||||
if (item.extras) {
|
||||
form.extras = { ...item.extras }
|
||||
form.extras = { ...item.extras };
|
||||
}
|
||||
|
||||
if (item.enabled !== undefined) {
|
||||
form.enabled = item.enabled
|
||||
form.enabled = item.enabled;
|
||||
}
|
||||
|
||||
if (item.priority !== undefined) {
|
||||
form.priority = item.priority
|
||||
form.priority = item.priority;
|
||||
}
|
||||
|
||||
if (item.description !== undefined) {
|
||||
form.description = item.description
|
||||
form.description = item.description;
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
showImport.value = false
|
||||
import_string.value = '';
|
||||
showImport.value = false;
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to parse import string. ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Failed to parse import string. ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const show_data = (): string => {
|
||||
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(() => {
|
||||
if (Object.keys(test_data.value.data?.data ?? {}).length < 1) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!test_data.value.changed) {
|
||||
return test_data.value.data.status
|
||||
return test_data.value.data.status;
|
||||
}
|
||||
|
||||
try {
|
||||
const st = match_str(form.filter, test_data.value.data.data)
|
||||
console.log('Logic test:', st, form.filter, test_data.value.data.data)
|
||||
return st
|
||||
const st = match_str(form.filter, test_data.value.data.data);
|
||||
console.log('Logic test:', st, form.filter, test_data.value.data.data);
|
||||
return st;
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
return false
|
||||
console.error(e);
|
||||
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 => {
|
||||
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
|
||||
return Number(value)
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if ('true' === value.toLowerCase()) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
if ('false' === value.toLowerCase()) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const addExtra = (): void => {
|
||||
const key = newExtraKey.value.trim()
|
||||
const value = newExtraValue.value.trim()
|
||||
const key = newExtraKey.value.trim();
|
||||
const value = newExtraValue.value.trim();
|
||||
|
||||
if (!key || !value) {
|
||||
toast.error('Both key and value are required.')
|
||||
return
|
||||
toast.error('Both key and value are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateKey(key)) {
|
||||
toast.error('Key must be lower_case.')
|
||||
return
|
||||
toast.error('Key must be lower_case.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.extras[key]) {
|
||||
toast.error(`Key '${key}' already exists.`)
|
||||
return
|
||||
toast.error(`Key '${key}' already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
form.extras[key] = parseValue(value)
|
||||
newExtraKey.value = ''
|
||||
newExtraValue.value = ''
|
||||
}
|
||||
form.extras[key] = parseValue(value);
|
||||
newExtraKey.value = '';
|
||||
newExtraValue.value = '';
|
||||
};
|
||||
|
||||
const removeExtra = (key: string): void => {
|
||||
const { [key]: _, ...rest } = form.extras
|
||||
form.extras = rest
|
||||
}
|
||||
const { [key]: _, ...rest } = form.extras;
|
||||
form.extras = rest;
|
||||
};
|
||||
|
||||
const updateExtraKey = (event: Event, oldKey: string): void => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const newKey = target.value.trim()
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newKey = target.value.trim();
|
||||
|
||||
if (!newKey) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateKey(newKey)) {
|
||||
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.')
|
||||
target.value = oldKey
|
||||
return
|
||||
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.');
|
||||
target.value = oldKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newKey !== oldKey) {
|
||||
if (form.extras[newKey]) {
|
||||
toast.error(`Key '${newKey}' already exists.`)
|
||||
target.value = oldKey
|
||||
return
|
||||
toast.error(`Key '${newKey}' already exists.`);
|
||||
target.value = oldKey;
|
||||
return;
|
||||
}
|
||||
|
||||
const value = form.extras[oldKey]
|
||||
const { [oldKey]: _, ...rest } = form.extras
|
||||
form.extras = { ...rest, [newKey]: value }
|
||||
const value = form.extras[oldKey];
|
||||
const { [oldKey]: _, ...rest } = form.extras;
|
||||
form.extras = { ...rest, [newKey]: value };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateExtraValue = (key: string, event: Event): void => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value.trim()
|
||||
form.extras[key] = value ? parseValue(value) : ''
|
||||
}
|
||||
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value.trim();
|
||||
form.extras[key] = value ? parseValue(value) : '';
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@
|
|||
|
||||
<section class="modal-card-body">
|
||||
<p class="mb-3 title is-5" v-if="!html_message">{{ message }}</p>
|
||||
<div class="content" v-if="html_message" v-html="html_message" style="max-height: 40vh; overflow: auto;" />
|
||||
<div
|
||||
class="content"
|
||||
v-if="html_message"
|
||||
v-html="html_message"
|
||||
style="max-height: 40vh; overflow: auto"
|
||||
/>
|
||||
|
||||
<div v-if="options?.length">
|
||||
<hr class="">
|
||||
<label v-for="opt in options" :key="opt.key" class="checkbox is-block mb-2 is-unselectable">
|
||||
<hr class="" />
|
||||
<label
|
||||
v-for="opt in options"
|
||||
:key="opt.key"
|
||||
class="checkbox is-block mb-2 is-unselectable"
|
||||
>
|
||||
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
|
|
@ -21,14 +30,23 @@
|
|||
</section>
|
||||
|
||||
<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">
|
||||
<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 }}
|
||||
</button>
|
||||
</div>
|
||||
<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 }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -39,74 +57,77 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm'
|
||||
default: 'Confirm',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Are you sure?'
|
||||
default: 'Are you sure?',
|
||||
},
|
||||
html_message: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array as () => Array<{ key: string; label: string, checked?: boolean }>,
|
||||
default: () => []
|
||||
type: Array as () => Array<{ key: string; label: string; checked?: boolean }>,
|
||||
default: () => [],
|
||||
},
|
||||
confirm_button_label: {
|
||||
type: String,
|
||||
default: 'Confirm'
|
||||
default: 'Confirm',
|
||||
},
|
||||
cancel_button_label: {
|
||||
type: String,
|
||||
default: 'Cancel'
|
||||
default: 'Cancel',
|
||||
},
|
||||
confirm_button_color: {
|
||||
type: String,
|
||||
default: 'is-danger'
|
||||
default: 'is-danger',
|
||||
},
|
||||
cancel_button_color: {
|
||||
type: String,
|
||||
default: 'is-success'
|
||||
}
|
||||
})
|
||||
default: 'is-success',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', options: Record<string, boolean>): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
(e: 'confirm', options: Record<string, boolean>): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const confirmBtn = ref<HTMLButtonElement | null>(null)
|
||||
const selected = reactive<Record<string, boolean>>({})
|
||||
const confirmBtn = ref<HTMLButtonElement | null>(null);
|
||||
const selected = reactive<Record<string, boolean>>({});
|
||||
|
||||
watch(() => props.visible, async visible => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.options) {
|
||||
for (const opt of props.options) {
|
||||
selected[opt.key] ??= false
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
confirmBtn.value?.focus()
|
||||
}, { immediate: true })
|
||||
if (props.options) {
|
||||
for (const opt of props.options) {
|
||||
selected[opt.key] ??= false;
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
confirmBtn.value?.focus();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleConfirm = () => emit('confirm', { ...selected })
|
||||
const cancel = () => emit('cancel')
|
||||
const handleConfirm = () => emit('confirm', { ...selected });
|
||||
const cancel = () => emit('cancel');
|
||||
|
||||
onMounted(() => disableOpacity())
|
||||
onBeforeUnmount(() => enableOpacity())
|
||||
onMounted(() => disableOpacity());
|
||||
onBeforeUnmount(() => enableOpacity());
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
<template>
|
||||
<div class="message is-warning" v-if="status !== 'connected'">
|
||||
<div class="message-body">
|
||||
<span class="icon"><i class="fas"
|
||||
:class="{ 'fa-info-circle': status === 'disconnected', 'fa-spinner fa-pulse': status === 'connecting' }" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fas"
|
||||
:class="{
|
||||
'fa-info-circle': status === 'disconnected',
|
||||
'fa-spinner fa-pulse': status === 'connecting',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="status === 'disconnected'">
|
||||
Websocket connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here
|
||||
</NuxtLink> to reconnect.
|
||||
</span>
|
||||
<span v-else-if="status === 'connecting'">
|
||||
Connecting to websocket server...
|
||||
Websocket connection lost,
|
||||
<NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here </NuxtLink>
|
||||
to reconnect.
|
||||
</span>
|
||||
<span v-else-if="status === 'connecting'"> Connecting to websocket server... </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { connectionStatus } from '~/stores/SocketStore'
|
||||
defineProps<{ 'status': connectionStatus }>()
|
||||
defineEmits<{ (e: "reconnect"): void }>()
|
||||
import type { connectionStatus } from '~/stores/SocketStore';
|
||||
defineProps<{ status: connectionStatus }>();
|
||||
defineEmits<{ (e: 'reconnect'): void }>();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,22 @@
|
|||
<div class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span class="icon"
|
||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||
/></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header-icon" v-if="reference">
|
||||
<button type="button" @click="showImport = !showImport">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}"
|
||||
/></span>
|
||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -31,11 +37,22 @@
|
|||
|
||||
<div class="field has-addons">
|
||||
<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 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>Import</span>
|
||||
</button>
|
||||
|
|
@ -65,7 +82,12 @@
|
|||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||
<span>Field Description</span>
|
||||
</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">
|
||||
A short description of the field, it will be shown in the UI.
|
||||
</span>
|
||||
|
|
@ -80,11 +102,17 @@
|
|||
</label>
|
||||
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,24 +123,36 @@
|
|||
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||
<span>Associated yt-dlp option</span>
|
||||
</label>
|
||||
<InputAutocomplete v-model="form.field" :options="ytDlpOptions" :disabled="addInProgress"
|
||||
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
|
||||
<InputAutocomplete
|
||||
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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="column is-6 is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<span class="icon"><i class="fas fa-sort-numeric-up" /></span>
|
||||
<span>Field Order</span>
|
||||
</label>
|
||||
<input type="number" v-model.number="form.order" class="input" :disabled="addInProgress" />
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="form.order"
|
||||
class="input"
|
||||
:disabled="addInProgress"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -125,9 +165,11 @@
|
|||
</label>
|
||||
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
|
||||
<span class="help is-bold">
|
||||
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r"
|
||||
target="_blank">
|
||||
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||
The icon of the field, must be from
|
||||
<NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
|
||||
font-awesome</NuxtLink
|
||||
>
|
||||
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,15 +179,24 @@
|
|||
<div class="card-footer mt-auto">
|
||||
<div class="card-footer-item">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="dlFieldForm">
|
||||
<button
|
||||
class="button is-fullwidth is-primary"
|
||||
:disabled="addInProgress"
|
||||
type="submit"
|
||||
:class="{ 'is-loading': addInProgress }"
|
||||
form="dlFieldForm"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
||||
type="button">
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -159,161 +210,172 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
import type { ImportedItem } from '~/types'
|
||||
import { decode } from '~/utils'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { ImportedItem } from '~/types';
|
||||
import { decode } from '~/utils';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'submit', payload: { reference: number | null | undefined, item: DLField }): void
|
||||
}>()
|
||||
(e: 'cancel'): void;
|
||||
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
reference?: number | null
|
||||
item: DLField
|
||||
addInProgress?: boolean
|
||||
}>()
|
||||
reference?: number | null;
|
||||
item: DLField;
|
||||
addInProgress?: boolean;
|
||||
}>();
|
||||
|
||||
const toast = useNotification()
|
||||
const box = useConfirm()
|
||||
const config = useConfigStore()
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
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 ytDlpOptions = ref<AutoCompleteOptions>([])
|
||||
const showImport = useStorage('showDlFieldsImport', false)
|
||||
const import_string = ref('')
|
||||
const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item)));
|
||||
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
||||
const showImport = useStorage('showDlFieldsImport', false);
|
||||
const import_string = ref('');
|
||||
|
||||
if (!form.extras) {
|
||||
form.extras = {}
|
||||
form.extras = {};
|
||||
}
|
||||
|
||||
if (!form.kind) {
|
||||
form.kind = 'string'
|
||||
form.kind = 'string';
|
||||
}
|
||||
|
||||
if (!form.description) {
|
||||
form.description = ''
|
||||
form.description = '';
|
||||
}
|
||||
|
||||
if (!form.value) {
|
||||
form.value = ''
|
||||
form.value = '';
|
||||
}
|
||||
|
||||
if (!form.icon) {
|
||||
form.icon = ''
|
||||
form.icon = '';
|
||||
}
|
||||
|
||||
if (!form.order) {
|
||||
form.order = 1
|
||||
form.order = 1;
|
||||
}
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
(ytDlpOptions.value = newOptions
|
||||
.filter((opt) => !opt.ignored)
|
||||
.flatMap((opt) =>
|
||||
opt.flags
|
||||
.filter((flag) => flag.startsWith('--'))
|
||||
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||
)),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const importItem = async (): Promise<void> => {
|
||||
const val = import_string.value.trim()
|
||||
const val = import_string.value.trim();
|
||||
if (!val) {
|
||||
toast.error('The import string is required.')
|
||||
return
|
||||
toast.error('The import string is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = decode(val) as DLField & ImportedItem
|
||||
const item = decode(val) as DLField & ImportedItem;
|
||||
|
||||
if (!item._type || item._type !== 'dl_field') {
|
||||
toast.error(`Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`)
|
||||
return
|
||||
toast.error(
|
||||
`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?'))) {
|
||||
return
|
||||
if (
|
||||
(form.name || form.field || form.description) &&
|
||||
!(await box.confirm('Overwrite the current form fields?'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.name) {
|
||||
form.name = item.name
|
||||
form.name = item.name;
|
||||
}
|
||||
|
||||
if (item.field) {
|
||||
form.field = item.field
|
||||
form.field = item.field;
|
||||
}
|
||||
|
||||
if (item.description !== undefined) {
|
||||
form.description = item.description
|
||||
form.description = item.description;
|
||||
}
|
||||
|
||||
if (item.kind) {
|
||||
form.kind = item.kind
|
||||
form.kind = item.kind;
|
||||
}
|
||||
|
||||
if (item.icon !== undefined) {
|
||||
form.icon = item.icon
|
||||
form.icon = item.icon;
|
||||
}
|
||||
|
||||
if (item.order !== undefined) {
|
||||
form.order = item.order
|
||||
form.order = item.order;
|
||||
}
|
||||
|
||||
if (item.value !== undefined) {
|
||||
form.value = item.value
|
||||
form.value = item.value;
|
||||
}
|
||||
|
||||
if (item.extras) {
|
||||
form.extras = { ...item.extras }
|
||||
form.extras = { ...item.extras };
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
showImport.value = false
|
||||
import_string.value = '';
|
||||
showImport.value = false;
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to parse import string. ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Failed to parse import string. ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkInfo = (): void => {
|
||||
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description']
|
||||
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description'];
|
||||
|
||||
for (const key of required) {
|
||||
if (!form[key]) {
|
||||
toast.error(`The ${key} field is required.`)
|
||||
return
|
||||
toast.error(`The ${key} field is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!form.order || form.order < 1) {
|
||||
toast.error('Order must be a positive number.')
|
||||
return
|
||||
toast.error('Order must be a positive number.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fieldTypes.includes(form.kind)) {
|
||||
toast.error(`Invalid field type: ${form.kind}`)
|
||||
return
|
||||
toast.error(`Invalid field type: ${form.kind}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) {
|
||||
toast.error('Invalid field format, it must start with "--" and contain no spaces.')
|
||||
return
|
||||
toast.error('Invalid field format, it must start with "--" and contain no spaces.');
|
||||
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) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<form class="modal is-active" @submit.prevent="updateItems">
|
||||
<div class="modal-background" @click="cancel" />
|
||||
<div class="modal-card" style="min-width:calc(100vw - 30vw);">
|
||||
<div class="modal-card" style="min-width: calc(100vw - 30vw)">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Manage Custom Fields</p>
|
||||
<button type="button" class="delete" aria-label="close" @click="cancel" />
|
||||
|
|
@ -18,21 +18,33 @@
|
|||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<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>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button type="button" class="button is-info" @click="loadContent()"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-info"
|
||||
@click="loadContent()"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">The idea behind the custom fields is to allow you to add new fields to the download
|
||||
form to automate some of the yt-dlp options.</span>
|
||||
<span class="subtitle"
|
||||
>The idea behind the custom fields is to allow you to add new fields to the download
|
||||
form to automate some of the yt-dlp options.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -40,7 +52,11 @@
|
|||
<div class="box">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-12-mobile">
|
||||
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger">
|
||||
<NuxtLink
|
||||
@click="deleteField(index)"
|
||||
:disabled="isLoading"
|
||||
class="has-text-danger"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-trash" /></span>
|
||||
<span>Delete Field</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -54,12 +70,17 @@
|
|||
</label>
|
||||
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
|
||||
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
|
||||
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind"
|
||||
v-text="kind" />
|
||||
<option
|
||||
v-for="kind in Object.values(FieldTypes)"
|
||||
:key="`kind-${kind}`"
|
||||
:value="kind"
|
||||
v-text="kind"
|
||||
/>
|
||||
</select>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox.
|
||||
Field Type. String is a single line input, Text is a multi-line input, Bool is
|
||||
a checkbox.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,10 +102,17 @@
|
|||
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||
<span>Associated yt-dlp option</span>
|
||||
</label>
|
||||
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
|
||||
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" />
|
||||
<InputAutocomplete
|
||||
v-model="item.field"
|
||||
:options="ytDlpOptions"
|
||||
:disabled="isLoading"
|
||||
placeholder="Type or select a yt-dlp option"
|
||||
:multiple="false"
|
||||
:openOnFocus="true"
|
||||
/>
|
||||
<span class="help is-bold">
|
||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
|
||||
<code>-w</code>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,7 +122,12 @@
|
|||
<span class="icon"><i class="fas fa-info-circle" /></span>
|
||||
<span>Field Description</span>
|
||||
</label>
|
||||
<input type="text" v-model="item.description" class="input" :disabled="isLoading" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="item.description"
|
||||
class="input"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<span class="help is-bold">
|
||||
A short description of the field, it will be shown in the UI.
|
||||
</span>
|
||||
|
|
@ -109,7 +142,8 @@
|
|||
</label>
|
||||
<input type="number" v-model="item.order" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The order of the field, used to sort the fields in the UI. Lower numbers will appear first.
|
||||
The order of the field, used to sort the fields in the UI. Lower numbers will
|
||||
appear first.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,9 +156,11 @@
|
|||
</label>
|
||||
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
|
||||
<span class="help is-bold">
|
||||
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r"
|
||||
target="_blank">
|
||||
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||
The icon of the field, must be from
|
||||
<NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
|
||||
font-awesome</NuxtLink
|
||||
>
|
||||
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,7 +176,7 @@
|
|||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-save" /></span>
|
||||
|
|
@ -148,7 +184,12 @@
|
|||
</button>
|
||||
</div>
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -160,132 +201,139 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { ref } from 'vue';
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
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 items = ref<DLField[]>([])
|
||||
const config = useConfigStore()
|
||||
const ytDlpOptions = ref<AutoCompleteOptions>([])
|
||||
const isLoading = ref<boolean>(false);
|
||||
const items = ref<DLField[]>([]);
|
||||
const config = useConfigStore();
|
||||
const ytDlpOptions = ref<AutoCompleteOptions>([]);
|
||||
|
||||
const FieldTypes = {
|
||||
STRING: 'string',
|
||||
TEXT: 'text',
|
||||
BOOL: 'bool'
|
||||
}
|
||||
BOOL: 'bool',
|
||||
};
|
||||
|
||||
const cancel = () => emitter('cancel')
|
||||
const cancel = () => emitter('cancel');
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/dl_fields')
|
||||
isLoading.value = true;
|
||||
const response = await request('/api/dl_fields');
|
||||
|
||||
const data = await response.json()
|
||||
const data = await response.json();
|
||||
if (0 === data.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = data
|
||||
items.value = data;
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error('Failed to fetch page content.')
|
||||
console.error(e);
|
||||
toast.error('Failed to fetch page content.');
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateItems = async () => {
|
||||
|
||||
for (const item of items.value) {
|
||||
if (validateItem(item, items.value.indexOf(item) + 1)) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
const resp = await request('/api/dl_fields', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(items.value)
|
||||
})
|
||||
body: JSON.stringify(items.value),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json()
|
||||
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`)
|
||||
return
|
||||
const error = await resp.json();
|
||||
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Fields updated successfully.')
|
||||
emitter('cancel')
|
||||
toast.success('Fields updated successfully.');
|
||||
emitter('cancel');
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addNewField = () => items.value.push({
|
||||
name: '',
|
||||
description: '',
|
||||
kind: 'string',
|
||||
field: '',
|
||||
value: '',
|
||||
icon: '',
|
||||
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
|
||||
extras: {}
|
||||
})
|
||||
const addNewField = () =>
|
||||
items.value.push({
|
||||
name: '',
|
||||
description: '',
|
||||
kind: 'string',
|
||||
field: '',
|
||||
value: '',
|
||||
icon: '',
|
||||
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
|
||||
extras: {},
|
||||
});
|
||||
|
||||
const deleteField = (index: number) => items.value.splice(index, 1)
|
||||
const deleteField = (index: number) => items.value.splice(index, 1);
|
||||
|
||||
const validateItem = (item: DLField, index: number): boolean => {
|
||||
const requiredFields = ['name', 'field', 'kind', 'description']
|
||||
const requiredFields = ['name', 'field', 'kind', 'description'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!item[field as keyof DLField]) {
|
||||
toast.error(`${item.name || index}: Field ${field} is required.`)
|
||||
return false
|
||||
toast.error(`${item.name || index}: Field ${field} is required.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.order || item.order < 1) {
|
||||
toast.error(`${item.name || index}: Order must be a positive number.`)
|
||||
return false
|
||||
toast.error(`${item.name || index}: Order must be a positive number.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Object.values(FieldTypes).includes(item.kind)) {
|
||||
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`)
|
||||
return false
|
||||
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
||||
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
||||
return false
|
||||
toast.error(
|
||||
`${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
|
||||
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
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(
|
||||
opt => opt.flags.filter(flag => flag.startsWith('--')
|
||||
).map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
(ytDlpOptions.value = newOptions
|
||||
.filter((opt) => !opt.ignored)
|
||||
.flatMap((opt) =>
|
||||
opt.flags
|
||||
.filter((flag) => flag.startsWith('--'))
|
||||
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||
)),
|
||||
{ immediate: true },
|
||||
);
|
||||
onMounted(async () => {
|
||||
disableOpacity()
|
||||
await loadContent()
|
||||
})
|
||||
onBeforeUnmount(() => enableOpacity())
|
||||
disableOpacity();
|
||||
await loadContent();
|
||||
});
|
||||
onBeforeUnmount(() => enableOpacity());
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,19 +6,40 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</label>
|
||||
<div class="control is-expanded" v-if="'string' === type">
|
||||
<input :id="`dlf-${id}`" :type="type" class="input" v-model="model" :placeholder="placeholder"
|
||||
:disabled="disabled" />
|
||||
<input
|
||||
:id="`dlf-${id}`"
|
||||
:type="type"
|
||||
class="input"
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded" v-if="'text' === type">
|
||||
<textarea class="textarea is-pre" :id="`dlf-${id}`" v-model="model" :placeholder="placeholder"
|
||||
:disabled="disabled"></textarea>
|
||||
<textarea
|
||||
class="textarea is-pre"
|
||||
:id="`dlf-${id}`"
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="control is-expanded" v-if="'bool' === type">
|
||||
<input type="checkbox" :id="`dlf-${id}`" v-model="model" class="switch is-success" :disabled="disabled" />
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="`dlf-${id}`"
|
||||
v-model="model"
|
||||
class="switch is-success"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<label :for="`dlf-${id}`" class="label is-unselectable">
|
||||
{{ model ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -39,14 +60,14 @@
|
|||
import type { ModelRef } from 'vue';
|
||||
import type { DLFieldType } from '~/types/dl_fields';
|
||||
defineProps<{
|
||||
id: number|string,
|
||||
label: string,
|
||||
field?: string,
|
||||
type: DLFieldType,
|
||||
description?: string
|
||||
icon?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const model = defineModel() as ModelRef<string>
|
||||
id: number | string;
|
||||
label: string;
|
||||
field?: string;
|
||||
type: DLFieldType;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
const model = defineModel() as ModelRef<string>;
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
/* container fades */
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: opacity .18s ease;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
|
|
@ -13,24 +13,31 @@
|
|||
/* animate the card itself */
|
||||
.dialog-enter-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 {
|
||||
transform: translateY(-8px);
|
||||
opacity: .98;
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.dialog-leave-to .modal-card {
|
||||
transform: translateY(-8px);
|
||||
opacity: .98;
|
||||
opacity: 0.98;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<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-card" @keydown.enter.stop.prevent="onEnter">
|
||||
<header class="modal-card-head p-4">
|
||||
|
|
@ -46,8 +53,14 @@
|
|||
<!-- prompt input -->
|
||||
<div v-if="'prompt' === state.current?.type" class="field">
|
||||
<div class="control">
|
||||
<input ref="inputEl" class="input" type="text" v-model="localInput"
|
||||
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop />
|
||||
<input
|
||||
ref="inputEl"
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="localInput"
|
||||
:placeholder="(state.current?.opts as any)?.placeholder ?? ''"
|
||||
@keyup.stop
|
||||
/>
|
||||
</div>
|
||||
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
|
||||
<span class="icon-text">
|
||||
|
|
@ -56,8 +69,14 @@
|
|||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||
class="content" v-html="(state.current?.opts as ConfirmOptions)?.rawHTML" />
|
||||
<div
|
||||
v-else-if="
|
||||
'confirm' === state.current?.type &&
|
||||
(state.current?.opts as ConfirmOptions)?.rawHTML
|
||||
"
|
||||
class="content"
|
||||
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
||||
|
|
@ -70,12 +89,18 @@
|
|||
</button>
|
||||
</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="control">
|
||||
<button id="primaryButton" class="button" @click="onEnter"
|
||||
<button
|
||||
id="primaryButton"
|
||||
class="button"
|
||||
@click="onEnter"
|
||||
: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"><i class="fas fa-check" /></span>
|
||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
||||
|
|
@ -100,58 +125,61 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog'
|
||||
import { ref, watch, nextTick, computed } from 'vue';
|
||||
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) => {
|
||||
if (state.current) {
|
||||
disableOpacity()
|
||||
}
|
||||
else {
|
||||
enableOpacity()
|
||||
}
|
||||
watch(
|
||||
() => state.current,
|
||||
(cur) => {
|
||||
if (state.current) {
|
||||
disableOpacity();
|
||||
} else {
|
||||
enableOpacity();
|
||||
}
|
||||
|
||||
localInput.value = 'prompt' === cur?.type ? (cur.opts as any).initial ?? '' : ''
|
||||
}, { immediate: true })
|
||||
localInput.value = 'prompt' === cur?.type ? ((cur.opts as any).initial ?? '') : '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const inputEl = ref<HTMLInputElement>()
|
||||
const inputEl = ref<HTMLInputElement>();
|
||||
const focusPrimary = () => {
|
||||
const root = document.getElementById('app-dialog-host')
|
||||
const root = document.getElementById('app-dialog-host');
|
||||
if (!root) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const btn = root.querySelector<HTMLButtonElement>('#primaryButton')
|
||||
btn?.focus()
|
||||
}
|
||||
const btn = root.querySelector<HTMLButtonElement>('#primaryButton');
|
||||
btn?.focus();
|
||||
};
|
||||
const focusInput = async () => {
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
if ('prompt' === state.current?.type) {
|
||||
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }))
|
||||
return
|
||||
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }));
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(focusPrimary)
|
||||
}
|
||||
requestAnimationFrame(focusPrimary);
|
||||
};
|
||||
|
||||
const onCancel = () => cancel()
|
||||
const onEnter = () => confirm(localInput.value)
|
||||
const onCancel = () => cancel();
|
||||
const onEnter = () => confirm(localInput.value);
|
||||
|
||||
const defaultTitle = computed(() => {
|
||||
if (!state.current) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
switch (state.current.type) {
|
||||
case 'alert':
|
||||
return 'Alert'
|
||||
return 'Alert';
|
||||
case 'confirm':
|
||||
return 'Confirm'
|
||||
return 'Confirm';
|
||||
case 'prompt':
|
||||
return 'Input required'
|
||||
return 'Input required';
|
||||
default:
|
||||
return 'Dialog'
|
||||
return 'Dialog';
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true" type="button"
|
||||
aria-controls="dropdown-menu" @click="toggle" :class="button_classes">
|
||||
<button
|
||||
class="button is-fullwidth is-justify-content-space-between"
|
||||
aria-haspopup="true"
|
||||
type="button"
|
||||
aria-controls="dropdown-menu"
|
||||
@click="toggle"
|
||||
:class="button_classes"
|
||||
>
|
||||
<span class="icon" v-if="icons"><i :class="icons" /></span>
|
||||
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
|
||||
<div class="is-pulled-right">
|
||||
|
|
@ -33,131 +39,131 @@
|
|||
</template>
|
||||
|
||||
<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({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Select'
|
||||
default: 'Select',
|
||||
},
|
||||
icons: {
|
||||
type: String,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
button_classes: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: '',
|
||||
},
|
||||
hide_label_on_mobile: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropUp = ref(false)
|
||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown')
|
||||
const menu = useTemplateRef<HTMLDivElement>('menu')
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const isOpen = ref(false);
|
||||
const dropUp = ref(false);
|
||||
const dropdown = useTemplateRef<HTMLDivElement>('dropdown');
|
||||
const menu = useTemplateRef<HTMLDivElement>('menu');
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile)
|
||||
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile);
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!dropdown.value || !isOpen.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const menuHeight = menu.value?.offsetHeight || 300
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
||||
const menuHeight = menu.value?.offsetHeight || 300;
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
// Determine if dropdown should appear above or below
|
||||
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow
|
||||
dropUp.value = shouldDropUp
|
||||
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow;
|
||||
dropUp.value = shouldDropUp;
|
||||
|
||||
// Calculate position
|
||||
const left = triggerRect.left
|
||||
const width = triggerRect.width
|
||||
const left = triggerRect.left;
|
||||
const width = triggerRect.width;
|
||||
|
||||
if (shouldDropUp) {
|
||||
// Position above the trigger
|
||||
const bottom = window.innerHeight - triggerRect.top
|
||||
const bottom = window.innerHeight - triggerRect.top;
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
bottom: `${bottom}px`,
|
||||
width: `${width}px`,
|
||||
top: 'auto'
|
||||
}
|
||||
top: 'auto',
|
||||
};
|
||||
} else {
|
||||
// Position below the trigger
|
||||
const top = triggerRect.bottom
|
||||
const top = triggerRect.bottom;
|
||||
menuStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
bottom: 'auto'
|
||||
}
|
||||
bottom: 'auto',
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
isOpen.value = !isOpen.value;
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle_slot_click = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.dropdown-item')) {
|
||||
isOpen.value = false
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle_event = (event: MouseEvent) => {
|
||||
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)) {
|
||||
isOpen.value = false
|
||||
isOpen.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
updatePosition();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(() => emitter('open_state', isOpen.value))
|
||||
watchEffect(() => emitter('open_state', isOpen.value));
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handle_event)
|
||||
window.addEventListener('scroll', handleScroll, true) // Use capture to catch all scroll events
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
document.addEventListener('click', handle_event);
|
||||
window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handle_event)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
document.removeEventListener('click', handle_event);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -192,7 +198,14 @@ onBeforeUnmount(() => {
|
|||
.dropdown.dropdown-portal .dropdown-content {
|
||||
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
|
||||
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
|
||||
box-shadow: var(--bulma-dropdown-content-shadow, var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02)));
|
||||
box-shadow: var(
|
||||
--bulma-dropdown-content-shadow,
|
||||
var(
|
||||
--bulma-shadow,
|
||||
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
|
||||
0 0px 0 1px rgba(10, 10, 10, 0.02)
|
||||
)
|
||||
);
|
||||
padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
|
||||
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,31 +16,31 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
|
||||
defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const emitter = defineEmits(['closeModel']);
|
||||
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
emitter('closeModel');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handle_event)
|
||||
disableOpacity()
|
||||
})
|
||||
document.addEventListener('keydown', handle_event);
|
||||
disableOpacity();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
enableOpacity()
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
})
|
||||
enableOpacity();
|
||||
document.removeEventListener('keydown', handle_event);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,53 +1,58 @@
|
|||
<template>
|
||||
<ModalText :isLoading="isLoading" :data="data" :externalModel="externalModel"
|
||||
@closeModel="() => emitter('closeModel')" :code_classes="code_classes" />
|
||||
<ModalText
|
||||
:isLoading="isLoading"
|
||||
:data="data"
|
||||
:externalModel="externalModel"
|
||||
@closeModel="() => emitter('closeModel')"
|
||||
:code_classes="code_classes"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const toast = useNotification()
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
||||
const toast = useNotification();
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||
|
||||
const props = defineProps<{
|
||||
link?: string
|
||||
preset?: string
|
||||
cli?: string
|
||||
useUrl?: boolean
|
||||
externalModel?: boolean
|
||||
code_classes?: string
|
||||
}>()
|
||||
link?: string;
|
||||
preset?: string;
|
||||
cli?: string;
|
||||
useUrl?: boolean;
|
||||
externalModel?: boolean;
|
||||
code_classes?: string;
|
||||
}>();
|
||||
|
||||
const isLoading = ref<boolean>(true)
|
||||
const data = ref<any>({})
|
||||
const isLoading = ref<boolean>(true);
|
||||
const data = ref<any>({});
|
||||
|
||||
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) {
|
||||
const params = new URLSearchParams()
|
||||
const params = new URLSearchParams();
|
||||
if (props.preset) {
|
||||
params.append('preset', props.preset)
|
||||
params.append('preset', props.preset);
|
||||
}
|
||||
if (props.cli) {
|
||||
params.append('args', props.cli)
|
||||
params.append('args', props.cli);
|
||||
}
|
||||
params.append('url', props.link || '')
|
||||
url += '?' + params.toString()
|
||||
params.append('url', props.link || '');
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(url)
|
||||
const body = await response.text()
|
||||
const response = await request(url);
|
||||
const body = await response.text();
|
||||
|
||||
try {
|
||||
data.value = JSON.parse(body)
|
||||
data.value = JSON.parse(body);
|
||||
} catch {
|
||||
data.value = body
|
||||
data.value = body;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Error: ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Error: ${e.message}`);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,66 +9,68 @@ img {
|
|||
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<img :src="image">
|
||||
<img :src="image" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
|
||||
const toast = useNotification()
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const toast = useNotification();
|
||||
const emitter = defineEmits(['closeModel']);
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
const image = ref<string>('')
|
||||
const isLoading = ref<boolean>(false);
|
||||
const image = ref<string>('');
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
emitter('closeModel');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
disableOpacity()
|
||||
document.addEventListener('keydown', handle_event)
|
||||
disableOpacity();
|
||||
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 {
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
|
||||
const imgRequest = await request(url)
|
||||
const imgRequest = await request(url);
|
||||
if (200 !== imgRequest.status) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
image.value = URL.createObjectURL(await imgRequest.blob())
|
||||
image.value = URL.createObjectURL(await imgRequest.blob());
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Error: ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Error: ${e.message}`);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
document.removeEventListener('keydown', handle_event);
|
||||
if (image.value) {
|
||||
URL.revokeObjectURL(image.value)
|
||||
URL.revokeObjectURL(image.value);
|
||||
}
|
||||
enableOpacity()
|
||||
})
|
||||
enableOpacity();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,40 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||
<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
|
||||
class="dropdown"
|
||||
:class="{ 'is-active': showList && filteredOptions.length }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<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 class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||
<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)"
|
||||
<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">
|
||||
<a
|
||||
v-for="(option, idx) in filteredOptions"
|
||||
:key="option.value"
|
||||
@mousedown.prevent="selectOption(option.value)"
|
||||
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)">
|
||||
style="display: flex; justify-content: space-between"
|
||||
:ref="(el) => setDropdownItemRef(el, idx)"
|
||||
>
|
||||
<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 }}
|
||||
</abbr>
|
||||
</a>
|
||||
|
|
@ -21,36 +44,39 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, nextTick } from 'vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
options: AutoCompleteOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
multiple?: boolean
|
||||
openOnFocus?: boolean
|
||||
allowShortFlags?: boolean
|
||||
}>(), {
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
id: '',
|
||||
multiple: true,
|
||||
openOnFocus: false,
|
||||
allowShortFlags: false
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: AutoCompleteOptions;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
multiple?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
allowShortFlags?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
id: '',
|
||||
multiple: true,
|
||||
openOnFocus: false,
|
||||
allowShortFlags: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>()
|
||||
const model = defineModel<string>();
|
||||
|
||||
const onInput = () => {
|
||||
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0
|
||||
highlightedIndex.value = showList.value ? 0 : -1
|
||||
}
|
||||
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
|
||||
highlightedIndex.value = showList.value ? 0 : -1;
|
||||
};
|
||||
|
||||
const showList = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
||||
const showList = ref(false);
|
||||
const highlightedIndex = ref(-1);
|
||||
const dropdownItemRefs = ref<(HTMLElement | null)[]>([]);
|
||||
|
||||
// Extract the last non-space token and its bounds
|
||||
const getLastToken = (value: string) => {
|
||||
|
|
@ -59,211 +85,216 @@ const getLastToken = (value: string) => {
|
|||
return {
|
||||
token: value,
|
||||
start: 0,
|
||||
end: value.length
|
||||
}
|
||||
end: value.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple enabled: extract last token for multi-flag support
|
||||
const m = (value || '').match(/(\S+)$/)
|
||||
const token: string = m?.[1] ?? ''
|
||||
const start = m ? (m.index as number) : value.length
|
||||
const end = m ? start + token.length : value.length
|
||||
return { token, start, end }
|
||||
}
|
||||
const m = (value || '').match(/(\S+)$/);
|
||||
const token: string = m?.[1] ?? '';
|
||||
const start = m ? (m.index as number) : value.length;
|
||||
const end = m ? start + token.length : value.length;
|
||||
return { token, start, end };
|
||||
};
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const value = model.value || ''
|
||||
const value = model.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 (props.openOnFocus && !token) {
|
||||
return props.options
|
||||
return props.options;
|
||||
}
|
||||
|
||||
// Hide suggestions when token has '='
|
||||
if (!token || token.includes('=')) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if token is a valid flag format
|
||||
const isLongFlag = token.startsWith('--')
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
|
||||
const isLongFlag = token.startsWith('--');
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
||||
|
||||
if (!isLongFlag && !isShortFlag) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check for exact match first - if found, only show that
|
||||
const exactMatch = props.options.find(opt => opt.value === token)
|
||||
const exactMatch = props.options.find((opt) => opt.value === token);
|
||||
if (exactMatch) {
|
||||
return [exactMatch]
|
||||
return [exactMatch];
|
||||
}
|
||||
|
||||
const startsWithFlag = []
|
||||
const includesFlag = []
|
||||
const includesDesc = []
|
||||
const startsWithFlag = [];
|
||||
const includesFlag = [];
|
||||
const includesDesc = [];
|
||||
|
||||
for (const opt of props.options) {
|
||||
const flag = opt.value
|
||||
const desc = opt.description.toLowerCase()
|
||||
const flag = opt.value;
|
||||
const desc = opt.description.toLowerCase();
|
||||
|
||||
if (isShortFlag) {
|
||||
// Short flags: case-sensitive matching for flag, case-insensitive for description
|
||||
if (flag === token) {
|
||||
startsWithFlag.push(opt)
|
||||
startsWithFlag.push(opt);
|
||||
} else if (flag.includes(token)) {
|
||||
includesFlag.push(opt)
|
||||
includesFlag.push(opt);
|
||||
} else if (desc.includes(token.toLowerCase())) {
|
||||
includesDesc.push(opt)
|
||||
includesDesc.push(opt);
|
||||
}
|
||||
} else {
|
||||
// Long flags: case-insensitive matching
|
||||
const val = token.toLowerCase()
|
||||
const flagLower = flag.toLowerCase()
|
||||
const val = token.toLowerCase();
|
||||
const flagLower = flag.toLowerCase();
|
||||
|
||||
if (flagLower.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
startsWithFlag.push(opt);
|
||||
} else if (flagLower.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
includesFlag.push(opt);
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
includesDesc.push(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||
})
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
||||
});
|
||||
|
||||
const selectOption = (val: string) => {
|
||||
const value = model.value || ''
|
||||
const { token, start, end } = getLastToken(value)
|
||||
const value = model.value || '';
|
||||
const { token, start, end } = getLastToken(value);
|
||||
|
||||
// If multiple is disabled, replace entire value
|
||||
if (!props.multiple) {
|
||||
// Preserve any '=value' suffix already typed
|
||||
const eqPos = token.indexOf('=')
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||
model.value = val + after
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
return
|
||||
const eqPos = token.indexOf('=');
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||
model.value = val + after;
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple enabled: replace only the last token
|
||||
if (token) {
|
||||
// Preserve any '=value' suffix already typed for this token
|
||||
const eqPos = token.indexOf('=')
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||
model.value = value.slice(0, start) + val + after + value.slice(end)
|
||||
const eqPos = token.indexOf('=');
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||
model.value = value.slice(0, start) + val + after + value.slice(end);
|
||||
} else {
|
||||
model.value = val
|
||||
model.value = val;
|
||||
}
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
};
|
||||
|
||||
const hideList = () => {
|
||||
setTimeout(() => {
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
dropdownItemRefs.value = []
|
||||
}, 100)
|
||||
}
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
dropdownItemRefs.value = [];
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.openOnFocus) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
// When openOnFocus is enabled, show dropdown if there are options
|
||||
const hasOptions = filteredOptions.value.length > 0
|
||||
showList.value = hasOptions
|
||||
highlightedIndex.value = hasOptions ? 0 : -1
|
||||
}
|
||||
const hasOptions = filteredOptions.value.length > 0;
|
||||
showList.value = hasOptions;
|
||||
highlightedIndex.value = hasOptions ? 0 : -1;
|
||||
};
|
||||
|
||||
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, () => {
|
||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null)
|
||||
highlightedIndex.value = filteredOptions.value.length ? 0 : -1;
|
||||
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null);
|
||||
nextTick(() => {
|
||||
const dropdown = document.querySelector('.dropdown-content')
|
||||
const dropdown = document.querySelector('.dropdown-content');
|
||||
if (dropdown) {
|
||||
dropdown.scrollTop = 0
|
||||
dropdown.scrollTop = 0;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const isFlagTrigger = computed(() => {
|
||||
const { token } = getLastToken(model.value || '')
|
||||
const { token } = getLastToken(model.value || '');
|
||||
|
||||
// If openOnFocus is enabled and input is empty, allow trigger
|
||||
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
|
||||
const isLongFlag = token.startsWith('--')
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--')
|
||||
const isLongFlag = token.startsWith('--');
|
||||
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
|
||||
|
||||
if (!isLongFlag && !isShortFlag) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow trigger even for exact matches so users can see descriptions
|
||||
return true
|
||||
})
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Escape closes dropdown and lets arrows navigate the input
|
||||
if (e.key === 'Escape') {
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
return
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
if (!showList.value || !filteredOptions.value.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = 5
|
||||
const pageSize = 5;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
||||
nextTick(() => scrollHighlightedIntoView());
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
||||
nextTick(() => scrollHighlightedIntoView());
|
||||
} else if (e.key === 'PageDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + pageSize,
|
||||
filteredOptions.value.length - 1,
|
||||
);
|
||||
nextTick(() => scrollHighlightedIntoView());
|
||||
} else if (e.key === 'PageUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||
nextTick(() => scrollHighlightedIntoView())
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
||||
nextTick(() => scrollHighlightedIntoView());
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||
filteredOptions.value[highlightedIndex.value] : undefined
|
||||
const selected =
|
||||
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||
? filteredOptions.value[highlightedIndex.value]
|
||||
: undefined;
|
||||
if (selected && isFlagTrigger.value) {
|
||||
e.preventDefault()
|
||||
selectOption(selected.value)
|
||||
e.preventDefault();
|
||||
selectOption(selected.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function scrollHighlightedIntoView() {
|
||||
const el = dropdownItemRefs.value[highlightedIndex.value]
|
||||
const el = dropdownItemRefs.value[highlightedIndex.value];
|
||||
if (!el) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
el.scrollIntoView({ block: 'nearest' })
|
||||
el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,73 +5,80 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { useIntersectionObserver } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
renderOnIdle?: boolean
|
||||
unrender?: boolean
|
||||
minHeight?: number
|
||||
unrenderDelay?: number
|
||||
}>()
|
||||
renderOnIdle?: boolean;
|
||||
unrender?: boolean;
|
||||
minHeight?: number;
|
||||
unrenderDelay?: number;
|
||||
}>();
|
||||
|
||||
const shouldRender = ref(false)
|
||||
const targetEl = ref<HTMLElement | null>(null)
|
||||
const fixedMinHeight = ref(0)
|
||||
const shouldRender = ref(false);
|
||||
const targetEl = ref<HTMLElement | null>(null);
|
||||
const fixedMinHeight = ref(0);
|
||||
|
||||
let unrenderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let renderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renderTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onIdle(cb: () => void): void {
|
||||
if ('requestIdleCallback' in window) {
|
||||
(window as any).requestIdleCallback(cb)
|
||||
(window as any).requestIdleCallback(cb);
|
||||
} else {
|
||||
setTimeout(() => nextTick(cb), 300)
|
||||
setTimeout(() => nextTick(cb), 300);
|
||||
}
|
||||
}
|
||||
|
||||
const { stop } = useIntersectionObserver(targetEl, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting) {
|
||||
if (unrenderTimer) clearTimeout(unrenderTimer)
|
||||
const { stop } = useIntersectionObserver(
|
||||
targetEl,
|
||||
(entries) => {
|
||||
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) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
else if (props.unrender) {
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer)
|
||||
}
|
||||
|
||||
unrenderTimer = setTimeout(() => {
|
||||
if (targetEl.value?.clientHeight) {
|
||||
fixedMinHeight.value = targetEl.value.clientHeight
|
||||
if (!props.unrender) {
|
||||
stop();
|
||||
}
|
||||
shouldRender.value = false
|
||||
}, props.unrenderDelay ?? 6000)
|
||||
}
|
||||
}, { rootMargin: '600px', })
|
||||
} else if (props.unrender) {
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer);
|
||||
}
|
||||
|
||||
unrenderTimer = setTimeout(() => {
|
||||
if (targetEl.value?.clientHeight) {
|
||||
fixedMinHeight.value = targetEl.value.clientHeight;
|
||||
}
|
||||
shouldRender.value = false;
|
||||
}, props.unrenderDelay ?? 6000);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px' },
|
||||
);
|
||||
|
||||
if (props.renderOnIdle) {
|
||||
onIdle(() => {
|
||||
shouldRender.value = true
|
||||
shouldRender.value = true;
|
||||
if (!props.unrender) {
|
||||
stop()
|
||||
stop();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (renderTimer) {
|
||||
clearTimeout(renderTimer)
|
||||
|
||||
clearTimeout(renderTimer);
|
||||
}
|
||||
if (unrenderTimer) {
|
||||
clearTimeout(unrenderTimer)
|
||||
clearTimeout(unrenderTimer);
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -34,39 +34,38 @@
|
|||
border-left-color: #e5534b;
|
||||
}
|
||||
|
||||
.markdown-alert-note>.markdown-alert-title {
|
||||
.markdown-alert-note > .markdown-alert-title {
|
||||
color: #539bf5;
|
||||
}
|
||||
|
||||
.markdown-alert-tip>.markdown-alert-title {
|
||||
.markdown-alert-tip > .markdown-alert-title {
|
||||
color: #57ab5a;
|
||||
}
|
||||
|
||||
.markdown-alert-important>.markdown-alert-title {
|
||||
.markdown-alert-important > .markdown-alert-title {
|
||||
color: #986ee2;
|
||||
}
|
||||
|
||||
.markdown-alert-warning>.markdown-alert-title {
|
||||
.markdown-alert-warning > .markdown-alert-title {
|
||||
color: #c69026;
|
||||
}
|
||||
|
||||
.markdown-alert-caution>.markdown-alert-title {
|
||||
.markdown-alert-caution > .markdown-alert-title {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
code {
|
||||
word-break: break-word !important
|
||||
word-break: break-word !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="modal is-active">
|
||||
|
||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||
|
||||
<div class="modal-content modal-content-max">
|
||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<i class="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
|
||||
|
|
@ -80,120 +79,121 @@ code {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { baseUrl } from 'marked-base-url'
|
||||
import markedAlert from 'marked-alert'
|
||||
import { gfmHeadingId } from 'marked-gfm-heading-id'
|
||||
import Message from '~/components/Message.vue'
|
||||
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { baseUrl } from 'marked-base-url';
|
||||
import markedAlert from 'marked-alert';
|
||||
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
||||
import Message from '~/components/Message.vue';
|
||||
|
||||
const props = defineProps<{ file: string }>()
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
||||
const props = defineProps<{ file: string }>();
|
||||
const emitter = defineEmits<{ (e: 'closeModel'): void }>();
|
||||
|
||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg']
|
||||
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg'];
|
||||
|
||||
const content = ref<string>('')
|
||||
const error = ref<string>('')
|
||||
const isLoading = ref<boolean>(true)
|
||||
const content = ref<string>('');
|
||||
const error = ref<string>('');
|
||||
const isLoading = ref<boolean>(true);
|
||||
|
||||
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) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const href = target.getAttribute('data-url')
|
||||
const href = target.getAttribute('data-url');
|
||||
if (!href) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
await loader(href)
|
||||
}
|
||||
e.preventDefault();
|
||||
await loader(href);
|
||||
};
|
||||
|
||||
const addListeners = (): void => {
|
||||
removeListeners()
|
||||
removeListeners();
|
||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
||||
const href = l.getAttribute('data-url')
|
||||
const href = l.getAttribute('data-url');
|
||||
if (!href) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
(l as HTMLElement).addEventListener('click', handleClick)
|
||||
})
|
||||
}
|
||||
(l as HTMLElement).addEventListener('click', handleClick);
|
||||
});
|
||||
};
|
||||
|
||||
const removeListeners = (): void => {
|
||||
document.querySelectorAll('.content a').forEach((l: Element): void => {
|
||||
const href = l.getAttribute('data-url')
|
||||
const href = l.getAttribute('data-url');
|
||||
if (!href) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
(l as HTMLElement).removeEventListener('click', handleClick)
|
||||
})
|
||||
}
|
||||
(l as HTMLElement).removeEventListener('click', handleClick);
|
||||
});
|
||||
};
|
||||
|
||||
const loader = async (file: string) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await fetch(`${file}?_=${Date.now()}`)
|
||||
isLoading.value = true;
|
||||
const response = await fetch(`${file}?_=${Date.now()}`);
|
||||
if (true !== response.ok) {
|
||||
const err = await response.json()
|
||||
error.value = err.error.message
|
||||
return
|
||||
const err = await response.json();
|
||||
error.value = err.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const text = await response.text();
|
||||
|
||||
marked.use(gfmHeadingId())
|
||||
marked.use(baseUrl(window.origin))
|
||||
marked.use(markedAlert())
|
||||
marked.use(gfmHeadingId());
|
||||
marked.use(baseUrl(window.origin));
|
||||
marked.use(markedAlert());
|
||||
marked.use({
|
||||
gfm: true,
|
||||
hooks: {
|
||||
postprocess: (text: string) => text.replace(
|
||||
/<!--\s*?i:([\w.-]+)\s*?-->/gi,
|
||||
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map((n: string) => n.trim()).join(' ')}"></i></span>`
|
||||
)
|
||||
postprocess: (text: string) =>
|
||||
text.replace(
|
||||
/<!--\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) => {
|
||||
if (token.type !== 'link') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (token.href.startsWith('#')) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.some(l => token.href.includes(l))) {
|
||||
const name = urls.find(l => token.href.includes(l)) || ''
|
||||
token._external = false
|
||||
token.href = `/${name}`
|
||||
if (urls.some((l) => token.href.includes(l))) {
|
||||
const name = urls.find((l) => token.href.includes(l)) || '';
|
||||
token._external = false;
|
||||
token.href = `/${name}`;
|
||||
} else {
|
||||
token._external = true
|
||||
token._external = true;
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
link(token: any) {
|
||||
const text = this.parser.parseInline(token.tokens)
|
||||
const title = token.title ? ` title="${token.title}"` : ''
|
||||
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : ''
|
||||
let local = ''
|
||||
const name = urls.find(l => token.href.includes(l)) || ''
|
||||
const text = this.parser.parseInline(token.tokens);
|
||||
const title = token.title ? ` title="${token.title}"` : '';
|
||||
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
let local = '';
|
||||
const name = urls.find((l) => token.href.includes(l)) || '';
|
||||
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) {
|
||||
// `token.header` and `token.rows` are available
|
||||
|
|
@ -201,39 +201,42 @@ const loader = async (file: string) => {
|
|||
// Then wrap with classed <table>
|
||||
|
||||
// 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 tr = row.map((cell: any) => {
|
||||
return `<td>${this.parser.parseInline(cell.tokens)}</td>`
|
||||
}).join('')
|
||||
return `<tr>${tr}</tr>`
|
||||
}).join('')
|
||||
const bodyHtml = (token.rows || [])
|
||||
.map((row: any[]) => {
|
||||
const tr = row
|
||||
.map((cell: any) => {
|
||||
return `<td>${this.parser.parseInline(cell.tokens)}</td>`;
|
||||
})
|
||||
.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) {
|
||||
const alt = token.text ? ` alt="${token.text}"` : ' alt=""'
|
||||
const title = token.title ? ` title="${token.title}"` : ''
|
||||
const refPolicy = ' referrerpolicy="no-referrer"'
|
||||
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : ''
|
||||
const loading = ' loading="lazy"'
|
||||
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`
|
||||
const alt = token.text ? ` alt="${token.text}"` : ' alt=""';
|
||||
const title = token.title ? ` title="${token.title}"` : '';
|
||||
const refPolicy = ' referrerpolicy="no-referrer"';
|
||||
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
|
||||
const loading = ' loading="lazy"';
|
||||
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
content.value = String(marked.parse(text))
|
||||
});
|
||||
|
||||
content.value = String(marked.parse(text));
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
error.value = e.message
|
||||
console.error(e);
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => loader(props.file))
|
||||
onUpdated(() => addListeners())
|
||||
onBeforeUnmount(() => removeListeners())
|
||||
onMounted(async () => loader(props.file));
|
||||
onUpdated(() => addListeners());
|
||||
onBeforeUnmount(() => removeListeners());
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
<template>
|
||||
<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">
|
||||
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
|
||||
</span>
|
||||
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
||||
</div>
|
||||
<div class="is-unselectable message-header" :class="{ 'is-clickable': useToggle, }" v-if="title || icon"
|
||||
@click="true === useToggle ? $emit('toggle', toggle) : null">
|
||||
<div
|
||||
class="is-unselectable message-header"
|
||||
:class="{ 'is-clickable': useToggle }"
|
||||
v-if="title || icon"
|
||||
@click="true === useToggle ? $emit('toggle', toggle) : null"
|
||||
>
|
||||
<template v-if="icon">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i :class="icon"></i></span>
|
||||
|
|
@ -17,7 +25,11 @@
|
|||
<template v-else>{{ title }}</template>
|
||||
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
|
||||
</div>
|
||||
<div class="content message-body is-text-break" v-if="false === useToggle || toggle" :class="body_class">
|
||||
<div
|
||||
class="content message-body is-text-break"
|
||||
v-if="false === useToggle || toggle"
|
||||
:class="body_class"
|
||||
>
|
||||
<template v-if="message">{{ message }}</template>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
@ -25,34 +37,37 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
/** Title text for the notification */
|
||||
title?: string | null
|
||||
/** Icon class for the notification */
|
||||
icon?: string | null
|
||||
/** Main message content */
|
||||
message?: string | null
|
||||
/** If true, show toggle button */
|
||||
useToggle?: boolean
|
||||
/** Current toggle state */
|
||||
toggle?: boolean
|
||||
/** If true, show close button */
|
||||
useClose?: boolean,
|
||||
body_class?: string | null,
|
||||
}>(), {
|
||||
title: null,
|
||||
icon: null,
|
||||
message: null,
|
||||
useToggle: false,
|
||||
toggle: false,
|
||||
useClose: false,
|
||||
body_class: null,
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Title text for the notification */
|
||||
title?: string | null;
|
||||
/** Icon class for the notification */
|
||||
icon?: string | null;
|
||||
/** Main message content */
|
||||
message?: string | null;
|
||||
/** If true, show toggle button */
|
||||
useToggle?: boolean;
|
||||
/** Current toggle state */
|
||||
toggle?: boolean;
|
||||
/** If true, show close button */
|
||||
useClose?: boolean;
|
||||
body_class?: string | null;
|
||||
}>(),
|
||||
{
|
||||
title: null,
|
||||
icon: null,
|
||||
message: null,
|
||||
useToggle: false,
|
||||
toggle: false,
|
||||
useClose: false,
|
||||
body_class: null,
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
/** Emitted when the toggle button is clicked */
|
||||
(e: 'toggle', value?: boolean): void
|
||||
(e: 'toggle', value?: boolean): void;
|
||||
/** Emitted when the close button is clicked */
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
|
||||
const emitter = defineEmits(['close'])
|
||||
const emitter = defineEmits(['close']);
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
|
|
@ -32,22 +32,22 @@ defineProps({
|
|||
default: '',
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emitter('close')
|
||||
}
|
||||
emitter('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handle_event)
|
||||
disableOpacity()
|
||||
})
|
||||
document.addEventListener('keydown', handle_event);
|
||||
disableOpacity();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
enableOpacity()
|
||||
})
|
||||
document.removeEventListener('keydown', handle_event);
|
||||
enableOpacity();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<style scoped>
|
||||
code {
|
||||
color: var(--bulma-code) !important
|
||||
color: var(--bulma-code) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ code {
|
|||
<div class="modal is-active" v-if="false === externalModel">
|
||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||
<div class="modal-content modal-content-max">
|
||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||
<i class="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
<div v-else>
|
||||
|
|
@ -27,20 +27,32 @@ code {
|
|||
</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 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 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" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" /></pre>
|
||||
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;">
|
||||
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')">
|
||||
<pre
|
||||
:class="[code_classes, custom_classes]"
|
||||
><code class="p-4 is-block" v-text="data" /></pre>
|
||||
<div class="m-4 is-flex" style="position: absolute; top: 0; right: 0">
|
||||
<button
|
||||
class="button is-small is-purple mr-3"
|
||||
@click="() => toggleClass('is-pre-wrap-force')"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-text-width" /></span>
|
||||
</button>
|
||||
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))">
|
||||
<button
|
||||
class="button is-info is-small"
|
||||
@click="() => copyText(JSON.stringify(data, null, 2))"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-copy" /></span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -51,40 +63,43 @@ code {
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
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 }>(), {
|
||||
code_classes: '',
|
||||
isLoading: false,
|
||||
externalModel: false,
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{ externalModel?: boolean; data: any; code_classes?: string; isLoading?: boolean }>(),
|
||||
{
|
||||
code_classes: '',
|
||||
isLoading: false,
|
||||
externalModel: false,
|
||||
},
|
||||
);
|
||||
|
||||
const custom_classes = useStorage<string>('modal_text_classes', '')
|
||||
const custom_classes = useStorage<string>('modal_text_classes', '');
|
||||
|
||||
const handle_event = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
emitter('closeModel')
|
||||
emitter('closeModel');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async (): Promise<void> => {
|
||||
disableOpacity()
|
||||
document.addEventListener('keydown', handle_event)
|
||||
})
|
||||
disableOpacity();
|
||||
document.addEventListener('keydown', handle_event);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
enableOpacity()
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
})
|
||||
enableOpacity();
|
||||
document.removeEventListener('keydown', handle_event);
|
||||
});
|
||||
|
||||
const toggleClass = (className: string) => {
|
||||
if (custom_classes.value.includes(className)) {
|
||||
custom_classes.value = custom_classes.value.replace(className, '').trim()
|
||||
custom_classes.value = custom_classes.value.replace(className, '').trim();
|
||||
} else {
|
||||
custom_classes.value += ` ${className}`
|
||||
custom_classes.value += ` ${className}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from "~/components/Message.vue";
|
||||
import Message from '~/components/Message.vue';
|
||||
|
||||
const reloadPage = () => window.location.reload();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,21 +3,26 @@
|
|||
<div class="column is-12">
|
||||
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
|
||||
<div class="card">
|
||||
|
||||
<div class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span class="icon"
|
||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||
/></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-header-icon" v-if="reference">
|
||||
<button type="button" @click="showImport = !showImport">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}"
|
||||
/></span>
|
||||
<span>
|
||||
<span v-if="showImport">Hide</span>
|
||||
<span v-else>Show</span>
|
||||
|
|
@ -29,20 +34,28 @@
|
|||
|
||||
<div class="card-content">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
|
||||
<div class="column is-12" v-if="showImport || !reference">
|
||||
<label class="label is-inline" for="import_string">
|
||||
Import string
|
||||
</label>
|
||||
<label class="label is-inline" for="import_string"> Import string </label>
|
||||
|
||||
<div class="field has-addons">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>Import</span>
|
||||
</button>
|
||||
|
|
@ -56,37 +69,52 @@
|
|||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="name">
|
||||
Target name
|
||||
</label>
|
||||
<label class="label is-inline" for="name"> Target name </label>
|
||||
<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>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<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
|
||||
notifications.</span>
|
||||
<span
|
||||
>The notification target name, this is used to identify the target in the logs
|
||||
and notifications.</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="url">
|
||||
Target URL
|
||||
</label>
|
||||
<label class="label is-inline" for="url"> Target URL </label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
|
||||
required>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The URL to send the notification to. It can be regular http/https endpoint. or <NuxtLink
|
||||
target="blank" href="https://github.com/caronc/apprise?tab=readme-ov-file#readme">Apprise
|
||||
</NuxtLink> URL.
|
||||
The URL to send the notification to. It can be regular http/https endpoint. or
|
||||
<NuxtLink
|
||||
target="blank"
|
||||
href="https://github.com/caronc/apprise?tab=readme-ov-file#readme"
|
||||
>Apprise
|
||||
</NuxtLink>
|
||||
URL.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -94,13 +122,20 @@
|
|||
|
||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="method">
|
||||
Request method
|
||||
</label>
|
||||
<label class="label is-inline" for="method"> Request method </label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
||||
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
|
||||
<select
|
||||
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 }}
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -110,8 +145,8 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The request method to use when sending the notification. This can be any of the standard HTTP
|
||||
methods.
|
||||
The request method to use when sending the notification. This can be any of
|
||||
the standard HTTP methods.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -119,13 +154,20 @@
|
|||
|
||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="type">
|
||||
Request Type
|
||||
</label>
|
||||
<label class="label is-inline" for="type"> Request Type </label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
||||
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
|
||||
<select
|
||||
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) }}
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -135,8 +177,8 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The request type to use when sending the notification. This can be <code>JSON</code> or
|
||||
<code>FORM</code> request.
|
||||
The request type to use when sending the notification. This can be
|
||||
<code>JSON</code> or <code>FORM</code> request.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -152,8 +194,18 @@
|
|||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-multiple is-fullwidth">
|
||||
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
||||
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
|
||||
<select
|
||||
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 }}
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -163,9 +215,9 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
Subscribe to the events you want to listen for. When the event is triggered, the notification will
|
||||
be sent to the target URL. If no events are selected, the notification will be sent for all
|
||||
events.
|
||||
Subscribe to the events you want to listen for. When the event is triggered,
|
||||
the notification will be sent to the target URL. If no events are selected,
|
||||
the notification will be sent for all events.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -181,14 +233,31 @@
|
|||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-multiple is-fullwidth">
|
||||
<select id="on" 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">
|
||||
<select
|
||||
id="on"
|
||||
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 }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<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 }}
|
||||
</option>
|
||||
</optgroup>
|
||||
|
|
@ -199,9 +268,9 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
Select the presets you want to listen for. If you select presets, only events that reference those
|
||||
presets will trigger the notification. If no presets are selected, the notification will be sent
|
||||
for all presets.
|
||||
Select the presets you want to listen for. If you select presets, only events
|
||||
that reference those presets will trigger the notification. If no presets are
|
||||
selected, the notification will be sent for all presets.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -214,8 +283,13 @@
|
|||
Enabled
|
||||
</label>
|
||||
<div class="control is-unselectable">
|
||||
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
||||
class="switch is-success" />
|
||||
<input
|
||||
id="enabled"
|
||||
type="checkbox"
|
||||
v-model="form.enabled"
|
||||
:disabled="addInProgress"
|
||||
class="switch is-success"
|
||||
/>
|
||||
<label for="enabled" class="is-unselectable">
|
||||
{{ form.enabled ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -229,19 +303,23 @@
|
|||
|
||||
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="data_key">
|
||||
Data field
|
||||
</label>
|
||||
<label class="label is-inline" for="data_key"> Data field </label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="data_key" v-model="form.request.data_key"
|
||||
:disabled="addInProgress" required>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The field name to use when sending the notification. This is used to identify the data in the
|
||||
request. The default is <code>data</code>.
|
||||
The field name to use when sending the notification. This is used to identify
|
||||
the data in the request. The default is <code>data</code>.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -250,17 +328,27 @@
|
|||
<div class="column is-12" v-if="!isAppriseTarget">
|
||||
<div class="field">
|
||||
<label class="label is-inline is-unselectable">
|
||||
Optional Headers - <button type="button" class="has-text-link"
|
||||
@click="form.request.headers.push({ key: '', value: '' });">Add Header
|
||||
Optional Headers -
|
||||
<button
|
||||
type="button"
|
||||
class="has-text-link"
|
||||
@click="form.request.headers.push({ key: '', value: '' })"
|
||||
>
|
||||
Add Header
|
||||
</button>
|
||||
</label>
|
||||
<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="field">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" v-model="form.request.headers[key].key"
|
||||
:disabled="addInProgress" required>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,8 +360,13 @@
|
|||
<div class="column is-6" v-if="form.request.headers[key]">
|
||||
<div class="field">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" v-model="form.request.headers[key].value"
|
||||
:disabled="addInProgress" required>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -284,8 +377,12 @@
|
|||
</div>
|
||||
<div class="column is-1">
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
|
||||
:disabled="addInProgress">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -304,15 +401,24 @@
|
|||
|
||||
<div class="card-footer">
|
||||
<p class="card-footer-item">
|
||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="taskForm">
|
||||
<button
|
||||
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>Save</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="card-footer-item">
|
||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
||||
type="button">
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -326,17 +432,17 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { notification } from '~/types/notification'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import type { ImportedItem } from '~/types'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification } from '~/types/notification';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import type { ImportedItem } from '~/types';
|
||||
|
||||
const emitter = defineEmits(['cancel', 'submit'])
|
||||
const toast = useNotification()
|
||||
const box = useConfirm()
|
||||
const config = useConfigStore()
|
||||
const { isApprise } = useNotifications()
|
||||
const emitter = defineEmits(['cancel', 'submit']);
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const config = useConfigStore();
|
||||
const { isApprise } = useNotifications();
|
||||
|
||||
const props = defineProps({
|
||||
reference: {
|
||||
|
|
@ -357,135 +463,136 @@ const props = defineProps({
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const form = reactive<notification>({ ...props.item })
|
||||
const requestMethods = ['POST', 'PUT']
|
||||
const requestType = ['json', 'form']
|
||||
const showImport = useStorage('showImport', false)
|
||||
const import_string = ref('')
|
||||
const form = reactive<notification>({ ...props.item });
|
||||
const requestMethods = ['POST', 'PUT'];
|
||||
const requestType = ['json', 'form'];
|
||||
const showImport = useStorage('showImport', false);
|
||||
const import_string = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
if (!form.request.data_key) {
|
||||
form.request.data_key = 'data'
|
||||
form.request.data_key = 'data';
|
||||
}
|
||||
if (form.enabled === undefined) {
|
||||
form.enabled = true
|
||||
form.enabled = true;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const checkInfo = async () => {
|
||||
let required: string[]
|
||||
let required: string[];
|
||||
|
||||
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 {
|
||||
required = ['name', 'request.url']
|
||||
required = ['name', 'request.url'];
|
||||
}
|
||||
|
||||
for (const key of required) {
|
||||
if (key.includes('.')) {
|
||||
const [parent, child] = key.split('.') as [keyof typeof form, string]
|
||||
const parentObj = form[parent] as Record<string, any> | undefined
|
||||
const [parent, child] = key.split('.') as [keyof typeof form, string];
|
||||
const parentObj = form[parent] as Record<string, any> | undefined;
|
||||
|
||||
if (!parentObj || !parentObj[child]) {
|
||||
toast.error(`The field ${parent}.${child} is required.`)
|
||||
return
|
||||
toast.error(`The field ${parent}.${child} is required.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const value = (form as Record<string, any>)[key]
|
||||
const value = (form as Record<string, any>)[key];
|
||||
if (!value) {
|
||||
toast.error(`The field ${key} is required.`)
|
||||
return
|
||||
toast.error(`The field ${key} is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAppriseTarget.value) {
|
||||
try {
|
||||
new URL(form.request.url)
|
||||
new URL(form.request.url);
|
||||
} catch {
|
||||
toast.error('Invalid URL')
|
||||
return
|
||||
toast.error('Invalid URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const headers = []
|
||||
const headers = [];
|
||||
for (const header of form.request.headers) {
|
||||
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 val = import_string.value.trim()
|
||||
const val = import_string.value.trim();
|
||||
if (!val) {
|
||||
toast.error('The import string is required.')
|
||||
return
|
||||
toast.error('The import string is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = decode(val) as notification & ImportedItem
|
||||
const item = decode(val) as notification & ImportedItem;
|
||||
|
||||
if ('notification' !== item._type) {
|
||||
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
|
||||
import_string.value = ''
|
||||
return
|
||||
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`);
|
||||
import_string.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.name || form.request?.url) {
|
||||
if (false === (await box.confirm('Overwrite the current form fields?'))) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.name) {
|
||||
form.name = item.name
|
||||
form.name = item.name;
|
||||
}
|
||||
|
||||
if (!form.request) {
|
||||
form.request = {} as any
|
||||
form.request = {} as any;
|
||||
}
|
||||
|
||||
if (item.request) {
|
||||
form.request = item.request
|
||||
form.request = item.request;
|
||||
}
|
||||
|
||||
if (item.request?.data_key) {
|
||||
form.request.data_key = item.request.data_key
|
||||
form.request.data_key = item.request.data_key;
|
||||
}
|
||||
|
||||
if (item.on) {
|
||||
form.on = item.on
|
||||
form.on = item.on;
|
||||
}
|
||||
|
||||
if (item.presets) {
|
||||
item.presets.forEach(p => {
|
||||
if (!config.presets.find(cp => cp.name === p)) {
|
||||
return
|
||||
item.presets.forEach((p) => {
|
||||
if (!config.presets.find((cp) => cp.name === p)) {
|
||||
return;
|
||||
}
|
||||
if (!form.presets.includes(p)) {
|
||||
form.presets.push(p)
|
||||
form.presets.push(p);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (item.enabled !== undefined) {
|
||||
form.enabled = item.enabled
|
||||
form.enabled = item.enabled;
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
import_string.value = '';
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to import task. ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Failed to import task. ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url))
|
||||
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
|
||||
const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url));
|
||||
const filter_presets = (flag: boolean = true) =>
|
||||
config.presets.filter((item) => item.default === flag);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
</span>
|
||||
<span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span>
|
||||
</a>
|
||||
<div class="navbar-dropdown is-right" style="width: 400px;">
|
||||
<div class="navbar-dropdown is-right" style="width: 400px">
|
||||
<template v-if="store.notifications.length > 0">
|
||||
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
||||
<span class="has-text-grey"></span>
|
||||
|
|
@ -73,20 +73,31 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="navbar-divider">
|
||||
<hr class="navbar-divider" />
|
||||
</template>
|
||||
<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"
|
||||
:class="['notification-item', 'notification-' + n.level]">
|
||||
<div
|
||||
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">
|
||||
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"
|
||||
@click="toggleExpand(n.id)">
|
||||
<p
|
||||
class="is-size-7 mb-1 notification-message"
|
||||
:class="{ expanded: expandedId === n.id }"
|
||||
@click="toggleExpand(n.id)"
|
||||
>
|
||||
{{ n.message }}
|
||||
</p>
|
||||
<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')"
|
||||
v-rtime="n.created" />
|
||||
- <NuxtLink @click="copy_text(n.id, n.message)">
|
||||
<span
|
||||
:date-datetime="n.created"
|
||||
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-else>Copy</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -118,20 +129,20 @@
|
|||
</template>
|
||||
|
||||
<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 expandedId = ref<string | null>(null)
|
||||
const copiedId = 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 => {
|
||||
copiedId.value = id
|
||||
copyText(text, false, false)
|
||||
copiedId.value = id;
|
||||
copyText(text, false, false);
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === id) copiedId.value = null
|
||||
}, 2000)
|
||||
}
|
||||
if (copiedId.value === id) copiedId.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,69 @@
|
|||
<template>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
|
||||
:class="{'is-loading':isLoading}">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
|
||||
:disabled="isLoading" :class="{'is-loading':isLoading}">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="pager_list" 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">
|
||||
<select
|
||||
id="pager_list"
|
||||
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 }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
|
||||
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
|
||||
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -38,33 +71,33 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import {makePagination} from '~/utils/index'
|
||||
import { makePagination } from '~/utils/index';
|
||||
|
||||
const emitter = defineEmits(['navigate'])
|
||||
const emitter = defineEmits(['navigate']);
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
last_page: {
|
||||
type: Number,
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const changePage = p => {
|
||||
const changePage = (p) => {
|
||||
if (p < 1 || p > props.last_page) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emitter('navigate', p)
|
||||
currentPage.value = p
|
||||
}
|
||||
emitter('navigate', p);
|
||||
currentPage.value = p;
|
||||
};
|
||||
|
||||
const currentPage = ref(props.page)
|
||||
const currentPage = ref(props.page);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
<template>
|
||||
<div class="popover-wrapper" ref="triggerElm" :tabindex="triggerTabIndex" @mouseenter="handleHoverEnter"
|
||||
@mouseleave="handleHoverLeave" @focusin="handleFocusIn" @focusout="handleFocusOut" @click="handleClick">
|
||||
<div
|
||||
class="popover-wrapper"
|
||||
ref="triggerElm"
|
||||
:tabindex="triggerTabIndex"
|
||||
@mouseenter="handleHoverEnter"
|
||||
@mouseleave="handleHoverLeave"
|
||||
@focusin="handleFocusIn"
|
||||
@focusout="handleFocusOut"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="popover-trigger">
|
||||
<slot name="trigger">
|
||||
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
||||
|
|
@ -13,9 +21,20 @@
|
|||
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="popover-portal" ref="portal">
|
||||
<div ref="popover" class="popover-card box" :class="placementClass" role="tooltip" :style="portalStyle"
|
||||
@mouseenter="handleHoverEnter" @mouseleave="handleHoverLeave">
|
||||
<button type="button" class="button is-bold is-fullwidth is-hidden-tablet" @click="closePopover">
|
||||
<div
|
||||
ref="popover"
|
||||
class="popover-card box"
|
||||
:class="placementClass"
|
||||
role="tooltip"
|
||||
:style="portalStyle"
|
||||
@mouseenter="handleHoverEnter"
|
||||
@mouseleave="handleHoverLeave"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-bold is-fullwidth is-hidden-tablet"
|
||||
@click="closePopover"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-times" /></span>
|
||||
<span>Close</span>
|
||||
</button>
|
||||
|
|
@ -38,10 +57,19 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PopoverProps } from '~/types/popover'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch, useTemplateRef } from 'vue'
|
||||
import type { PopoverProps } from '~/types/popover';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
useSlots,
|
||||
watch,
|
||||
useTemplateRef,
|
||||
} from 'vue';
|
||||
|
||||
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>()
|
||||
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>();
|
||||
|
||||
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
placement: 'top',
|
||||
|
|
@ -54,211 +82,211 @@ const props = withDefaults(defineProps<PopoverProps>(), {
|
|||
minWidth: 220,
|
||||
maxWidth: 340,
|
||||
maxHeight: 360,
|
||||
showDelay: 0
|
||||
})
|
||||
showDelay: 0,
|
||||
});
|
||||
|
||||
const slots = useSlots()
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = useTemplateRef<HTMLElement>('triggerElm')
|
||||
const popover = useTemplateRef<HTMLDivElement>('popover')
|
||||
const portalStyle = ref<Record<string, string>>({})
|
||||
const hoverTimeout = ref<number | null>(null)
|
||||
const openTimeout = ref<number | null>(null)
|
||||
const slots = useSlots();
|
||||
const isOpen = ref(false);
|
||||
const triggerRef = useTemplateRef<HTMLElement>('triggerElm');
|
||||
const popover = useTemplateRef<HTMLDivElement>('popover');
|
||||
const portalStyle = ref<Record<string, string>>({});
|
||||
const hoverTimeout = ref<number | null>(null);
|
||||
const openTimeout = ref<number | null>(null);
|
||||
|
||||
const placementClass = computed(() => `is-${props.placement}`)
|
||||
const triggerTabIndex = computed(() => props.trigger === 'hover' ? -1 : 0)
|
||||
const placementClass = computed(() => `is-${props.placement}`);
|
||||
const triggerTabIndex = computed(() => (props.trigger === 'hover' ? -1 : 0));
|
||||
|
||||
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title))
|
||||
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default))
|
||||
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title));
|
||||
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default));
|
||||
|
||||
const clearHoverTimeout = () => {
|
||||
if (null !== hoverTimeout.value) {
|
||||
window.clearTimeout(hoverTimeout.value)
|
||||
hoverTimeout.value = null
|
||||
window.clearTimeout(hoverTimeout.value);
|
||||
hoverTimeout.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearOpenTimeout = () => {
|
||||
if (null !== openTimeout.value) {
|
||||
window.clearTimeout(openTimeout.value)
|
||||
openTimeout.value = null
|
||||
window.clearTimeout(openTimeout.value);
|
||||
openTimeout.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearHoverTimeout()
|
||||
clearOpenTimeout()
|
||||
clearHoverTimeout();
|
||||
clearOpenTimeout();
|
||||
hoverTimeout.value = window.setTimeout(() => {
|
||||
isOpen.value = false
|
||||
}, 120)
|
||||
}
|
||||
isOpen.value = false;
|
||||
}, 120);
|
||||
};
|
||||
|
||||
const openPopoverImmediate = async () => {
|
||||
if (props.disabled || isOpen.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
isOpen.value = true;
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
const openPopover = () => {
|
||||
clearOpenTimeout()
|
||||
clearOpenTimeout();
|
||||
if (props.showDelay && 0 < props.showDelay) {
|
||||
openTimeout.value = window.setTimeout(() => {
|
||||
openTimeout.value = null
|
||||
void openPopoverImmediate()
|
||||
}, props.showDelay)
|
||||
return
|
||||
openTimeout.value = null;
|
||||
void openPopoverImmediate();
|
||||
}, props.showDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
void openPopoverImmediate()
|
||||
}
|
||||
void openPopoverImmediate();
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
if (!isOpen.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const togglePopover = async () => {
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
closePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
openPopover()
|
||||
}
|
||||
openPopover();
|
||||
};
|
||||
|
||||
const handleHoverEnter = () => {
|
||||
if ('hover' !== props.trigger || props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
clearHoverTimeout()
|
||||
openPopover()
|
||||
}
|
||||
clearHoverTimeout();
|
||||
openPopover();
|
||||
};
|
||||
|
||||
const handleHoverLeave = () => {
|
||||
if ('hover' !== props.trigger) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
clearOpenTimeout()
|
||||
scheduleClose()
|
||||
}
|
||||
clearOpenTimeout();
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
const handleFocusIn = () => {
|
||||
if ('focus' !== props.trigger || props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
openPopover()
|
||||
}
|
||||
openPopover();
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
if ('focus' !== props.trigger) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
clearOpenTimeout()
|
||||
scheduleClose()
|
||||
}
|
||||
clearOpenTimeout();
|
||||
scheduleClose();
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if ('click' !== props.trigger || props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
void togglePopover()
|
||||
}
|
||||
event.stopPropagation();
|
||||
void togglePopover();
|
||||
};
|
||||
|
||||
const handleDocumentClick = (event: MouseEvent) => {
|
||||
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as Node | null
|
||||
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target))
|
||||
const isInsidePopover = Boolean(popover.value && popover.value.contains(target))
|
||||
const target = event.target as Node | null;
|
||||
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target));
|
||||
const isInsidePopover = Boolean(popover.value && popover.value.contains(target));
|
||||
|
||||
if (!isInsideTrigger && !isInsidePopover) {
|
||||
clearOpenTimeout()
|
||||
closePopover()
|
||||
clearOpenTimeout();
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if ('Escape' === event.key && isOpen.value) {
|
||||
closePopover()
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef.value || !popover.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96)
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96);
|
||||
|
||||
popover.value.style.minWidth = `${props.minWidth}px`
|
||||
popover.value.style.maxWidth = `${constrainedMaxWidth}px`
|
||||
popover.value.style.maxHeight = `${props.maxHeight}px`
|
||||
popover.value.style.overflowY = 'auto'
|
||||
popover.value.style.minWidth = `${props.minWidth}px`;
|
||||
popover.value.style.maxWidth = `${constrainedMaxWidth}px`;
|
||||
popover.value.style.maxHeight = `${props.maxHeight}px`;
|
||||
popover.value.style.overflowY = 'auto';
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const popoverRect = popover.value.getBoundingClientRect()
|
||||
const offset = props.offset
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const popoverRect = popover.value.getBoundingClientRect();
|
||||
const offset = props.offset;
|
||||
|
||||
let effectivePlacement = props.placement
|
||||
let effectivePlacement = props.placement;
|
||||
|
||||
if ('top' === props.placement) {
|
||||
const spaceAbove = triggerRect.top - offset
|
||||
const spaceAbove = triggerRect.top - offset;
|
||||
if (spaceAbove < popoverRect.height) {
|
||||
effectivePlacement = 'bottom'
|
||||
effectivePlacement = 'bottom';
|
||||
}
|
||||
} else if ('bottom' === props.placement) {
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom - offset
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom - offset;
|
||||
if (spaceBelow < popoverRect.height) {
|
||||
effectivePlacement = 'top'
|
||||
effectivePlacement = 'top';
|
||||
}
|
||||
} else if ('left' === props.placement) {
|
||||
const spaceLeft = triggerRect.left - offset
|
||||
const spaceLeft = triggerRect.left - offset;
|
||||
if (spaceLeft < popoverRect.width) {
|
||||
effectivePlacement = 'right'
|
||||
effectivePlacement = 'right';
|
||||
}
|
||||
} else if ('right' === props.placement) {
|
||||
const spaceRight = viewportWidth - triggerRect.right - offset
|
||||
const spaceRight = viewportWidth - triggerRect.right - offset;
|
||||
if (spaceRight < popoverRect.width) {
|
||||
effectivePlacement = 'left'
|
||||
effectivePlacement = 'left';
|
||||
}
|
||||
}
|
||||
|
||||
let top = triggerRect.bottom + offset
|
||||
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2
|
||||
let top = triggerRect.bottom + offset;
|
||||
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
|
||||
|
||||
if ('top' === effectivePlacement) {
|
||||
top = triggerRect.top - popoverRect.height - offset
|
||||
top = triggerRect.top - popoverRect.height - offset;
|
||||
} else if ('left' === effectivePlacement) {
|
||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
||||
left = triggerRect.left - popoverRect.width - offset
|
||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
||||
left = triggerRect.left - popoverRect.width - offset;
|
||||
} else if ('right' === effectivePlacement) {
|
||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
||||
left = triggerRect.right + offset
|
||||
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
|
||||
left = triggerRect.right + offset;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
const maxLeft = viewportWidth - popoverRect.width - 8
|
||||
const maxTop = viewportHeight - popoverRect.height - 8
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
const maxLeft = viewportWidth - popoverRect.width - 8;
|
||||
const maxTop = viewportHeight - popoverRect.height - 8;
|
||||
|
||||
const clampedTop = clamp(Math.round(top), 8, maxTop)
|
||||
const clampedLeft = clamp(Math.round(left), 8, maxLeft)
|
||||
const clampedTop = clamp(Math.round(top), 8, maxTop);
|
||||
const clampedLeft = clamp(Math.round(left), 8, maxLeft);
|
||||
|
||||
portalStyle.value = {
|
||||
position: 'fixed',
|
||||
|
|
@ -267,37 +295,37 @@ const updatePosition = () => {
|
|||
minWidth: `${props.minWidth}px`,
|
||||
maxWidth: `${constrainedMaxWidth}px`,
|
||||
maxHeight: `${props.maxHeight}px`,
|
||||
overflowY: 'auto'
|
||||
}
|
||||
}
|
||||
overflowY: 'auto',
|
||||
};
|
||||
};
|
||||
|
||||
watch(isOpen, async (value) => {
|
||||
if (value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
await nextTick()
|
||||
emit('shown')
|
||||
return
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
await nextTick();
|
||||
emit('shown');
|
||||
return;
|
||||
}
|
||||
|
||||
emit('hidden')
|
||||
})
|
||||
emit('hidden');
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick, true)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
})
|
||||
document.addEventListener('click', handleDocumentClick, true);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick, true)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
clearHoverTimeout()
|
||||
clearOpenTimeout()
|
||||
})
|
||||
document.removeEventListener('click', handleDocumentClick, true);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
clearHoverTimeout();
|
||||
clearOpenTimeout();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
@ -315,7 +343,7 @@ onBeforeUnmount(() => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.popover-trigger>* {
|
||||
.popover-trigger > * {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -332,7 +360,11 @@ onBeforeUnmount(() => {
|
|||
background-color: var(--bulma-scheme-main, #fff);
|
||||
color: var(--bulma-text, #363636);
|
||||
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
|
||||
box-shadow: var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02));
|
||||
box-shadow: var(
|
||||
--bulma-shadow,
|
||||
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
|
||||
0 0px 0 1px rgba(10, 10, 10, 0.02)
|
||||
);
|
||||
border-radius: var(--bulma-radius-large, 0.5rem);
|
||||
pointer-events: auto;
|
||||
padding: 0.9rem 1rem;
|
||||
|
|
|
|||
|
|
@ -6,16 +6,22 @@
|
|||
<div class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span class="icon"
|
||||
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
|
||||
/></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header-icon" v-if="reference">
|
||||
<button type="button" @click="showImport = !showImport">
|
||||
<span class="icon"><i class="fa-solid" :class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-arrow-down': !showImport,
|
||||
'fa-arrow-up': showImport,
|
||||
}"
|
||||
/></span>
|
||||
<span>{{ showImport ? 'Hide' : 'Show' }} import</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -32,15 +38,30 @@
|
|||
</label>
|
||||
<div class="control is-expanded">
|
||||
<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>
|
||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
||||
<optgroup
|
||||
label="Custom presets"
|
||||
v-if="config?.presets.filter((p) => !p?.default).length > 0"
|
||||
>
|
||||
<option
|
||||
v-for="item in filter_presets(false)"
|
||||
:key="item.name"
|
||||
:value="item.name"
|
||||
>
|
||||
{{ prettyName(item.name) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Default presets">
|
||||
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
||||
<option
|
||||
v-for="item in filter_presets(true)"
|
||||
:key="item.name"
|
||||
:value="item.name"
|
||||
>
|
||||
{{ prettyName(item.name) }}
|
||||
</option>
|
||||
</optgroup>
|
||||
|
|
@ -49,8 +70,12 @@
|
|||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Select a preset to import its data. <span class="has-text-danger">Warning: This will
|
||||
overwrite the current form data.</span></span>
|
||||
<span
|
||||
>Select a preset to import its data.
|
||||
<span class="has-text-danger"
|
||||
>Warning: This will overwrite the current form data.</span
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,12 +89,21 @@
|
|||
<div class="field-body">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" id="import_string" v-model="import_string"
|
||||
autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="import_string"
|
||||
v-model="import_string"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-primary" :disabled="!import_string" type="button"
|
||||
@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>Import</span>
|
||||
</button>
|
||||
|
|
@ -78,12 +112,15 @@
|
|||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Paste shared preset string here to import it. <span class="has-text-danger">Warning:
|
||||
This will overwrite the current form data.</span></span>
|
||||
<span
|
||||
>Paste shared preset string here to import it.
|
||||
<span class="has-text-danger"
|
||||
>Warning: This will overwrite the current form data.</span
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
|
|
@ -93,7 +130,13 @@
|
|||
Name
|
||||
</label>
|
||||
<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>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -109,8 +152,15 @@
|
|||
Priority
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="number" class="input" id="priority" v-model.number="form.priority"
|
||||
:disabled="addInProgress" min="0" placeholder="0">
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
id="priority"
|
||||
v-model.number="form.priority"
|
||||
:disabled="addInProgress"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -131,8 +181,15 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
|
||||
v-model="form.folder" :disabled="addInProgress" list="folders">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="folder"
|
||||
placeholder="Leave empty to use default download path"
|
||||
v-model="form.folder"
|
||||
:disabled="addInProgress"
|
||||
list="folders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
|
|
@ -148,8 +205,14 @@
|
|||
Output template
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="output_template" :disabled="addInProgress"
|
||||
placeholder="Leave empty to use default template." v-model="form.template">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="output_template"
|
||||
:disabled="addInProgress"
|
||||
placeholder="Leave empty to use default template."
|
||||
v-model="form.template"
|
||||
/>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<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>Command options for yt-dlp</span>
|
||||
</label>
|
||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||
:disabled="addInProgress" />
|
||||
<TextareaAutocomplete
|
||||
id="cli_options"
|
||||
v-model="form.cli"
|
||||
:options="ytDlpOpt"
|
||||
:disabled="addInProgress"
|
||||
/>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
|
||||
options are supported
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
|
||||
>some are ignored</NuxtLink
|
||||
>. Use with caution.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -180,21 +250,39 @@
|
|||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<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>
|
||||
Cookies - <NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()">Upload file</NuxtLink>
|
||||
Cookies -
|
||||
<NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()"
|
||||
>Upload file</NuxtLink
|
||||
>
|
||||
</label>
|
||||
<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)"
|
||||
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>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Use this cookies if non are given with the URL. Use the <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
|
||||
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
|
||||
Cookie format.
|
||||
<span
|
||||
>Use this cookies if non are given with the URL. Use the
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
|
||||
>
|
||||
Recommended addon</NuxtLink
|
||||
>
|
||||
by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP Cookie
|
||||
format.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -207,8 +295,13 @@
|
|||
Description
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress"
|
||||
placeholder="Extras instructions for users to follow" />
|
||||
<textarea
|
||||
class="textarea"
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
:disabled="addInProgress"
|
||||
placeholder="Extras instructions for users to follow"
|
||||
/>
|
||||
</div>
|
||||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -221,15 +314,24 @@
|
|||
|
||||
<div class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="presetForm">
|
||||
<button
|
||||
class="button is-fullwidth is-primary"
|
||||
:disabled="addInProgress"
|
||||
type="submit"
|
||||
:class="{ 'is-loading': addInProgress }"
|
||||
form="presetForm"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
|
||||
type="button">
|
||||
<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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -250,191 +352,199 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import TextDropzone from '~/components/TextDropzone.vue'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||
import TextDropzone from '~/components/TextDropzone.vue';
|
||||
import type { ImportedItem } from '~/types';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
import type { Preset } from '~/types/presets'
|
||||
import { normalizePresetName, prettyName } from '~/utils'
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { normalizePresetName, prettyName } from '~/utils';
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(event: 'cancel'): void
|
||||
(event: 'submit', payload: { reference: number | null, preset: Preset }): void
|
||||
}>()
|
||||
(event: 'cancel'): void;
|
||||
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
reference?: number | null
|
||||
preset: Partial<Preset>
|
||||
addInProgress?: boolean
|
||||
presets?: Preset[]
|
||||
}>()
|
||||
reference?: number | null;
|
||||
preset: Partial<Preset>;
|
||||
addInProgress?: boolean;
|
||||
presets?: Preset[];
|
||||
}>();
|
||||
|
||||
const config = useConfigStore()
|
||||
const toast = useNotification()
|
||||
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)))
|
||||
const import_string = ref<string>('')
|
||||
const showImport = useStorage<boolean>('showImport', false)
|
||||
const selected_preset = ref<string>('')
|
||||
const showOptions = ref<boolean>(false)
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
|
||||
const config = useConfigStore();
|
||||
const toast = useNotification();
|
||||
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)));
|
||||
const import_string = ref<string>('');
|
||||
const showImport = useStorage<boolean>('showImport', false);
|
||||
const selected_preset = ref<string>('');
|
||||
const showOptions = ref<boolean>(false);
|
||||
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
||||
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
|
||||
|
||||
if (form.priority === undefined) {
|
||||
form.priority = 0
|
||||
form.priority = 0;
|
||||
}
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
.filter(flag => flag.startsWith('--'))
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => config.ytdlp_options,
|
||||
(newOptions) =>
|
||||
(ytDlpOpt.value = newOptions
|
||||
.filter((opt) => !opt.ignored)
|
||||
.flatMap((opt) =>
|
||||
opt.flags
|
||||
.filter((flag) => flag.startsWith('--'))
|
||||
.map((flag) => ({ value: flag, description: opt.description || '' })),
|
||||
)),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const checkInfo = async (): Promise<void> => {
|
||||
for (const key of ['name']) {
|
||||
if (!form[key as keyof Preset]) {
|
||||
toast.error(`The ${key} field is required.`)
|
||||
return
|
||||
toast.error(`The ${key} field is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedName = normalizePresetName(String(form.name))
|
||||
const normalizedName = normalizePresetName(String(form.name));
|
||||
if (!normalizedName) {
|
||||
toast.error('The name field is required.')
|
||||
return
|
||||
toast.error('The name field is required.');
|
||||
return;
|
||||
}
|
||||
form.name = normalizedName
|
||||
form.name = normalizedName;
|
||||
|
||||
if (form.folder) {
|
||||
form.folder = form.folder.trim()
|
||||
await nextTick()
|
||||
form.folder = form.folder.trim();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
if (form.cli && '' !== form.cli) {
|
||||
const options = await convertOptions(form.cli)
|
||||
const options = await convertOptions(form.cli);
|
||||
if (null === options) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
form.cli = form.cli.trim()
|
||||
form.cli = form.cli.trim();
|
||||
}
|
||||
|
||||
const copy: Preset = JSON.parse(JSON.stringify(form))
|
||||
let usedName = false
|
||||
const name = normalizedName
|
||||
const copy: Preset = JSON.parse(JSON.stringify(form));
|
||||
let usedName = false;
|
||||
const name = normalizedName;
|
||||
|
||||
props.presets?.forEach(p => {
|
||||
props.presets?.forEach((p) => {
|
||||
if (p.id === props.reference) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (p.name === name) {
|
||||
usedName = true
|
||||
usedName = true;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (usedName) {
|
||||
toast.error('The preset name is already in use.')
|
||||
return
|
||||
toast.error('The preset name is already in use.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in copy) {
|
||||
const val = copy[key as keyof Preset]
|
||||
const val = copy[key as keyof Preset];
|
||||
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> => {
|
||||
try {
|
||||
const response = await convertCliOptions(args)
|
||||
const response = await convertCliOptions(args);
|
||||
|
||||
if (response.output_template) {
|
||||
form.template = response.output_template
|
||||
form.template = response.output_template;
|
||||
}
|
||||
|
||||
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) {
|
||||
toast.error(e.message)
|
||||
return null
|
||||
toast.error(e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importItem = async (): Promise<void> => {
|
||||
const val = import_string.value.trim()
|
||||
const val = import_string.value.trim();
|
||||
if (!val) {
|
||||
toast.error('The import string is required.')
|
||||
return
|
||||
toast.error('The import string is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = decode(val) as Preset & ImportedItem
|
||||
const item = decode(val) as Preset & ImportedItem;
|
||||
|
||||
if (!item?._type || 'preset' !== item._type) {
|
||||
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
|
||||
return
|
||||
toast.error(
|
||||
`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.name) {
|
||||
form.name = item.name
|
||||
form.name = item.name;
|
||||
}
|
||||
|
||||
if (item.cli) {
|
||||
form.cli = item.cli
|
||||
form.cli = item.cli;
|
||||
}
|
||||
|
||||
if (item.template) {
|
||||
form.template = item.template
|
||||
form.template = item.template;
|
||||
}
|
||||
|
||||
if (item.folder) {
|
||||
form.folder = item.folder
|
||||
form.folder = item.folder;
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
form.description = item.description
|
||||
form.description = item.description;
|
||||
}
|
||||
|
||||
if (item.priority !== undefined) {
|
||||
form.priority = item.priority
|
||||
form.priority = item.priority;
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
showImport.value = false
|
||||
import_string.value = '';
|
||||
showImport.value = false;
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to parse. ${e.message}`)
|
||||
console.error(e);
|
||||
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> => {
|
||||
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) {
|
||||
toast.error('Preset not found.')
|
||||
return
|
||||
toast.error('Preset not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
form.cli = preset.cli || ''
|
||||
form.folder = preset.folder || ''
|
||||
form.template = preset.template || ''
|
||||
form.cookies = preset.cookies || ''
|
||||
form.description = preset.description || ''
|
||||
form.priority = preset.priority ?? 0
|
||||
form.cli = preset.cli || '';
|
||||
form.folder = preset.folder || '';
|
||||
form.template = preset.template || '';
|
||||
form.cookies = preset.cookies || '';
|
||||
form.description = preset.description || '';
|
||||
form.priority = preset.priority ?? 0;
|
||||
|
||||
await nextTick()
|
||||
selected_preset.value = ''
|
||||
}
|
||||
await nextTick();
|
||||
selected_preset.value = '';
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
<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="field is-grouped">
|
||||
<p class="control">
|
||||
<button type="button" class="button is-info" @click="refreshQueue" :class="{ 'is-loading': isRefreshing }"
|
||||
v-tooltip.bottom="'Refresh queue data'">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-info"
|
||||
@click="refreshQueue"
|
||||
:class="{ 'is-loading': isRefreshing }"
|
||||
v-tooltip.bottom="'Refresh queue data'"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-refresh" />
|
||||
</span>
|
||||
|
|
@ -15,35 +23,62 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
|
||||
v-if="filteredItems.length > 0">
|
||||
<div
|
||||
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
|
||||
v-if="filteredItems.length > 0"
|
||||
>
|
||||
<div class="column is-narrow">
|
||||
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll"
|
||||
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }">
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
@click="masterSelectAll = !masterSelectAll"
|
||||
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
|
||||
>
|
||||
<span class="icon">
|
||||
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
|
||||
</span>
|
||||
<span v-if="!masterSelectAll">Select</span>
|
||||
<span v-else>Unselect</span>
|
||||
<span v-if="selectedElms.length > 0">
|
||||
(<u class="has-text-danger">{{ selectedElms.length }}</u>)
|
||||
(<u class="has-text-danger">{{ selectedElms.length }}</u
|
||||
>)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-2-tablet is-5-mobile">
|
||||
<Dropdown label="Actions" icons="fa-solid fa-list">
|
||||
<a v-if="hasManualStart" class="dropdown-item has-text-success" @click="hasSelected ? startItems() : null"
|
||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
||||
<a
|
||||
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>Start</span>
|
||||
</a>
|
||||
<a v-if="hasPausable" class="dropdown-item has-text-warning" @click="hasSelected ? pauseSelected() : null"
|
||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
||||
<a
|
||||
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>Pause</span>
|
||||
</a>
|
||||
<a class="dropdown-item has-text-warning" @click="hasSelected ? cancelSelected() : null"
|
||||
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }">
|
||||
<a
|
||||
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>Cancel</span>
|
||||
</a>
|
||||
|
|
@ -54,16 +89,23 @@
|
|||
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||
<div class="column is-12" v-if="filteredItems.length > 0">
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 1300px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 1300px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
|
||||
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
|
||||
<span class="icon-text is-block">
|
||||
<span class="icon">
|
||||
<i class="fa-regular"
|
||||
:class="{ 'fa-square-check': !masterSelectAll, 'fa-square': masterSelectAll }" />
|
||||
<i
|
||||
class="fa-regular"
|
||||
:class="{
|
||||
'fa-square-check': !masterSelectAll,
|
||||
'fa-square': masterSelectAll,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
|
@ -79,13 +121,20 @@
|
|||
<tr v-for="item in filteredItems" :key="item._id">
|
||||
<td class="has-text-centered is-vcentered">
|
||||
<label class="checkbox is-block">
|
||||
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
|
||||
:value="item._id">
|
||||
<input
|
||||
class="completed-checkbox"
|
||||
type="checkbox"
|
||||
v-model="selectedElms"
|
||||
:id="'checkbox-' + item._id"
|
||||
:value="item._id"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
<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">
|
||||
<template #trigger>
|
||||
<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) }}
|
||||
</p>
|
||||
<hr
|
||||
v-if="(showThumbnails && getImage(config.app.download_path, item, false)) || 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" />
|
||||
v-if="
|
||||
(showThumbnails && getImage(config.app.download_path, item, false)) ||
|
||||
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>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
@ -122,25 +178,33 @@
|
|||
<td>
|
||||
<div class="progress-bar is-unselectable">
|
||||
<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>
|
||||
</td>
|
||||
<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"
|
||||
v-rtime="item.datetime" />
|
||||
<span
|
||||
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
|
||||
:data-datetime="item.datetime"
|
||||
v-rtime="item.datetime"
|
||||
/>
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
|
||||
<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>Play video</span>
|
||||
</NuxtLink>
|
||||
<hr class="dropdown-divider" />
|
||||
</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>Cancel Download</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -163,7 +227,10 @@
|
|||
|
||||
<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>yt-dlp Information</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -182,12 +249,19 @@
|
|||
</div>
|
||||
|
||||
<div class="columns is-multiline" v-else>
|
||||
<LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6"
|
||||
v-for="item in filteredItems" :key="item._id">
|
||||
<LateLoader
|
||||
:unrender="true"
|
||||
:min-height="showThumbnails ? 475 : 265"
|
||||
class="column is-6"
|
||||
v-for="item in filteredItems"
|
||||
:key="item._id"
|
||||
>
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<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 class="card-header-icon">
|
||||
<div class="field is-grouped">
|
||||
|
|
@ -214,14 +288,22 @@
|
|||
</Popover>
|
||||
<div class="control">
|
||||
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
||||
<span class="icon"><i class="fa-solid"
|
||||
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span>
|
||||
<span class="icon"
|
||||
><i
|
||||
class="fa-solid"
|
||||
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }"
|
||||
/></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="checkbox is-block">
|
||||
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
|
||||
:value="item._id">
|
||||
<input
|
||||
class="completed-checkbox"
|
||||
type="checkbox"
|
||||
v-model="selectedElms"
|
||||
:id="'checkbox-' + item._id"
|
||||
:value="item._id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -229,16 +311,27 @@
|
|||
</header>
|
||||
<div v-if="showThumbnails" class="card-image">
|
||||
<figure :class="['image', thumbnail_ratio]">
|
||||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay">
|
||||
<span
|
||||
v-if="isEmbedable(item.url)"
|
||||
@click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay"
|
||||
>
|
||||
<div class="play-icon embed-icon"></div>
|
||||
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
||||
:src="getImage(config.app.download_path, item)" />
|
||||
<img
|
||||
@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" />
|
||||
</span>
|
||||
<template v-else>
|
||||
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
||||
:src="getImage(config.app.download_path, item)" />
|
||||
<img
|
||||
@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" />
|
||||
</template>
|
||||
</figure>
|
||||
|
|
@ -248,7 +341,10 @@
|
|||
<div class="column is-12">
|
||||
<div class="progress-bar is-unselectable">
|
||||
<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 class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
|
|
@ -259,20 +355,27 @@
|
|||
</div>
|
||||
<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 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 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"
|
||||
v-rtime="item.datetime" />
|
||||
<span
|
||||
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
|
||||
:data-datetime="item.datetime"
|
||||
v-rtime="item.datetime"
|
||||
/>
|
||||
</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) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="columns is-multiline is-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>Cancel</span>
|
||||
</button>
|
||||
|
|
@ -284,7 +387,10 @@
|
|||
</button>
|
||||
</div>
|
||||
<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>Pause</span>
|
||||
</button>
|
||||
|
|
@ -292,14 +398,20 @@
|
|||
<div class="column">
|
||||
<Dropdown icons="fa-solid fa-cogs" label="Actions">
|
||||
<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>Play video</span>
|
||||
</NuxtLink>
|
||||
<hr class="dropdown-divider" />
|
||||
</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>yt-dlp Information</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -318,14 +430,24 @@
|
|||
|
||||
<div class="columns is-multiline" v-if="filteredItems.length < 1">
|
||||
<div class="column is-12">
|
||||
<Message class="is-warning" title="Filter results" 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>
|
||||
<Message
|
||||
class="is-warning"
|
||||
title="Filter results"
|
||||
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>
|
||||
You can search using any value shown in the item’s <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>.
|
||||
You can search using any value shown in the item’s
|
||||
<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>
|
||||
|
||||
<h5>Examples:</h5>
|
||||
|
|
@ -336,7 +458,13 @@
|
|||
<li><code>source_name:task_name</code> - items added by the specified task.</li>
|
||||
</ul>
|
||||
</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>
|
||||
</Message>
|
||||
</div>
|
||||
|
|
@ -352,371 +480,376 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { deepIncludes } from '~/utils'
|
||||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { deepIncludes } from '~/utils';
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||
(e: 'getItemInfo', id: string): void
|
||||
(e: 'clear_search'): void
|
||||
}>()
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void;
|
||||
(e: 'getItemInfo', id: string): void;
|
||||
(e: 'clear_search'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
thumbnails?: boolean
|
||||
query?: string
|
||||
}>()
|
||||
thumbnails?: boolean;
|
||||
query?: string;
|
||||
}>();
|
||||
|
||||
const config = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const socket = useSocketStore()
|
||||
const box = useConfirm()
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore();
|
||||
const stateStore = useStateStore();
|
||||
const socket = useSocketStore();
|
||||
const box = useConfirm();
|
||||
const toast = useNotification();
|
||||
|
||||
const hideThumbnail = useStorage('hideThumbnailQueue', false)
|
||||
const display_style = useStorage('display_style', 'grid')
|
||||
const bg_enable = useStorage('random_bg', true)
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||
const show_popover = useStorage<boolean>('show_popover', true)
|
||||
const hideThumbnail = useStorage('hideThumbnailQueue', false);
|
||||
const display_style = useStorage('display_style', 'grid');
|
||||
const bg_enable = useStorage('random_bg', true);
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.95);
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||
const show_popover = useStorage<boolean>('show_popover', true);
|
||||
|
||||
const selectedElms = ref<string[]>([])
|
||||
const masterSelectAll = ref(false)
|
||||
const embed_url = ref('')
|
||||
const isRefreshing = ref(false)
|
||||
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null)
|
||||
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true)
|
||||
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000)
|
||||
const selectedElms = ref<string[]>([]);
|
||||
const masterSelectAll = ref(false);
|
||||
const embed_url = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
|
||||
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
|
||||
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 () => {
|
||||
isRefreshing.value = true
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await stateStore.loadQueue()
|
||||
await stateStore.loadQueue();
|
||||
} catch {
|
||||
toast.error('Failed to refresh queue')
|
||||
toast.error('Failed to refresh queue');
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
if (autoRefreshInterval.value) {
|
||||
clearInterval(autoRefreshInterval.value)
|
||||
clearInterval(autoRefreshInterval.value);
|
||||
}
|
||||
|
||||
if (!autoRefreshEnabled.value || socket.isConnected) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
autoRefreshInterval.value = setInterval(async () => {
|
||||
if (!socket.isConnected && autoRefreshEnabled.value) {
|
||||
await refreshQueue()
|
||||
await refreshQueue();
|
||||
}
|
||||
}, autoRefreshDelay.value)
|
||||
}
|
||||
}, autoRefreshDelay.value);
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (autoRefreshInterval.value) {
|
||||
clearInterval(autoRefreshInterval.value)
|
||||
autoRefreshInterval.value = null
|
||||
clearInterval(autoRefreshInterval.value);
|
||||
autoRefreshInterval.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => socket.isConnected, (connected) => {
|
||||
if (connected) {
|
||||
stopAutoRefresh()
|
||||
} else if (autoRefreshEnabled.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => socket.isConnected,
|
||||
(connected) => {
|
||||
if (connected) {
|
||||
stopAutoRefresh();
|
||||
} else if (autoRefreshEnabled.value) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(autoRefreshEnabled, (enabled) => {
|
||||
if (enabled && !socket.isConnected) {
|
||||
startAutoRefresh()
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
stopAutoRefresh();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!socket.isConnected && autoRefreshEnabled.value) {
|
||||
startAutoRefresh()
|
||||
startAutoRefresh();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => stopAutoRefresh())
|
||||
onBeforeUnmount(() => stopAutoRefresh());
|
||||
|
||||
watch(masterSelectAll, (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 {
|
||||
selectedElms.value = []
|
||||
selectedElms.value = [];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const filteredItems = computed<StoreItem[]>(() => {
|
||||
const q = props.query?.toLowerCase()
|
||||
const q = props.query?.toLowerCase();
|
||||
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(() => {
|
||||
if (0 > stateStore.count('queue')) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return false;
|
||||
});
|
||||
|
||||
const hasPausable = computed(() => {
|
||||
if (0 > stateStore.count('queue')) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return false;
|
||||
});
|
||||
|
||||
const setIcon = (item: StoreItem): string => {
|
||||
if (!item.auto_start) {
|
||||
return 'fa-hourglass-half'
|
||||
return 'fa-hourglass-half';
|
||||
}
|
||||
if ('downloading' === item.status && item.is_live) {
|
||||
return 'fa-globe fa-spin'
|
||||
return 'fa-globe fa-spin';
|
||||
}
|
||||
if ('downloading' === item.status) {
|
||||
return 'fa-download'
|
||||
return 'fa-download';
|
||||
}
|
||||
if ('postprocessing' === item.status) {
|
||||
return 'fa-cog fa-spin'
|
||||
return 'fa-cog fa-spin';
|
||||
}
|
||||
if (null === item.status && true === config.paused) {
|
||||
return 'fa-pause-circle'
|
||||
return 'fa-pause-circle';
|
||||
}
|
||||
if (!item.status) {
|
||||
return 'fa-question'
|
||||
return 'fa-question';
|
||||
}
|
||||
return 'fa-spinner fa-spin'
|
||||
}
|
||||
return 'fa-spinner fa-spin';
|
||||
};
|
||||
|
||||
const setStatus = (item: StoreItem): string => {
|
||||
if (!item.auto_start) {
|
||||
return 'Pending'
|
||||
return 'Pending';
|
||||
}
|
||||
if (null === item.status && true === config.paused) {
|
||||
return 'Paused'
|
||||
return 'Paused';
|
||||
}
|
||||
if ('downloading' === item.status && item.is_live) {
|
||||
return 'Streaming'
|
||||
return 'Streaming';
|
||||
}
|
||||
|
||||
if ('started' === item.status) {
|
||||
return 'Starting'
|
||||
return 'Starting';
|
||||
}
|
||||
|
||||
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) {
|
||||
return 'Unknown...'
|
||||
return 'Unknown...';
|
||||
}
|
||||
return ucFirst(item.status)
|
||||
}
|
||||
return ucFirst(item.status);
|
||||
};
|
||||
|
||||
const setIconColor = (item: StoreItem): string => {
|
||||
if (['downloading', 'started'].includes(item.status as string)) {
|
||||
return 'has-text-success'
|
||||
return 'has-text-success';
|
||||
}
|
||||
if ('postprocessing' === item.status) {
|
||||
return 'has-text-info'
|
||||
return 'has-text-info';
|
||||
}
|
||||
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 => {
|
||||
if (null === value || 0 === value) {
|
||||
return 'Live'
|
||||
return 'Live';
|
||||
}
|
||||
if (value < 60) {
|
||||
return `${Math.round(value)}s`
|
||||
return `${Math.round(value)}s`;
|
||||
}
|
||||
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 minutes = value % 3600
|
||||
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
|
||||
}
|
||||
const hours = Math.floor(value / 3600);
|
||||
const minutes = value % 3600;
|
||||
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
|
||||
};
|
||||
|
||||
const speedPipe = (value: number | null): string => {
|
||||
if (null === value || 0 === value) {
|
||||
return '0KB/s'
|
||||
return '0KB/s';
|
||||
}
|
||||
const k = 1024
|
||||
const dm = 2
|
||||
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))
|
||||
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
const k = 1024;
|
||||
const dm = 2;
|
||||
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));
|
||||
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const percentPipe = (value: number | null): string => {
|
||||
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 => {
|
||||
let string = ''
|
||||
let string = '';
|
||||
if (!item.auto_start) {
|
||||
return 'Manual start'
|
||||
return 'Manual start';
|
||||
}
|
||||
if (null === item.status && true === config.paused) {
|
||||
return 'Global Pause'
|
||||
return 'Global Pause';
|
||||
}
|
||||
|
||||
if ('started' === item.status) {
|
||||
return 'Starting'
|
||||
return 'Starting';
|
||||
}
|
||||
|
||||
if ('postprocessing' === item.status) {
|
||||
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) {
|
||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing';
|
||||
}
|
||||
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) {
|
||||
string += ' - ' + ETAPipe(item.eta)
|
||||
string += ' - ' + ETAPipe(item.eta);
|
||||
}
|
||||
return string
|
||||
}
|
||||
return string;
|
||||
};
|
||||
|
||||
const confirmCancel = async (item: StoreItem) => {
|
||||
if (true !== (await box.confirm(`Cancel '${item.title}'?`))) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
cancelItems(item._id)
|
||||
return true
|
||||
}
|
||||
cancelItems(item._id);
|
||||
return true;
|
||||
};
|
||||
|
||||
const cancelSelected = async () => {
|
||||
if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
cancelItems(selectedElms.value)
|
||||
selectedElms.value = []
|
||||
masterSelectAll.value = false
|
||||
return true
|
||||
}
|
||||
cancelItems(selectedElms.value);
|
||||
selectedElms.value = [];
|
||||
masterSelectAll.value = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const cancelItems = (item: string | string[]) => {
|
||||
const items: string[] = []
|
||||
const items: string[] = [];
|
||||
if ('object' === typeof item) {
|
||||
for (const key in item) {
|
||||
items.push((item as any)[key])
|
||||
items.push((item as any)[key]);
|
||||
}
|
||||
} else {
|
||||
items.push(item)
|
||||
items.push(item);
|
||||
}
|
||||
if (0 > items.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
stateStore.cancelItems(items)
|
||||
}
|
||||
stateStore.cancelItems(items);
|
||||
};
|
||||
|
||||
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id])
|
||||
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id])
|
||||
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id]);
|
||||
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id]);
|
||||
|
||||
const startItems = async () => {
|
||||
if (1 > selectedElms.value.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const filtered: string[] = []
|
||||
selectedElms.value.forEach(id => {
|
||||
const item = stateStore.get('queue', id) as StoreItem
|
||||
const filtered: string[] = [];
|
||||
selectedElms.value.forEach((id) => {
|
||||
const item = stateStore.get('queue', id) as StoreItem;
|
||||
if (item && !item.auto_start && !item.status) {
|
||||
filtered.push(id)
|
||||
filtered.push(id);
|
||||
}
|
||||
})
|
||||
selectedElms.value = []
|
||||
});
|
||||
selectedElms.value = [];
|
||||
if (1 > filtered.length) {
|
||||
toast.error('No eligible items to start.')
|
||||
return
|
||||
toast.error('No eligible items to start.');
|
||||
return;
|
||||
}
|
||||
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 () => {
|
||||
if (1 > selectedElms.value.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const filtered: string[] = []
|
||||
selectedElms.value.forEach(id => {
|
||||
const item = stateStore.get('queue', id) as StoreItem
|
||||
const filtered: string[] = [];
|
||||
selectedElms.value.forEach((id) => {
|
||||
const item = stateStore.get('queue', id) as StoreItem;
|
||||
if (item && item.auto_start && !item.status) {
|
||||
filtered.push(id)
|
||||
filtered.push(id);
|
||||
}
|
||||
})
|
||||
selectedElms.value = []
|
||||
});
|
||||
selectedElms.value = [];
|
||||
if (1 > filtered.length) {
|
||||
toast.error('No eligible items to pause.')
|
||||
return
|
||||
toast.error('No eligible items to pause.');
|
||||
return;
|
||||
}
|
||||
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 target = e.target as HTMLImageElement
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.naturalHeight > target.naturalWidth) {
|
||||
target.classList.add('image-portrait')
|
||||
target.classList.add('image-portrait');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onImgError = (e: Event) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
const target = e.target as HTMLImageElement;
|
||||
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) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<template>
|
||||
<div class="modal" :class="{ 'is-active': isOpen }">
|
||||
<div class="modal-background" @click="emitter('close')" />
|
||||
<div class="modal-card"
|
||||
:class="{ 'slide-from-right': direction === 'right', 'slide-from-left': direction === 'left' }">
|
||||
<div
|
||||
class="modal-card"
|
||||
:class="{
|
||||
'slide-from-right': direction === 'right',
|
||||
'slide-from-left': direction === 'left',
|
||||
}"
|
||||
>
|
||||
<header class="modal-card-head is-rounded-less">
|
||||
<p class="modal-card-title">WebUI Settings</p>
|
||||
<button class="delete" @click="emitter('close')" aria-label="close" />
|
||||
|
|
@ -12,7 +17,12 @@
|
|||
<div class="field">
|
||||
<label class="label">Page View</label>
|
||||
<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">
|
||||
{{ simpleMode ? 'Simple View' : 'Regular View' }}
|
||||
</label>
|
||||
|
|
@ -36,17 +46,17 @@
|
|||
<label class="label">Color scheme</label>
|
||||
<div class="control">
|
||||
<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>Auto</span>
|
||||
</label>
|
||||
<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>Light</span>
|
||||
</label>
|
||||
<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>Dark</span>
|
||||
</label>
|
||||
|
|
@ -56,7 +66,7 @@
|
|||
<div class="field">
|
||||
<label class="label">Show Background</label>
|
||||
<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">
|
||||
{{ bg_enable ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -65,9 +75,16 @@
|
|||
|
||||
<div class="field" v-if="bg_enable">
|
||||
<div class="control">
|
||||
<button @click="$emit('reload_bg')" class="button is-link is-light is-fullwidth" :disabled="isLoading">
|
||||
<span class="icon"><i class="fa"
|
||||
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
|
||||
<button
|
||||
@click="$emit('reload_bg')"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -82,7 +99,14 @@
|
|||
</a>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -101,7 +125,11 @@
|
|||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<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 }})
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -112,7 +140,12 @@
|
|||
<div class="field">
|
||||
<label class="label">Show Thumbnails</label>
|
||||
<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">
|
||||
{{ show_thumbnail ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -127,11 +160,11 @@
|
|||
<label class="label">Aspect Ratio</label>
|
||||
<div class="control">
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -143,7 +176,12 @@
|
|||
<div class="field">
|
||||
<label class="label">Popover</label>
|
||||
<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">
|
||||
{{ show_popover ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -166,7 +204,12 @@
|
|||
<div class="field">
|
||||
<label class="label">Auto-refresh queue when disconnected</label>
|
||||
<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">
|
||||
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
|
||||
</label>
|
||||
|
|
@ -186,7 +229,14 @@
|
|||
</a>
|
||||
</div>
|
||||
<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>
|
||||
<p class="help">
|
||||
|
|
@ -207,7 +257,12 @@
|
|||
<div class="field">
|
||||
<label class="label">Show notifications</label>
|
||||
<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">
|
||||
{{ allow_toasts ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -254,7 +309,12 @@
|
|||
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
|
||||
<label class="label">Dismiss notification on click</label>
|
||||
<div class="control">
|
||||
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click">
|
||||
<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">
|
||||
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
|
||||
</label>
|
||||
|
|
@ -267,83 +327,91 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import { watch, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { POSITION } from 'vue-toastification'
|
||||
import { useConfigStore } from '~/stores/ConfigStore'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import type { notificationTarget } from '~/composables/useNotification'
|
||||
import 'assets/css/bulma-switch.css';
|
||||
import { watch, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { POSITION } from 'vue-toastification';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import type { notificationTarget } from '~/composables/useNotification';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
isOpen?: boolean
|
||||
direction?: 'left' | 'right'
|
||||
isLoading?: boolean
|
||||
}>(), {
|
||||
isOpen: false,
|
||||
direction: 'right',
|
||||
isLoading: false
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
isOpen?: boolean;
|
||||
direction?: 'left' | 'right';
|
||||
isLoading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
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 notification = useNotification()
|
||||
const config = useConfigStore();
|
||||
const notification = useNotification();
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto')
|
||||
const allow_toasts = useStorage<boolean>('allow_toasts', true)
|
||||
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
||||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||
const toast_target = useStorage<notificationTarget>('toast_target', 'toast')
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||
const show_popover = useStorage<boolean>('show_popover', true)
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
||||
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_delay = useStorage<number>('queue_auto_refresh_delay', 10000)
|
||||
const isSecureContext = ref<boolean>(false)
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto');
|
||||
const allow_toasts = useStorage<boolean>('allow_toasts', true);
|
||||
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
|
||||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true);
|
||||
const toast_target = useStorage<notificationTarget>('toast_target', 'toast');
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||
const show_popover = useStorage<boolean>('show_popover', true);
|
||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
|
||||
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_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
||||
const isSecureContext = ref<boolean>(false);
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ('Escape' === e.key && props.isOpen) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emitter('close')
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emitter('close');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
isSecureContext.value = window.isSecureContext
|
||||
await nextTick()
|
||||
isSecureContext.value = window.isSecureContext;
|
||||
await nextTick();
|
||||
|
||||
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> => {
|
||||
if ('browser' === toast_target.value) {
|
||||
const permission = await notification.requestBrowserPermission()
|
||||
const permission = await notification.requestBrowserPermission();
|
||||
if ('granted' !== permission) {
|
||||
toast_target.value = 'toast'
|
||||
notification.warning('Browser notification permission denied. Reverting to toast notifications.')
|
||||
toast_target.value = 'toast';
|
||||
notification.warning(
|
||||
'Browser notification permission denied. Reverting to toast notifications.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.isOpen, isOpen => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('settings-panel-open')
|
||||
} else {
|
||||
document.body.classList.remove('settings-panel-open')
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('settings-panel-open');
|
||||
} else {
|
||||
document.body.classList.remove('settings-panel-open');
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -386,7 +454,6 @@ watch(() => props.isOpen, isOpen => {
|
|||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
||||
.modal-card.slide-from-right,
|
||||
.modal-card.slide-from-left {
|
||||
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>
|
||||
code {
|
||||
color: var(--bulma-code) !important
|
||||
color: var(--bulma-code) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -16,14 +16,19 @@ code {
|
|||
</span>
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input id="url" v-model="url" type="url" class="input" :class="{ 'is-danger': urlError }"
|
||||
placeholder="https://..." required />
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
<p v-if="urlError" class="help is-danger">{{ urlError }}</p>
|
||||
<p v-else class="help is-bold">
|
||||
Enter the URL of the resource you want to inspect.
|
||||
</p>
|
||||
<p v-else class="help is-bold">Enter the URL of the resource you want to inspect.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="preset">
|
||||
|
|
@ -35,13 +40,24 @@ code {
|
|||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="preset" class="is-fullwidth" v-model="preset">
|
||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
||||
<optgroup
|
||||
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 }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<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 }}
|
||||
</option>
|
||||
</optgroup>
|
||||
|
|
@ -50,8 +66,8 @@ code {
|
|||
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
Select a preset to apply its settings during inspection. In real scenario, the preset will be based on what is
|
||||
selected when creating the task.
|
||||
Select a preset to apply its settings during inspection. In real scenario, the preset will
|
||||
be based on what is selected when creating the task.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
|
|
@ -62,12 +78,18 @@ code {
|
|||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
<p class="help is-bold">
|
||||
In real scenario, the system auto-detects the appropriate handler based on the URL. This field is for testing
|
||||
purposes only.
|
||||
In real scenario, the system auto-detects the appropriate handler based on the URL. This
|
||||
field is for testing purposes only.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field is-grouped is-grouped-right">
|
||||
|
|
@ -96,8 +118,12 @@ code {
|
|||
</Message>
|
||||
|
||||
<div v-if="response" class="mt-4">
|
||||
<Message v-if="response.error" class="is-danger" title="Error"
|
||||
icon="fas fa-exclamation-triangle">
|
||||
<Message
|
||||
v-if="response.error"
|
||||
class="is-danger"
|
||||
title="Error"
|
||||
icon="fas fa-exclamation-triangle"
|
||||
>
|
||||
<p>{{ response.error }}</p>
|
||||
<p v-if="response.message">{{ response.message }}</p>
|
||||
</Message>
|
||||
|
|
@ -110,76 +136,92 @@ code {
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { request } from '~/utils'
|
||||
import { useConfigStore } from '~/stores/ConfigStore'
|
||||
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect'
|
||||
import { ref, watch } from 'vue';
|
||||
import { request } from '~/utils';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
|
||||
|
||||
const props = defineProps<{
|
||||
url?: string
|
||||
preset?: string
|
||||
handler?: string
|
||||
}>()
|
||||
url?: string;
|
||||
preset?: string;
|
||||
handler?: string;
|
||||
}>();
|
||||
|
||||
const config = useConfigStore()
|
||||
const url = ref<string>(props.url ?? '')
|
||||
const preset = ref<string>(props.preset || config.app.default_preset || '')
|
||||
const handler = ref<string>(props.handler ?? '')
|
||||
const loading = ref<boolean>(false)
|
||||
const response = ref<TaskInspectResponse | null>(null)
|
||||
const urlError = ref<string>('')
|
||||
const config = useConfigStore();
|
||||
const url = ref<string>(props.url ?? '');
|
||||
const preset = ref<string>(props.preset || config.app.default_preset || '');
|
||||
const handler = ref<string>(props.handler ?? '');
|
||||
const loading = ref<boolean>(false);
|
||||
const response = ref<TaskInspectResponse | null>(null);
|
||||
const urlError = ref<string>('');
|
||||
|
||||
// Watch for prop changes and update fields
|
||||
watch(() => props.url, 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 })
|
||||
watch(
|
||||
() => props.url,
|
||||
(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 => {
|
||||
try {
|
||||
const u = new URL(val)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
const u = new URL(val);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit() {
|
||||
urlError.value = ''
|
||||
response.value = null
|
||||
urlError.value = '';
|
||||
response.value = null;
|
||||
|
||||
if (!url.value || !validateUrl(url.value)) {
|
||||
urlError.value = 'Please enter a valid URL.'
|
||||
return
|
||||
urlError.value = 'Please enter a valid URL.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
|
||||
const payload: TaskInspectRequest = {
|
||||
url: url.value.trim(),
|
||||
preset: preset.value.trim() || undefined,
|
||||
handler: handler.value.trim() || undefined,
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await request('/api/tasks/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
response.value = await res.json()
|
||||
});
|
||||
response.value = await res.json();
|
||||
} catch (err: any) {
|
||||
response.value = { error: err?.message || 'Unknown error' }
|
||||
response.value = { error: err?.message || 'Unknown error' };
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
url.value = props.url || ''
|
||||
preset.value = props.preset || config.app.default_preset || ''
|
||||
handler.value = props.handler || ''
|
||||
response.value = null
|
||||
urlError.value = ''
|
||||
}
|
||||
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
|
||||
url.value = props.url || '';
|
||||
preset.value = props.preset || config.app.default_preset || '';
|
||||
handler.value = props.handler || '';
|
||||
response.value = null;
|
||||
urlError.value = '';
|
||||
};
|
||||
const filter_presets = (flag: boolean = true) =>
|
||||
config.presets.filter((item) => item.default === flag);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,53 @@
|
|||
<template>
|
||||
<div class="file-dropzone is-relative" :class="{ 'is-dragging': isDragging }" @drop.prevent="handleDrop"
|
||||
@dragover.prevent="handleDragOver" @dragenter.prevent="handleDragEnter" @dragleave.prevent="handleDragLeave"
|
||||
@click="handleClick">
|
||||
<div
|
||||
class="file-dropzone is-relative"
|
||||
: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 }"
|
||||
:value="model" @input="handleInput" :disabled="disabled" :placeholder="placeholder" :id="id" />
|
||||
|
||||
<div 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">
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
id?: string
|
||||
accept?: string
|
||||
maxSize?: number
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -34,202 +55,202 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
placeholder: '',
|
||||
id: '',
|
||||
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 fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const isDragging = ref<boolean>(false)
|
||||
const dragCounter = ref<number>(0)
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const isDragging = ref<boolean>(false);
|
||||
const dragCounter = ref<number>(0);
|
||||
|
||||
const handleInput = (event: Event): void => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
model.value = target.value
|
||||
}
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
model.value = target.value;
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: DragEvent): void => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounter.value++
|
||||
dragCounter.value++;
|
||||
if (event.dataTransfer?.types.includes('Files')) {
|
||||
isDragging.value = true
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (_event: DragEvent): void => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
dragCounter.value--
|
||||
dragCounter.value--;
|
||||
if (0 === dragCounter.value) {
|
||||
isDragging.value = false
|
||||
isDragging.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent): void => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent): Promise<void> => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragCounter.value = 0
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
const files = event.dataTransfer?.files;
|
||||
if (!files || 0 === files.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0]
|
||||
const file = files[0];
|
||||
if (file) {
|
||||
await processFile(file)
|
||||
await processFile(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection()
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().length > 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && event.target === textareaRef.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: Event): Promise<void> => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = target.files
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = target.files;
|
||||
|
||||
if (!files || 0 === files.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0]
|
||||
const file = files[0];
|
||||
if (file) {
|
||||
await processFile(file)
|
||||
await processFile(file);
|
||||
}
|
||||
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
fileInputRef.value.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processFile = async (file: File): Promise<void> => {
|
||||
try {
|
||||
if (file.size > props.maxSize) {
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
|
||||
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2)
|
||||
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`
|
||||
emit('error', errorMsg)
|
||||
console.error(errorMsg)
|
||||
return
|
||||
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
|
||||
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2);
|
||||
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`;
|
||||
emit('error', errorMsg);
|
||||
console.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const isBinary = await checkIfBinary(file)
|
||||
const isBinary = await checkIfBinary(file);
|
||||
if (isBinary) {
|
||||
const errorMsg = 'File appears to be binary. Please provide a text file.'
|
||||
emit('error', errorMsg)
|
||||
console.error(errorMsg)
|
||||
return
|
||||
const errorMsg = 'File appears to be binary. Please provide a text file.';
|
||||
emit('error', errorMsg);
|
||||
console.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await readFileAsText(file)
|
||||
model.value = text
|
||||
const text = await readFileAsText(file);
|
||||
model.value = text;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to read file'
|
||||
emit('error', errorMsg)
|
||||
console.error('Failed to read file:', error)
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to read file';
|
||||
emit('error', errorMsg);
|
||||
console.error('Failed to read file:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkIfBinary = async (file: File): Promise<boolean> => {
|
||||
const chunkSize = 8192
|
||||
const blob = file.slice(0, chunkSize)
|
||||
const chunkSize = 8192;
|
||||
const blob = file.slice(0, chunkSize);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
if (!e.target?.result) {
|
||||
resolve(false)
|
||||
return
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(e.target.result as ArrayBuffer)
|
||||
const bytes = new Uint8Array(e.target.result as ArrayBuffer);
|
||||
|
||||
let nullBytes = 0
|
||||
let nonPrintable = 0
|
||||
let nullBytes = 0;
|
||||
let nonPrintable = 0;
|
||||
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const byte = bytes[i]
|
||||
const byte = bytes[i];
|
||||
|
||||
if (undefined === byte) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (0 === byte) {
|
||||
nullBytes++
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
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.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Failed to read file for binary check'));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
};
|
||||
|
||||
const readFileAsText = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string)
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'))
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('File reading error'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
reader.onerror = () => reject(new Error('File reading error'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
|
||||
const triggerFileSelect = (): void => {
|
||||
if (props.disabled) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
defineExpose({ triggerFileSelect })
|
||||
defineExpose({ triggerFileSelect });
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,43 @@
|
|||
<template>
|
||||
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||
<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
|
||||
class="dropdown"
|
||||
:class="{ 'is-active': showList && filteredOptions.length }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<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 class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||
<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)"
|
||||
<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">
|
||||
<a
|
||||
v-for="(option, idx) in filteredOptions"
|
||||
:key="option.value"
|
||||
@mousedown.prevent="appendFlag(option.value)"
|
||||
: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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,193 +45,204 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
|
||||
const props = defineProps<{
|
||||
options: AutoCompleteOptions
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}>()
|
||||
options: AutoCompleteOptions;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string>()
|
||||
const localValue = ref(model.value || '')
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const caretIndex = ref(0)
|
||||
const showList = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const dropdownItems = ref<HTMLElement[]>([])
|
||||
const model = defineModel<string>();
|
||||
const localValue = ref(model.value || '');
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const caretIndex = ref(0);
|
||||
const showList = ref(false);
|
||||
const highlightedIndex = ref(-1);
|
||||
const dropdownItems = ref<HTMLElement[]>([]);
|
||||
|
||||
watch(model, val => localValue.value = val || '')
|
||||
watch(localValue, val => model.value = val)
|
||||
watch(model, (val) => (localValue.value = val || ''));
|
||||
watch(localValue, (val) => (model.value = val));
|
||||
|
||||
// Compute token at caret position
|
||||
const getCurrentToken = (value: string, caret: number) => {
|
||||
const left = value.slice(0, caret)
|
||||
const right = value.slice(caret)
|
||||
const leftMatch = left.match(/(\S+)$/)
|
||||
const rightMatch = right.match(/^(\S+)/)
|
||||
const leftToken: string = (leftMatch?.[1] ?? '') as string
|
||||
const rightToken: string = (rightMatch?.[1] ?? '') as string
|
||||
const token = leftToken + rightToken
|
||||
const start = leftMatch ? caret - leftToken.length : caret
|
||||
const end = rightMatch ? caret + rightToken.length : caret
|
||||
return { token, start, end }
|
||||
}
|
||||
const left = value.slice(0, caret);
|
||||
const right = value.slice(caret);
|
||||
const leftMatch = left.match(/(\S+)$/);
|
||||
const rightMatch = right.match(/^(\S+)/);
|
||||
const leftToken: string = (leftMatch?.[1] ?? '') as string;
|
||||
const rightToken: string = (rightMatch?.[1] ?? '') as string;
|
||||
const token = leftToken + rightToken;
|
||||
const start = leftMatch ? caret - leftToken.length : caret;
|
||||
const end = rightMatch ? caret + rightToken.length : caret;
|
||||
return { token, start, end };
|
||||
};
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||
// Hide suggestions once '=' is present in the current token
|
||||
if (!token || token.includes('=')) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
// Only suggest when typing a long flag starting with `--`
|
||||
if (!token.startsWith('--')) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
// Hide suggestions if token exactly matches an option value
|
||||
if (props.options.some(opt => opt.value === token)) {
|
||||
return []
|
||||
if (props.options.some((opt) => opt.value === token)) {
|
||||
return [];
|
||||
}
|
||||
const val = token.toLowerCase()
|
||||
const startsWithFlag = []
|
||||
const includesFlag = []
|
||||
const includesDesc = []
|
||||
const val = token.toLowerCase();
|
||||
const startsWithFlag = [];
|
||||
const includesFlag = [];
|
||||
const includesDesc = [];
|
||||
for (const opt of props.options) {
|
||||
const flag = opt.value.toLowerCase()
|
||||
const desc = opt.description.toLowerCase()
|
||||
const flag = opt.value.toLowerCase();
|
||||
const desc = opt.description.toLowerCase();
|
||||
if (flag.startsWith(val)) {
|
||||
startsWithFlag.push(opt)
|
||||
startsWithFlag.push(opt);
|
||||
} else if (flag.includes(val)) {
|
||||
includesFlag.push(opt)
|
||||
includesFlag.push(opt);
|
||||
} else if (desc.includes(val)) {
|
||||
includesDesc.push(opt)
|
||||
includesDesc.push(opt);
|
||||
}
|
||||
}
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||
})
|
||||
return [...startsWithFlag, ...includesFlag, ...includesDesc];
|
||||
});
|
||||
|
||||
const appendFlag = (flag: string) => {
|
||||
const value = localValue.value
|
||||
const caret = caretIndex.value
|
||||
const { token, start, end } = getCurrentToken(value, caret)
|
||||
const value = localValue.value;
|
||||
const caret = caretIndex.value;
|
||||
const { token, start, end } = getCurrentToken(value, caret);
|
||||
if (token) {
|
||||
// Replace only the flag part of the token, preserving any '=value' suffix in the same token
|
||||
const eqPos = token.indexOf('=')
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : ''
|
||||
localValue.value = value.slice(0, start) + flag + after + value.slice(end)
|
||||
const eqPos = token.indexOf('=');
|
||||
const after = eqPos !== -1 ? token.slice(eqPos) : '';
|
||||
localValue.value = value.slice(0, start) + flag + after + value.slice(end);
|
||||
nextTick(() => {
|
||||
const pos = start + flag.length + after.length
|
||||
const pos = start + flag.length + after.length;
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos
|
||||
caretIndex.value = pos
|
||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
||||
caretIndex.value = pos;
|
||||
}
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// No token at caret: append at caret position
|
||||
const needsSpace = caret > 0 && value[caret - 1] !== ' '
|
||||
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret)
|
||||
const needsSpace = caret > 0 && value[caret - 1] !== ' ';
|
||||
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret);
|
||||
nextTick(() => {
|
||||
const pos = caret + (needsSpace ? 1 : 0) + flag.length
|
||||
const pos = caret + (needsSpace ? 1 : 0) + flag.length;
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos
|
||||
caretIndex.value = pos
|
||||
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
|
||||
caretIndex.value = pos;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
updateCaret()
|
||||
showList.value = true
|
||||
}
|
||||
updateCaret();
|
||||
showList.value = true;
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
updateCaret()
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||
const hasEqual = token.includes('=')
|
||||
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
||||
showList.value = isFlagTrigger && filteredOptions.value.length > 0
|
||||
highlightedIndex.value = showList.value ? 0 : -1
|
||||
}
|
||||
updateCaret();
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||
const hasEqual = token.includes('=');
|
||||
const isFlagTrigger = token.startsWith('--') && !hasEqual;
|
||||
showList.value = isFlagTrigger && filteredOptions.value.length > 0;
|
||||
highlightedIndex.value = showList.value ? 0 : -1;
|
||||
};
|
||||
|
||||
// Reset scroll position when filtered options change
|
||||
watch(filteredOptions, () => {
|
||||
highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1
|
||||
highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1;
|
||||
nextTick(() => {
|
||||
const dropdown = document.querySelector('.dropdown-content')
|
||||
const dropdown = document.querySelector('.dropdown-content');
|
||||
if (dropdown) {
|
||||
dropdown.scrollTop = 0
|
||||
dropdown.scrollTop = 0;
|
||||
}
|
||||
const items = document.querySelectorAll('.dropdown-item')
|
||||
dropdownItems.value = Array.from(items) as HTMLElement[]
|
||||
})
|
||||
})
|
||||
const items = document.querySelectorAll('.dropdown-item');
|
||||
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) => {
|
||||
// Track caret and allow ESC to immediately close suggestions and restore normal keys
|
||||
updateCaret()
|
||||
updateCaret();
|
||||
if (e.key === 'Escape') {
|
||||
showList.value = false
|
||||
highlightedIndex.value = -1
|
||||
return
|
||||
showList.value = false;
|
||||
highlightedIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
if (!showList.value || !filteredOptions.value.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = 5
|
||||
const pageSize = 5;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
const el = dropdownItems.value[highlightedIndex.value];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
const el = dropdownItems.value[highlightedIndex.value];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
} else if (e.key === 'PageDown') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + pageSize,
|
||||
filteredOptions.value.length - 1,
|
||||
);
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
const el = dropdownItems.value[highlightedIndex.value];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
} else if (e.key === 'PageUp') {
|
||||
e.preventDefault()
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||
e.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
|
||||
nextTick(() => {
|
||||
const el = dropdownItems.value[highlightedIndex.value]
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
const el = dropdownItems.value[highlightedIndex.value];
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value)
|
||||
const hasEqual = token.includes('=')
|
||||
const isFlagTrigger = token.startsWith('--') && !hasEqual
|
||||
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||
filteredOptions.value[highlightedIndex.value] : undefined
|
||||
const { token } = getCurrentToken(localValue.value, caretIndex.value);
|
||||
const hasEqual = token.includes('=');
|
||||
const isFlagTrigger = token.startsWith('--') && !hasEqual;
|
||||
const selected =
|
||||
highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
|
||||
? filteredOptions.value[highlightedIndex.value]
|
||||
: undefined;
|
||||
// Only autocomplete if there's a partial word being typed
|
||||
if (selected && isFlagTrigger) {
|
||||
e.preventDefault()
|
||||
appendFlag(selected.value)
|
||||
e.preventDefault();
|
||||
appendFlag(selected.value);
|
||||
}
|
||||
}
|
||||
|
||||
// dropdownItems is updated via a single top-level watch(filteredOptions)
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -83,25 +83,51 @@
|
|||
|
||||
<template>
|
||||
<div v-if="infoLoaded">
|
||||
<div style="position: relative;">
|
||||
<video class="player" ref="video" :poster="uri(thumbnail)" playsinline 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" />
|
||||
<div style="position: relative">
|
||||
<video
|
||||
class="player"
|
||||
ref="video"
|
||||
:poster="uri(thumbnail)"
|
||||
playsinline
|
||||
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>
|
||||
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<div>
|
||||
<span v-if="infoLoaded && !usingHls && hasVideoStream" class="is-hidden-mobile has-text-info is-pointer"
|
||||
@click.prevent="forceSwitchToHls">
|
||||
<span
|
||||
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>Trouble playing? switch to HLS stream.</span>
|
||||
</span>
|
||||
</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>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
|
||||
</span>
|
||||
|
|
@ -109,7 +135,7 @@
|
|||
</div>
|
||||
|
||||
<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="shortcut-section">
|
||||
|
|
@ -238,11 +264,13 @@
|
|||
</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 style="text-align: center;" v-else>
|
||||
<div style="text-align: center" v-else>
|
||||
<div class="icon">
|
||||
<i class="fa-solid fa-spinner fa-spin fa-4x" />
|
||||
</div>
|
||||
|
|
@ -250,311 +278,325 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { watch } from 'vue'
|
||||
import Hls from 'hls.js'
|
||||
import { disableOpacity, enableOpacity } from '~/utils'
|
||||
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { watch } from 'vue';
|
||||
import Hls from 'hls.js';
|
||||
import { disableOpacity, enableOpacity } from '~/utils';
|
||||
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
|
||||
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import type { file_info, video_source_element, video_track_element } from '~/types/video'
|
||||
import type { StoreItem } from '~/types/store';
|
||||
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<{
|
||||
(e: 'closeModel'): void,
|
||||
(e: 'error', message: string): void,
|
||||
}>()
|
||||
(e: 'closeModel'): void;
|
||||
(e: 'error', message: string): void;
|
||||
}>();
|
||||
|
||||
const video = useTemplateRef<HTMLVideoElement>('video')
|
||||
const tracks = ref<Array<video_track_element>>([])
|
||||
const sources = ref<Array<video_source_element>>([])
|
||||
const video = useTemplateRef<HTMLVideoElement>('video');
|
||||
const tracks = ref<Array<video_track_element>>([]);
|
||||
const sources = ref<Array<video_source_element>>([]);
|
||||
|
||||
const thumbnail = ref('/images/placeholder.png')
|
||||
const artist = ref('')
|
||||
const title = ref('')
|
||||
const isAudio = ref(false)
|
||||
const hasVideoStream = ref(false)
|
||||
const videoWidth = ref<number | undefined>(undefined)
|
||||
const videoHeight = ref<number | undefined>(undefined)
|
||||
const volume = useStorage('player_volume', 1)
|
||||
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
|
||||
const infoLoaded = ref(false)
|
||||
const destroyed = ref(false)
|
||||
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
|
||||
const havePoster = ref(false)
|
||||
const showHelp = ref(false)
|
||||
const usingHls = ref(false)
|
||||
const thumbnail = ref('/images/placeholder.png');
|
||||
const artist = ref('');
|
||||
const title = ref('');
|
||||
const isAudio = ref(false);
|
||||
const hasVideoStream = ref(false);
|
||||
const videoWidth = ref<number | undefined>(undefined);
|
||||
const videoHeight = ref<number | undefined>(undefined);
|
||||
const volume = useStorage('player_volume', 1);
|
||||
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox');
|
||||
const infoLoaded = ref(false);
|
||||
const destroyed = ref(false);
|
||||
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
|
||||
const havePoster = ref(false);
|
||||
const showHelp = ref(false);
|
||||
const usingHls = ref(false);
|
||||
|
||||
let unbindMediaSessionListeners: null | (() => void) = null
|
||||
let hls: Hls | null = null
|
||||
let cleanupKeyboardShortcuts: null | (() => void) = null
|
||||
let unbindMediaSessionListeners: null | (() => void) = null;
|
||||
let hls: Hls | null = null;
|
||||
let cleanupKeyboardShortcuts: null | (() => void) = null;
|
||||
|
||||
const handle_event = (e: KeyboardEvent) => {
|
||||
if ('Escape' !== e.key) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
emitter('closeModel')
|
||||
}
|
||||
emitter('closeModel');
|
||||
};
|
||||
|
||||
const bindMediaSessionListeners = (el: HTMLVideoElement) => {
|
||||
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
||||
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
||||
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
||||
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget)
|
||||
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget);
|
||||
const onPause = async (e: Event) => {
|
||||
const target = (e.currentTarget as HTMLVideoElement) ?? null
|
||||
const target = (e.currentTarget as HTMLVideoElement) ?? null;
|
||||
if (!target || destroyed.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const dataUrl = await captureFrame(target)
|
||||
const dataUrl = await captureFrame(target);
|
||||
if (dataUrl) {
|
||||
thumbnail.value = dataUrl
|
||||
havePoster.value = true
|
||||
applyMediaSessionMetadata()
|
||||
thumbnail.value = dataUrl;
|
||||
havePoster.value = true;
|
||||
applyMediaSessionMetadata();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener('loadedmetadata', onLoadedMetadata)
|
||||
el.addEventListener('timeupdate', onTimeUpdate)
|
||||
el.addEventListener('ratechange', onRateChange)
|
||||
el.addEventListener('seeked', onSeeked)
|
||||
el.addEventListener('pause', onPause)
|
||||
el.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
el.addEventListener('timeupdate', onTimeUpdate);
|
||||
el.addEventListener('ratechange', onRateChange);
|
||||
el.addEventListener('seeked', onSeeked);
|
||||
el.addEventListener('pause', onPause);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('loadedmetadata', onLoadedMetadata)
|
||||
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||
el.removeEventListener('ratechange', onRateChange)
|
||||
el.removeEventListener('seeked', onSeeked)
|
||||
el.removeEventListener('pause', onPause)
|
||||
}
|
||||
}
|
||||
el.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
el.removeEventListener('timeupdate', onTimeUpdate);
|
||||
el.removeEventListener('ratechange', onRateChange);
|
||||
el.removeEventListener('seeked', onSeeked);
|
||||
el.removeEventListener('pause', onPause);
|
||||
};
|
||||
};
|
||||
|
||||
const updateMediaSessionPosition = (target: EventTarget | null) => {
|
||||
if (false === ('mediaSession' in navigator)) {
|
||||
return
|
||||
if (false === 'mediaSession' in navigator) {
|
||||
return;
|
||||
}
|
||||
const el = (target as HTMLVideoElement) ?? null
|
||||
const el = (target as HTMLVideoElement) ?? null;
|
||||
if (!el || destroyed.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const d = el.duration
|
||||
const d = el.duration;
|
||||
if (false === Number.isFinite(d) || 0 >= d) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: d,
|
||||
playbackRate: el.playbackRate,
|
||||
position: el.currentTime,
|
||||
})
|
||||
} catch { }
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const volume_change_handler = () => {
|
||||
const el = video.value
|
||||
const el = video.value;
|
||||
if (!el) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
volume.value = el.volume
|
||||
updateMediaSessionPosition(el)
|
||||
}
|
||||
volume.value = el.volume;
|
||||
updateMediaSessionPosition(el);
|
||||
};
|
||||
|
||||
const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
|
||||
if (!el || destroyed.value) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
const w = el.videoWidth
|
||||
const h = el.videoHeight
|
||||
const w = el.videoWidth;
|
||||
const h = el.videoHeight;
|
||||
|
||||
try {
|
||||
if ('OffscreenCanvas' in window) {
|
||||
const c = new (window as any).OffscreenCanvas(w, h)
|
||||
const ctx = c.getContext('2d')
|
||||
if (!ctx) { return '' }
|
||||
ctx.drawImage(el, 0, 0, w, h)
|
||||
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 })
|
||||
return await new Promise<string>(r => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => r(String(fr.result))
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
const c = new (window as any).OffscreenCanvas(w, h);
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) {
|
||||
return '';
|
||||
}
|
||||
ctx.drawImage(el, 0, 0, w, h);
|
||||
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 });
|
||||
return await new Promise<string>((r) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => r(String(fr.result));
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
} else {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = w
|
||||
c.height = h
|
||||
const ctx = c.getContext('2d')
|
||||
if (!ctx) { return '' }
|
||||
ctx.drawImage(el, 0, 0, w, h)
|
||||
return c.toDataURL('image/jpeg', 0.86)
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) {
|
||||
return '';
|
||||
}
|
||||
ctx.drawImage(el, 0, 0, w, h);
|
||||
return c.toDataURL('image/jpeg', 0.86);
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
|
||||
if (!el || destroyed.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (havePoster.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (0 === el.videoWidth || 0 === el.videoHeight) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const dataUrl = await captureFrame(el)
|
||||
const dataUrl = await captureFrame(el);
|
||||
if (!dataUrl) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
thumbnail.value = dataUrl
|
||||
havePoster.value = true
|
||||
applyMediaSessionMetadata()
|
||||
}
|
||||
thumbnail.value = dataUrl;
|
||||
havePoster.value = true;
|
||||
applyMediaSessionMetadata();
|
||||
};
|
||||
|
||||
const restoreDefaultTextTrack = async () => {
|
||||
const el = video.value
|
||||
const el = video.value;
|
||||
if (!el) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tracksList = el.textTracks
|
||||
const tracksList = el.textTracks;
|
||||
if (!tracksList || 0 === tracksList.length) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if first track has cues - if not, we need to reload tracks
|
||||
const firstTrack = tracksList[0] as TextTrack | undefined
|
||||
const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0)
|
||||
const firstTrack = tracksList[0] as TextTrack | undefined;
|
||||
const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0);
|
||||
|
||||
if (needsReload) {
|
||||
const trackElements = el.querySelectorAll('track')
|
||||
const trackElements = el.querySelectorAll('track');
|
||||
|
||||
trackElements.forEach((trackEl, idx) => {
|
||||
const parent = trackEl.parentNode
|
||||
const clone = trackEl.cloneNode(true) as HTMLTrackElement
|
||||
trackEl.remove()
|
||||
const parent = trackEl.parentNode;
|
||||
const clone = trackEl.cloneNode(true) as HTMLTrackElement;
|
||||
trackEl.remove();
|
||||
setTimeout(() => {
|
||||
if (parent) {
|
||||
parent.appendChild(clone)
|
||||
parent.appendChild(clone);
|
||||
// Set mode after cues load
|
||||
clone.addEventListener('load', () => {
|
||||
const trackObj = clone.track
|
||||
if (trackObj) {
|
||||
trackObj.mode = idx === 0 ? 'showing' : 'disabled'
|
||||
}
|
||||
}, { once: true })
|
||||
clone.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
const trackObj = clone.track;
|
||||
if (trackObj) {
|
||||
trackObj.mode = idx === 0 ? 'showing' : 'disabled';
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}, idx * 10)
|
||||
})
|
||||
}, idx * 10);
|
||||
});
|
||||
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const track = tracksList[i] as TextTrack | undefined
|
||||
const track = tracksList[i] as TextTrack | undefined;
|
||||
if (!track) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const newMode = 0 === i ? 'showing' : 'disabled'
|
||||
track.mode = newMode
|
||||
const newMode = 0 === i ? 'showing' : 'disabled';
|
||||
track.mode = newMode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore subtitle track state', error)
|
||||
console.warn('Failed to restore subtitle track state', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
disableOpacity()
|
||||
const req = await request(makeDownload(config, props.item, 'api/file/info'))
|
||||
const response: file_info = await req.json()
|
||||
disableOpacity();
|
||||
const req = await request(makeDownload(config, props.item, 'api/file/info'));
|
||||
const response: file_info = await req.json();
|
||||
|
||||
if (!req.ok) {
|
||||
emitter('error', response?.error || 'Failed to fetch video info. Unknown error')
|
||||
emitter('closeModel')
|
||||
return
|
||||
emitter('error', response?.error || 'Failed to fetch video info. Unknown error');
|
||||
emitter('closeModel');
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
|
||||
if (props.item.extras?.thumbnail) {
|
||||
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
|
||||
havePoster.value = true
|
||||
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail);
|
||||
havePoster.value = true;
|
||||
} else {
|
||||
if (response.sidecar?.image?.[0]?.file) {
|
||||
thumbnail.value = makeDownload(config, { 'filename': response.sidecar.image[0].file })
|
||||
havePoster.value = true
|
||||
thumbnail.value = makeDownload(config, { filename: response.sidecar.image[0].file });
|
||||
havePoster.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
hasVideoStream.value = Array.isArray(response.ffprobe?.video)
|
||||
&& response.ffprobe.video.some(s => 'video' === s.codec_type)
|
||||
hasVideoStream.value =
|
||||
Array.isArray(response.ffprobe?.video) &&
|
||||
response.ffprobe.video.some((s) => 'video' === s.codec_type);
|
||||
|
||||
// Extract video dimensions to prevent layout reflow
|
||||
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) {
|
||||
videoWidth.value = videoStream.width
|
||||
videoHeight.value = videoStream.height
|
||||
videoWidth.value = videoStream.width;
|
||||
videoHeight.value = videoStream.height;
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
|
||||
isAudio.value = true
|
||||
isAudio.value = true;
|
||||
} else {
|
||||
if (false === hasVideoStream.value) {
|
||||
isAudio.value = true
|
||||
isAudio.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isApple) {
|
||||
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
|
||||
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8', allowedCodec ? false : true)
|
||||
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4');
|
||||
const src = makeDownload(
|
||||
config,
|
||||
props.item,
|
||||
allowedCodec ? 'api/download' : 'm3u8',
|
||||
allowedCodec ? false : true,
|
||||
);
|
||||
sources.value.push({
|
||||
src,
|
||||
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
||||
onerror: (err: Event) => src_error(err),
|
||||
})
|
||||
usingHls.value = !allowedCodec
|
||||
});
|
||||
usingHls.value = !allowedCodec;
|
||||
} else {
|
||||
const src = makeDownload(config, props.item, 'api/download', false)
|
||||
sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err), })
|
||||
usingHls.value = false
|
||||
const src = makeDownload(config, props.item, 'api/download', false);
|
||||
sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err) });
|
||||
usingHls.value = false;
|
||||
}
|
||||
|
||||
if (props.item.extras?.channel) {
|
||||
artist.value = props.item.extras.channel
|
||||
artist.value = props.item.extras.channel;
|
||||
}
|
||||
|
||||
if (!artist.value && props.item.extras?.uploader) {
|
||||
artist.value = props.item.extras.uploader
|
||||
artist.value = props.item.extras.uploader;
|
||||
}
|
||||
|
||||
if (props.item?.title) {
|
||||
title.value = props.item.title
|
||||
title.value = props.item.title;
|
||||
} else {
|
||||
if (response?.title) {
|
||||
title.value = response.title
|
||||
title.value = response.title;
|
||||
} else {
|
||||
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',
|
||||
label: cap.name,
|
||||
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) {
|
||||
document.documentElement.style.setProperty('--webkit-text-track-display', 'block')
|
||||
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
|
||||
}
|
||||
|
||||
infoLoaded.value = true
|
||||
await nextTick()
|
||||
infoLoaded.value = true;
|
||||
await nextTick();
|
||||
|
||||
prepareVideoPlayer()
|
||||
prepareVideoPlayer();
|
||||
|
||||
if (video.value) {
|
||||
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
|
||||
unbindMediaSessionListeners = bindMediaSessionListeners(video.value);
|
||||
}
|
||||
|
||||
const keyboardShortcutsResult = useKeyboardShortcuts({
|
||||
videoElement: video,
|
||||
enabled: ref(true),
|
||||
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 = () => {
|
||||
if (false === ('mediaSession' in navigator)) {
|
||||
return
|
||||
if (false === 'mediaSession' in navigator) {
|
||||
return;
|
||||
}
|
||||
const meta: MediaMetadataInit = { title: title.value }
|
||||
const meta: MediaMetadataInit = { title: title.value };
|
||||
if (artist.value) {
|
||||
meta.artist = artist.value
|
||||
meta.artist = artist.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 {
|
||||
navigator.mediaSession.metadata = new MediaMetadata(meta)
|
||||
} catch { }
|
||||
}
|
||||
navigator.mediaSession.metadata = new MediaMetadata(meta);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
onUpdated(() => prepareVideoPlayer())
|
||||
onUpdated(() => prepareVideoPlayer());
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
enableOpacity()
|
||||
enableOpacity();
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
hls = null
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
usingHls.value = false
|
||||
usingHls.value = false;
|
||||
|
||||
document.removeEventListener('keydown', handle_event)
|
||||
document.removeEventListener('keydown', handle_event);
|
||||
|
||||
if (cleanupKeyboardShortcuts) {
|
||||
cleanupKeyboardShortcuts()
|
||||
cleanupKeyboardShortcuts = null
|
||||
cleanupKeyboardShortcuts();
|
||||
cleanupKeyboardShortcuts = null;
|
||||
}
|
||||
|
||||
if (unbindMediaSessionListeners) {
|
||||
unbindMediaSessionListeners()
|
||||
unbindMediaSessionListeners = null
|
||||
unbindMediaSessionListeners();
|
||||
unbindMediaSessionListeners = null;
|
||||
}
|
||||
|
||||
if (title.value) {
|
||||
window.document.title = 'YTPTube'
|
||||
window.document.title = 'YTPTube';
|
||||
}
|
||||
|
||||
if (!video.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
destroyed.value = true
|
||||
destroyed.value = true;
|
||||
|
||||
try {
|
||||
video.value.pause()
|
||||
video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src'))
|
||||
video.value.removeEventListener('volumechange', volume_change_handler)
|
||||
video.value.load()
|
||||
video.value.pause();
|
||||
video.value.querySelectorAll('source').forEach((source) => source.removeAttribute('src'));
|
||||
video.value.removeEventListener('volumechange', volume_change_handler);
|
||||
video.value.load();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const prepareVideoPlayer = () => {
|
||||
if (!infoLoaded.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
applyMediaSessionMetadata()
|
||||
applyMediaSessionMetadata();
|
||||
|
||||
if (title.value) {
|
||||
window.document.title = `YTPTube - Playing: ${title.value}`
|
||||
window.document.title = `YTPTube - Playing: ${title.value}`;
|
||||
}
|
||||
|
||||
if (!video.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
video.value.volume = volume.value
|
||||
video.value.addEventListener('volumechange', volume_change_handler)
|
||||
restoreDefaultTextTrack()
|
||||
video.value.volume = volume.value;
|
||||
video.value.addEventListener('volumechange', volume_change_handler);
|
||||
restoreDefaultTextTrack();
|
||||
|
||||
if (hasVideoStream.value) {
|
||||
if ('requestVideoFrameCallback' in video.value) {
|
||||
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
|
||||
(video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
|
||||
} else {
|
||||
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) => {
|
||||
if (hls) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
if (destroyed.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.value && (notFirefox && video.value.paused)) {
|
||||
return
|
||||
if (video.value && notFirefox && video.value.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('Source failed to load, attempting HLS fallback via hls.js...', e)
|
||||
attach_hls(makeDownload(config, props.item, 'm3u8', true))
|
||||
}
|
||||
console.warn('Source failed to load, attempting HLS fallback via hls.js...', e);
|
||||
attach_hls(makeDownload(config, props.item, 'm3u8', true));
|
||||
};
|
||||
|
||||
const attach_hls = (link: string) => {
|
||||
if (!video.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
|
|
@ -712,45 +753,45 @@ const attach_hls = (link: string) => {
|
|||
lowLatencyMode: true,
|
||||
backBufferLength: 120,
|
||||
fragLoadingTimeOut: 200000,
|
||||
})
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata())
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata());
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
await restoreDefaultTextTrack()
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await restoreDefaultTextTrack();
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await restoreDefaultTextTrack()
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await restoreDefaultTextTrack();
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.LEVEL_LOADED, () => {
|
||||
if (video.value) {
|
||||
if ('requestVideoFrameCallback' in video.value) {
|
||||
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
|
||||
(video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
|
||||
} else {
|
||||
const once = () => captureFirstFramePoster(video.value!)
|
||||
; (video.value as any).addEventListener('loadeddata', once, { once: true })
|
||||
const once = () => captureFirstFramePoster(video.value!);
|
||||
(video.value as any).addEventListener('loadeddata', once, { once: true });
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
hls.loadSource(link)
|
||||
hls.attachMedia(video.value)
|
||||
usingHls.value = true
|
||||
}
|
||||
hls.loadSource(link);
|
||||
hls.attachMedia(video.value);
|
||||
usingHls.value = true;
|
||||
};
|
||||
|
||||
const forceSwitchToHls = () => {
|
||||
if (usingHls.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasVideoStream.value) {
|
||||
useNotification().error('Cannot switch to HLS: stream has no video track.')
|
||||
return
|
||||
useNotification().error('Cannot switch to HLS: stream has no video track.');
|
||||
return;
|
||||
}
|
||||
|
||||
attach_hls(makeDownload(config, props.item, 'm3u8', true))
|
||||
}
|
||||
attach_hls(makeDownload(config, props.item, 'm3u8', true));
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
<!-- ui/app/pages/options.vue -->
|
||||
<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="columns is-multiline is-vcentered">
|
||||
<div class="column is-12 is-6-desktop">
|
||||
<label class="label is-small">Search</label>
|
||||
<div class="control has-icons-left">
|
||||
<input v-model.trim="filters.query" type="text" class="input" placeholder="Filter by flag or description..."
|
||||
autocomplete="off" />
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,12 +62,27 @@
|
|||
<div class="column is-12 is-6-desktop">
|
||||
<label class="label is-small">Flags</label>
|
||||
<div class="buttons are-small">
|
||||
<button class="button" :class="{ 'is-link': filters.flagKind === 'any' }"
|
||||
@click="filters.flagKind = 'any'">Any</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>
|
||||
<button
|
||||
class="button"
|
||||
:class="{ 'is-link': filters.flagKind === 'any' }"
|
||||
@click="filters.flagKind = 'any'"
|
||||
>
|
||||
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>
|
||||
|
|
@ -78,7 +97,10 @@
|
|||
<h2 class="title is-6 mb-3">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-regular fa-folder-open" /></span>
|
||||
<span>{{ group.name }} <small class="has-text-grey">({{ group.items.length }})</small></span>
|
||||
<span
|
||||
>{{ group.name }}
|
||||
<small class="has-text-grey">({{ group.items.length }})</small></span
|
||||
>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="table-container">
|
||||
|
|
@ -93,8 +115,10 @@
|
|||
<template v-for="opt in group.items" :key="opt.flags.join('|')">
|
||||
<tr v-if="!opt.ignored">
|
||||
<td>
|
||||
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
||||
@click="copyFlag(opt.flags)" />
|
||||
<i
|
||||
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="tags">
|
||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
||||
|
|
@ -102,7 +126,9 @@
|
|||
</div>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -127,8 +153,10 @@
|
|||
<template v-for="opt in visible" :key="opt.flags.join('|')">
|
||||
<tr v-if="!opt.ignored">
|
||||
<td>
|
||||
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
|
||||
@click="copyFlag(opt.flags)" />
|
||||
<i
|
||||
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="tags">
|
||||
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
|
||||
|
|
@ -139,7 +167,9 @@
|
|||
{{ opt.group || 'root' }}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -152,127 +182,129 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { YTDLPOption } from '~/types/ytdlp'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { YTDLPOption } from '~/types/ytdlp';
|
||||
|
||||
const isLoading = ref(false)
|
||||
const options = ref<YTDLPOption[]>([])
|
||||
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped')
|
||||
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag')
|
||||
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc')
|
||||
const isLoading = ref(false);
|
||||
const options = ref<YTDLPOption[]>([]);
|
||||
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped');
|
||||
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag');
|
||||
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc');
|
||||
|
||||
const filters = reactive({
|
||||
query: '',
|
||||
group: '',
|
||||
flagKind: 'any' as 'any' | 'short' | 'long',
|
||||
})
|
||||
});
|
||||
|
||||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await request('/api/yt-dlp/options')
|
||||
isLoading.value = true;
|
||||
const resp = await request('/api/yt-dlp/options');
|
||||
if (!resp.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const data = await resp.json()
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) {
|
||||
options.value = data as YTDLPOption[]
|
||||
options.value = data as YTDLPOption[];
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyFlag = async (flags: string[]): Promise<void> => {
|
||||
const longFlag = flags.find(f => f.startsWith('--'))
|
||||
const longFlag = flags.find((f) => f.startsWith('--'));
|
||||
if (!longFlag) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
copyText(longFlag)
|
||||
}
|
||||
copyText(longFlag);
|
||||
};
|
||||
|
||||
onMounted(async () => await reload())
|
||||
onMounted(async () => await reload());
|
||||
|
||||
const groupNames = computed<string[]>(() => {
|
||||
const s = new Set<string>()
|
||||
const s = new Set<string>();
|
||||
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 q = filters.query.toLowerCase()
|
||||
const g = filters.group
|
||||
const q = filters.query.toLowerCase();
|
||||
const g = filters.group;
|
||||
|
||||
return options.value.filter((o) => {
|
||||
if (g && (o.group || 'root') !== g) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
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))) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
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)) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const sorted = computed<YTDLPOption[]>(() => {
|
||||
const dir = 'asc' === sortDir.value ? 1 : -1
|
||||
const arr = [...filtered.value]
|
||||
const dir = 'asc' === sortDir.value ? 1 : -1;
|
||||
const arr = [...filtered.value];
|
||||
|
||||
arr.sort((a, b) => {
|
||||
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) {
|
||||
return ga * dir
|
||||
return ga * dir;
|
||||
}
|
||||
}
|
||||
|
||||
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '')
|
||||
return fa * dir
|
||||
})
|
||||
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '');
|
||||
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 map = new Map<string, YTDLPOption[]>()
|
||||
const grouped = computed<{ name: string; items: YTDLPOption[] }[]>(() => {
|
||||
const map = new Map<string, YTDLPOption[]>();
|
||||
|
||||
for (const o of visible.value) {
|
||||
const key = o.group || 'root'
|
||||
const key = o.group || 'root';
|
||||
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 out = Array.from(map.entries()).map(([name, items]) => ({ name, items }))
|
||||
const dir = 'asc' === sortDir.value ? 1 : -1;
|
||||
const out = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||
|
||||
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 {
|
||||
// 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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
<div class="columns">
|
||||
<div class="column">
|
||||
<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-title">Goodbye!</div>
|
||||
<p class="goodbye-subtitle">YTPTube has shut down.</p>
|
||||
|
|
@ -72,7 +73,8 @@
|
|||
}
|
||||
|
||||
@keyframes floatTitle {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
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 { useConfigStore } from '~/stores/ConfigStore'
|
||||
import { request, parse_api_error, sTrim, encodePath } from '~/utils'
|
||||
import type { FileItem, Pagination } from '~/types/filebrowser'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { useConfigStore } from '~/stores/ConfigStore';
|
||||
import { request, parse_api_error, sTrim, encodePath } from '~/utils';
|
||||
import type { FileItem, Pagination } from '~/types/filebrowser';
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
const path = ref<string>('/')
|
||||
const items = ref<FileItem[]>([]);
|
||||
const path = ref<string>('/');
|
||||
const pagination = ref<Pagination>({
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
|
|
@ -14,219 +14,228 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
const isLoading = ref<boolean>(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const selectedElms = ref<string[]>([])
|
||||
const masterSelectAll = ref<boolean>(false)
|
||||
const sort_by = ref<string>('name')
|
||||
const sort_order = ref<string>('asc')
|
||||
const search = ref<string>('')
|
||||
const throwInstead = ref(false)
|
||||
const notify = useNotification()
|
||||
});
|
||||
const isLoading = ref<boolean>(false);
|
||||
const lastError = ref<string | null>(null);
|
||||
const selectedElms = ref<string[]>([]);
|
||||
const masterSelectAll = ref<boolean>(false);
|
||||
const sort_by = ref<string>('name');
|
||||
const sort_order = ref<string>('asc');
|
||||
const search = ref<string>('');
|
||||
const throwInstead = ref(false);
|
||||
const notify = useNotification();
|
||||
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
return await clone.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
const buildQueryParams = (page?: number): string => {
|
||||
const config = useConfigStore()
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(page ?? pagination.value.page))
|
||||
params.set('per_page', String(config.app.default_pagination || 50))
|
||||
params.set('sort_by', sort_by.value)
|
||||
params.set('sort_order', sort_order.value)
|
||||
const config = useConfigStore();
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page ?? pagination.value.page));
|
||||
params.set('per_page', String(config.app.default_pagination || 50));
|
||||
params.set('sort_by', sort_by.value);
|
||||
params.set('sort_order', sort_order.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> => {
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
try {
|
||||
if (typeof dir !== 'string') {
|
||||
dir = '/'
|
||||
dir = '/';
|
||||
}
|
||||
|
||||
dir = encodePath(sTrim(dir, '/'))
|
||||
const query = buildQueryParams(page)
|
||||
const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`)
|
||||
dir = encodePath(sTrim(dir, '/'));
|
||||
const query = buildQueryParams(page);
|
||||
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 || []
|
||||
path.value = data.path || '/'
|
||||
items.value = data.contents || [];
|
||||
path.value = data.path || '/';
|
||||
if (data.pagination) {
|
||||
pagination.value = data.pagination
|
||||
pagination.value = data.pagination;
|
||||
}
|
||||
|
||||
selectedElms.value = []
|
||||
masterSelectAll.value = false
|
||||
lastError.value = null
|
||||
selectedElms.value = [];
|
||||
masterSelectAll.value = false;
|
||||
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> => {
|
||||
if (!['name', 'size', 'date', 'type'].includes(by)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (by !== sort_by.value) {
|
||||
sort_by.value = by
|
||||
}
|
||||
else {
|
||||
sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc'
|
||||
sort_by.value = by;
|
||||
} else {
|
||||
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> => {
|
||||
search.value = value
|
||||
await loadContents(path.value, 1)
|
||||
}
|
||||
search.value = value;
|
||||
await loadContents(path.value, 1);
|
||||
};
|
||||
|
||||
const setSortBy = (value: string): void => {
|
||||
sort_by.value = value
|
||||
}
|
||||
sort_by.value = value;
|
||||
};
|
||||
|
||||
const setSortOrder = (value: string): void => {
|
||||
sort_order.value = value
|
||||
}
|
||||
sort_order.value = value;
|
||||
};
|
||||
|
||||
const setSearchValue = (value: string): void => {
|
||||
search.value = value
|
||||
}
|
||||
search.value = value;
|
||||
};
|
||||
|
||||
const setPage = (value: number): void => {
|
||||
pagination.value.page = value
|
||||
}
|
||||
pagination.value.page = value;
|
||||
};
|
||||
|
||||
const changePage = async (page: number): Promise<void> => {
|
||||
await loadContents(path.value, page)
|
||||
}
|
||||
await loadContents(path.value, page);
|
||||
};
|
||||
|
||||
const performAction = async (
|
||||
item: FileItem,
|
||||
action: string,
|
||||
payload: Record<string, unknown>,
|
||||
callback?: (item: FileItem, action: string, data: Record<string, unknown>, source: FileItem) => void,
|
||||
multiple: boolean = false
|
||||
callback?: (
|
||||
item: FileItem,
|
||||
action: string,
|
||||
data: Record<string, unknown>,
|
||||
source: FileItem,
|
||||
) => void,
|
||||
multiple: boolean = false,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request('/api/file/actions', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
if (!multiple && result.path !== item.path) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!multiple && !result.status) {
|
||||
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`)
|
||||
return false
|
||||
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (callback && typeof callback === 'function') {
|
||||
if (!multiple) {
|
||||
callback(item, action, payload as Record<string, unknown>, item)
|
||||
}
|
||||
else {
|
||||
const matchedItem = items.value.find(it => it.path === result.path)
|
||||
callback(item, action, payload as Record<string, unknown>, item);
|
||||
} else {
|
||||
const matchedItem = items.value.find((it) => it.path === result.path);
|
||||
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 (
|
||||
actions: Array<{ path: string, action: string, [key: string]: unknown }>,
|
||||
callback?: (result: { path: string, status: boolean, error?: string }) => void
|
||||
actions: Array<{ path: string; action: string; [key: string]: unknown }>,
|
||||
callback?: (result: { path: string; status: boolean; error?: string }) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request('/api/file/actions', {
|
||||
method: 'POST',
|
||||
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) {
|
||||
if (!result.status) {
|
||||
notify.error(`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`)
|
||||
continue
|
||||
notify.error(
|
||||
`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
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 trimmedDir = sTrim(newDir, '/')
|
||||
const trimmedDir = sTrim(newDir, '/');
|
||||
if (!trimmedDir || trimmedDir === dir) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await performAction(
|
||||
|
|
@ -234,17 +243,17 @@ const createDirectory = async (dir: string, newDir: string): Promise<boolean> =>
|
|||
'directory',
|
||||
{ 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 trimmedName = newName.trim()
|
||||
const trimmedName = newName.trim();
|
||||
if (!trimmedName || trimmedName === item.name) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
return await performAction(
|
||||
|
|
@ -252,33 +261,28 @@ const renameItem = async (item: FileItem, newName: string): Promise<boolean> =>
|
|||
'rename',
|
||||
{ new_name: trimmedName },
|
||||
(it, _, data) => {
|
||||
const source = data as { new_path?: string }
|
||||
const source = data as { new_path?: string };
|
||||
if (source.new_path) {
|
||||
it.name = source.new_path.split('/').pop() || trimmedName
|
||||
it.path = source.new_path
|
||||
it.name = source.new_path.split('/').pop() || trimmedName;
|
||||
it.path = source.new_path;
|
||||
}
|
||||
notify.success(`Renamed '${item.name}'.`)
|
||||
notify.success(`Renamed '${item.name}'.`);
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const deleteItem = async (item: FileItem): Promise<boolean> => {
|
||||
return await performAction(
|
||||
item,
|
||||
'delete',
|
||||
{},
|
||||
() => {
|
||||
items.value = items.value.filter(i => i.path !== item.path)
|
||||
notify.warning(`Deleted '${item.name}'.`)
|
||||
}
|
||||
)
|
||||
}
|
||||
return await performAction(item, 'delete', {}, () => {
|
||||
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 trimmedPath = sTrim(newPath, '/') || '/'
|
||||
const trimmedPath = sTrim(newPath, '/') || '/';
|
||||
if (!trimmedPath || trimmedPath === item.path) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
return await performAction(
|
||||
|
|
@ -286,73 +290,73 @@ const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
|
|||
'move',
|
||||
{ new_path: trimmedPath },
|
||||
(it, _, data) => {
|
||||
const source = data as { new_path?: string; path?: string }
|
||||
const source = data as { new_path?: string; path?: string };
|
||||
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> => {
|
||||
if (selectedElms.value.length < 1) {
|
||||
notify.error('No items selected.')
|
||||
return false
|
||||
notify.error('No items selected.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const actions = selectedElms.value.map(p => {
|
||||
return { path: p, action: 'delete' }
|
||||
})
|
||||
const actions = selectedElms.value.map((p) => {
|
||||
return { path: p, action: 'delete' };
|
||||
});
|
||||
|
||||
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) {
|
||||
items.value = items.value.filter(it => it.path !== result.path)
|
||||
notify.warning(`Deleted '${item.name}'.`)
|
||||
items.value = items.value.filter((it) => it.path !== result.path);
|
||||
notify.warning(`Deleted '${item.name}'.`);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (success) {
|
||||
selectedElms.value = []
|
||||
selectedElms.value = [];
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
const moveSelected = async (newPath: string): Promise<boolean> => {
|
||||
if (selectedElms.value.length < 1) {
|
||||
notify.error('No items selected.')
|
||||
return false
|
||||
notify.error('No items selected.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmedPath = sTrim(newPath, '/') || '/'
|
||||
const actions = selectedElms.value.map(p => ({
|
||||
const trimmedPath = sTrim(newPath, '/') || '/';
|
||||
const actions = selectedElms.value.map((p) => ({
|
||||
path: p,
|
||||
action: 'move',
|
||||
new_path: trimmedPath,
|
||||
}))
|
||||
}));
|
||||
|
||||
const success = await performMassAction(actions, (result) => {
|
||||
items.value = items.value.filter(it => it.path !== result.path)
|
||||
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`)
|
||||
})
|
||||
items.value = items.value.filter((it) => it.path !== result.path);
|
||||
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`);
|
||||
});
|
||||
|
||||
if (success) {
|
||||
selectedElms.value = []
|
||||
selectedElms.value = [];
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
const clearError = (): void => {
|
||||
lastError.value = null
|
||||
}
|
||||
lastError.value = null;
|
||||
};
|
||||
|
||||
const reset = (): void => {
|
||||
items.value = []
|
||||
path.value = '/'
|
||||
items.value = [];
|
||||
path.value = '/';
|
||||
pagination.value = {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
|
|
@ -360,16 +364,16 @@ const reset = (): void => {
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
}
|
||||
selectedElms.value = []
|
||||
masterSelectAll.value = false
|
||||
search.value = ''
|
||||
lastError.value = null
|
||||
}
|
||||
};
|
||||
selectedElms.value = [];
|
||||
masterSelectAll.value = false;
|
||||
search.value = '';
|
||||
lastError.value = null;
|
||||
};
|
||||
|
||||
const filteredItems = computed<FileItem[]>(() => {
|
||||
return items.value
|
||||
})
|
||||
return items.value;
|
||||
});
|
||||
|
||||
export const useBrowser = () => ({
|
||||
items: readonly(items),
|
||||
|
|
@ -403,4 +407,4 @@ export const useBrowser = () => ({
|
|||
clearError,
|
||||
reset,
|
||||
throwInstead,
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { ref, readonly } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type {
|
||||
Condition,
|
||||
ConditionTestRequest,
|
||||
ConditionTestResponse,
|
||||
Pagination,
|
||||
} from '~/types/conditions'
|
||||
import type { APIResponse } from '~/types/responses'
|
||||
} from '~/types/conditions';
|
||||
import type { APIResponse } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* List of all conditions in memory.
|
||||
*/
|
||||
const conditions = ref<Array<Condition>>([])
|
||||
const conditions = ref<Array<Condition>>([]);
|
||||
/**
|
||||
* Pagination state for conditions list.
|
||||
*/
|
||||
|
|
@ -24,32 +24,32 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* 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.
|
||||
* Used separately from isLoading for finer control.
|
||||
*/
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const addInProgress = ref<boolean>(false);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
|
||||
/**
|
||||
* 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).
|
||||
|
|
@ -59,12 +59,12 @@ const notify = useNotification()
|
|||
const sortConditions = (items: Array<Condition>): Array<Condition> => {
|
||||
return [...items].sort((a, b) => {
|
||||
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.
|
||||
|
|
@ -73,13 +73,12 @@ const sortConditions = (items: Array<Condition>): Array<Condition> => {
|
|||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
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.
|
||||
|
|
@ -88,23 +87,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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.filter(item => item.id !== condition.id),
|
||||
...conditions.value.filter((item) => item.id !== condition.id),
|
||||
condition,
|
||||
])
|
||||
]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a condition from the conditions list by ID.
|
||||
|
|
@ -128,12 +127,12 @@ const updateConditions = (condition: Condition): void => {
|
|||
* @param id Condition ID
|
||||
*/
|
||||
const removeCondition = (id: number) => {
|
||||
const initialLength = conditions.value.length
|
||||
conditions.value = conditions.value.filter(item => item.id !== id)
|
||||
const initialLength = conditions.value.length;
|
||||
conditions.value = conditions.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
|
|
@ -141,31 +140,32 @@ const removeCondition = (id: number) => {
|
|||
* @param page Page number
|
||||
* @param perPage Items per page
|
||||
*/
|
||||
const loadConditions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
isLoading.value = true
|
||||
const loadConditions = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/conditions/?page=${page}`
|
||||
let url = `/api/conditions/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<Condition>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } = await parse_list_response<Condition>(json);
|
||||
|
||||
conditions.value = sortConditions(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
conditions.value = sortConditions(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
|
|
@ -174,21 +174,20 @@ const loadConditions = async (page: number = 1, perPage: number | undefined = un
|
|||
*/
|
||||
const getCondition = async (id: number): Promise<Condition | null> => {
|
||||
try {
|
||||
const response = await request(`/api/conditions/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/conditions/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const condition = await parse_api_response<Condition>(json)
|
||||
const json = await response.json();
|
||||
const condition = await parse_api_response<Condition>(json);
|
||||
|
||||
lastError.value = null
|
||||
return condition
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -200,43 +199,41 @@ const createCondition = async (
|
|||
condition: Omit<Condition, 'id'>,
|
||||
callback?: (response: APIResponse<Condition>) => void,
|
||||
): Promise<Condition | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request('/api/conditions/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(condition),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const created = await parse_api_response<Condition>(json)
|
||||
const json = await response.json();
|
||||
const created = await parse_api_response<Condition>(json);
|
||||
|
||||
updateConditions(created)
|
||||
notify.success('Condition created.')
|
||||
lastError.value = null
|
||||
updateConditions(created);
|
||||
notify.success('Condition created.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: created })
|
||||
callback({ success: true, error: null, detail: null, data: created });
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing condition via API (PUT - full update).
|
||||
|
|
@ -250,46 +247,44 @@ const updateCondition = async (
|
|||
condition: Condition,
|
||||
callback?: (response: APIResponse<Condition>) => void,
|
||||
): Promise<Condition | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (condition.id) {
|
||||
condition.id = undefined
|
||||
condition.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/conditions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(condition),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Condition>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Condition>(json);
|
||||
|
||||
updateConditions(updated)
|
||||
notify.success(`Condition '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateConditions(updated);
|
||||
notify.success(`Condition '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially updates an existing condition via API (PATCH).
|
||||
|
|
@ -303,46 +298,44 @@ const patchCondition = async (
|
|||
patch: Partial<Condition>,
|
||||
callback?: (response: APIResponse<Condition>) => void,
|
||||
): Promise<Condition | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (patch.id) {
|
||||
patch.id = undefined
|
||||
patch.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/conditions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Condition>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Condition>(json);
|
||||
|
||||
updateConditions(updated)
|
||||
notify.success(`Condition '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateConditions(updated);
|
||||
notify.success(`Condition '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a condition by ID via API.
|
||||
|
|
@ -355,63 +348,63 @@ const deleteCondition = async (
|
|||
callback?: (response: APIResponse<boolean>) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removeCondition(id)
|
||||
notify.success('Condition deleted.')
|
||||
lastError.value = null
|
||||
removeCondition(id);
|
||||
notify.success('Condition deleted.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: true })
|
||||
callback({ success: true, error: null, detail: null, data: true });
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return false
|
||||
if (throwInstead.value) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests a condition against a URL.
|
||||
* @param testRequest Test request parameters
|
||||
* @returns Test result or null on error
|
||||
*/
|
||||
const testCondition = async (testRequest: ConditionTestRequest): Promise<ConditionTestResponse | null> => {
|
||||
const testCondition = async (
|
||||
testRequest: ConditionTestRequest,
|
||||
): Promise<ConditionTestResponse | null> => {
|
||||
try {
|
||||
const response = await request('/api/conditions/test/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(testRequest),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const result = await parse_api_response<ConditionTestResponse>(json)
|
||||
const json = await response.json();
|
||||
const result = await parse_api_response<ConditionTestResponse>(json);
|
||||
|
||||
lastError.value = null
|
||||
return result
|
||||
lastError.value = null;
|
||||
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.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* useConditions composable
|
||||
|
|
@ -434,4 +427,4 @@ export const useConditions = () => ({
|
|||
testCondition,
|
||||
clearError,
|
||||
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 { status } = await dialog.confirmDialog(Object.assign({
|
||||
title: 'Please Confirm',
|
||||
message: msg,
|
||||
cancelText: 'Cancel',
|
||||
confirmText: 'OK',
|
||||
} as ConfirmOptions, opts || {}))
|
||||
const { status } = await dialog.confirmDialog(
|
||||
Object.assign(
|
||||
{
|
||||
title: 'Please Confirm',
|
||||
message: msg,
|
||||
cancelText: 'Cancel',
|
||||
confirmText: 'OK',
|
||||
} as ConfirmOptions,
|
||||
opts || {},
|
||||
),
|
||||
);
|
||||
|
||||
return status
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const alert = async (msg: string, opts: AlertOptions = {}) => {
|
||||
const { status } = await dialog.alertDialog(Object.assign({
|
||||
title: 'Alert',
|
||||
message: msg,
|
||||
confirmText: 'OK',
|
||||
} as AlertOptions, opts || {}))
|
||||
return status
|
||||
}
|
||||
const { status } = await dialog.alertDialog(
|
||||
Object.assign(
|
||||
{
|
||||
title: 'Alert',
|
||||
message: msg,
|
||||
confirmText: 'OK',
|
||||
} as AlertOptions,
|
||||
opts || {},
|
||||
),
|
||||
);
|
||||
return status;
|
||||
};
|
||||
|
||||
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) {
|
||||
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 = {
|
||||
/**
|
||||
* Title of the dialog
|
||||
*/
|
||||
title?: string
|
||||
title?: string;
|
||||
/**
|
||||
* Message to display in the dialog
|
||||
*/
|
||||
message?: string
|
||||
message?: string;
|
||||
/**
|
||||
* Text for the confirm button
|
||||
*/
|
||||
confirmText?: string
|
||||
confirmText?: string;
|
||||
/**
|
||||
* Color class for the confirm button (e.g., 'is-primary', 'is-danger')
|
||||
*/
|
||||
confirmColor?: string
|
||||
}
|
||||
confirmColor?: string;
|
||||
};
|
||||
|
||||
export type PromptOptions = BaseOptions & {
|
||||
/**
|
||||
* Text for the input field
|
||||
*/
|
||||
initial?: string
|
||||
initial?: string;
|
||||
/**
|
||||
* Placeholder text for the input field
|
||||
*/
|
||||
placeholder?: string
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Text for the cancel button
|
||||
*/
|
||||
cancelText?: string
|
||||
cancelText?: string;
|
||||
/**
|
||||
* Function to validate the input value
|
||||
* @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 & {
|
||||
/**
|
||||
* Text for the confirm button
|
||||
*/
|
||||
cancelText?: string
|
||||
cancelText?: string;
|
||||
/**
|
||||
* Raw HTML content to include in the dialog message.
|
||||
*/
|
||||
rawHTML?: string
|
||||
}
|
||||
rawHTML?: string;
|
||||
};
|
||||
|
||||
export type AlertOptions = BaseOptions & {}
|
||||
export type AlertOptions = BaseOptions & {};
|
||||
|
||||
type QueueItem = {
|
||||
type: 'prompt' | 'confirm' | 'alert'
|
||||
opts: PromptOptions | ConfirmOptions | AlertOptions
|
||||
resolve: (r: DialogResult<any>) => void
|
||||
}
|
||||
type: 'prompt' | 'confirm' | 'alert';
|
||||
opts: PromptOptions | ConfirmOptions | AlertOptions;
|
||||
resolve: (r: DialogResult<any>) => void;
|
||||
};
|
||||
|
||||
type DialogState = {
|
||||
current: QueueItem | null
|
||||
queue: QueueItem[]
|
||||
errorMsg: string | null
|
||||
input: string
|
||||
}
|
||||
current: QueueItem | null;
|
||||
queue: QueueItem[];
|
||||
errorMsg: string | null;
|
||||
input: string;
|
||||
};
|
||||
|
||||
export const useDialog = () => {
|
||||
const raw = useState<DialogState>('dialog:state', () => reactive({
|
||||
current: null,
|
||||
queue: [],
|
||||
errorMsg: null,
|
||||
input: '',
|
||||
} as DialogState))
|
||||
const raw = useState<DialogState>('dialog:state', () =>
|
||||
reactive({
|
||||
current: null,
|
||||
queue: [],
|
||||
errorMsg: null,
|
||||
input: '',
|
||||
} as DialogState),
|
||||
);
|
||||
|
||||
const state = raw.value
|
||||
const state = raw.value;
|
||||
|
||||
const _dequeue = () => {
|
||||
if (!state.current && state.queue.length) {
|
||||
state.current = state.queue.shift()!
|
||||
state.errorMsg = null
|
||||
state.input = state.current.type === 'prompt' ? (state.current.opts as PromptOptions).initial ?? '' : ''
|
||||
state.current = state.queue.shift()!;
|
||||
state.errorMsg = null;
|
||||
state.input =
|
||||
state.current.type === 'prompt'
|
||||
? ((state.current.opts as PromptOptions).initial ?? '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const promptDialog = (opts: PromptOptions) => new Promise<DialogResult<string>>((resolve) => {
|
||||
state.queue.push({ type: 'prompt', opts, resolve })
|
||||
_dequeue()
|
||||
})
|
||||
const promptDialog = (opts: PromptOptions) =>
|
||||
new Promise<DialogResult<string>>((resolve) => {
|
||||
state.queue.push({ type: 'prompt', opts, resolve });
|
||||
_dequeue();
|
||||
});
|
||||
|
||||
const confirmDialog = (opts: ConfirmOptions) => new Promise<DialogResult<null>>((resolve) => {
|
||||
state.queue.push({ type: 'confirm', opts, resolve })
|
||||
_dequeue()
|
||||
})
|
||||
const confirmDialog = (opts: ConfirmOptions) =>
|
||||
new Promise<DialogResult<null>>((resolve) => {
|
||||
state.queue.push({ type: 'confirm', opts, resolve });
|
||||
_dequeue();
|
||||
});
|
||||
|
||||
const alertDialog = (opts: AlertOptions) => new Promise<DialogResult<null>>((resolve) => {
|
||||
state.queue.push({ type: 'alert', opts, resolve })
|
||||
_dequeue()
|
||||
})
|
||||
const alertDialog = (opts: AlertOptions) =>
|
||||
new Promise<DialogResult<null>>((resolve) => {
|
||||
state.queue.push({ type: 'alert', opts, resolve });
|
||||
_dequeue();
|
||||
});
|
||||
|
||||
const confirm = (value?: string) => {
|
||||
if (!state.current) return
|
||||
if (!state.current) return;
|
||||
if (state.current.type === 'prompt') {
|
||||
const val = value ?? state.input
|
||||
const v = (state.current.opts as PromptOptions).validate?.(val)
|
||||
const val = value ?? state.input;
|
||||
const v = (state.current.opts as PromptOptions).validate?.(val);
|
||||
if (v && v !== true) {
|
||||
state.errorMsg = v
|
||||
return
|
||||
state.errorMsg = v;
|
||||
return;
|
||||
}
|
||||
state.current.resolve({ status: true, value: val })
|
||||
state.current.resolve({ status: true, value: val });
|
||||
} else {
|
||||
state.current.resolve({ status: true, value: null })
|
||||
state.current.resolve({ status: true, value: null });
|
||||
}
|
||||
state.current = null
|
||||
_dequeue()
|
||||
}
|
||||
state.current = null;
|
||||
_dequeue();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (!state.current) return
|
||||
state.current.resolve({ status: false, value: null })
|
||||
state.current = null
|
||||
_dequeue()
|
||||
}
|
||||
if (!state.current) return;
|
||||
state.current.resolve({ status: false, value: null });
|
||||
state.current = null;
|
||||
_dequeue();
|
||||
};
|
||||
|
||||
return {
|
||||
promptDialog,
|
||||
|
|
@ -131,5 +139,5 @@ export const useDialog = () => {
|
|||
confirm,
|
||||
cancel,
|
||||
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 { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import type { DLField, DLFieldRequest } from '~/types/dl_fields'
|
||||
import type { APIResponse, Pagination } from '~/types/responses'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type { DLField, DLFieldRequest } from '~/types/dl_fields';
|
||||
import type { APIResponse, Pagination } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* List of all dl fields in memory.
|
||||
*/
|
||||
const dlFields = ref<Array<DLField>>([])
|
||||
const dlFields = ref<Array<DLField>>([]);
|
||||
/**
|
||||
* Pagination state for dl fields list.
|
||||
*/
|
||||
|
|
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const addInProgress = ref<boolean>(false);
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
const notify = useNotification();
|
||||
|
||||
/**
|
||||
* Sorts dl fields by order (ascending), then name (A-Z).
|
||||
|
|
@ -49,12 +49,12 @@ const notify = useNotification()
|
|||
const sortDlFields = (items: Array<DLField>): Array<DLField> => {
|
||||
return [...items].sort((a, b) => {
|
||||
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.
|
||||
|
|
@ -63,13 +63,12 @@ const sortDlFields = (items: Array<DLField>): Array<DLField> => {
|
|||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
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.
|
||||
|
|
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const updateDlFields = (field: DLField): void => {
|
||||
const isNew = !dlFields.value.some(item => item.id === field.id)
|
||||
dlFields.value = sortDlFields([
|
||||
...dlFields.value.filter(item => item.id !== field.id),
|
||||
field,
|
||||
])
|
||||
const isNew = !dlFields.value.some((item) => item.id === field.id);
|
||||
dlFields.value = sortDlFields([...dlFields.value.filter((item) => item.id !== field.id), field]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a dl field from the dlFields list by ID.
|
||||
|
|
@ -118,12 +114,12 @@ const updateDlFields = (field: DLField): void => {
|
|||
* @param id DLField ID
|
||||
*/
|
||||
const removeDlField = (id: number) => {
|
||||
const initialLength = dlFields.value.length
|
||||
dlFields.value = dlFields.value.filter(item => item.id !== id)
|
||||
const initialLength = dlFields.value.length;
|
||||
dlFields.value = dlFields.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
|
|
@ -131,31 +127,32 @@ const removeDlField = (id: number) => {
|
|||
* @param page Page number
|
||||
* @param perPage Items per page
|
||||
*/
|
||||
const loadDlFields = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
isLoading.value = true
|
||||
const loadDlFields = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/dl_fields/?page=${page}`
|
||||
let url = `/api/dl_fields/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<DLField>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } = await parse_list_response<DLField>(json);
|
||||
|
||||
dlFields.value = sortDlFields(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
dlFields.value = sortDlFields(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
|
|
@ -164,21 +161,20 @@ const loadDlFields = async (page: number = 1, perPage: number | undefined = unde
|
|||
*/
|
||||
const getDlField = async (id: number): Promise<DLField | null> => {
|
||||
try {
|
||||
const response = await request(`/api/dl_fields/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/dl_fields/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const field = await parse_api_response<DLField>(json)
|
||||
const json = await response.json();
|
||||
const field = await parse_api_response<DLField>(json);
|
||||
|
||||
lastError.value = null
|
||||
return field
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -190,43 +186,41 @@ const createDlField = async (
|
|||
field: DLFieldRequest,
|
||||
callback?: (response: APIResponse<DLField>) => void,
|
||||
): Promise<DLField | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request('/api/dl_fields/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(field),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const created = await parse_api_response<DLField>(json)
|
||||
const json = await response.json();
|
||||
const created = await parse_api_response<DLField>(json);
|
||||
|
||||
updateDlFields(created)
|
||||
notify.success('DL field created.')
|
||||
lastError.value = null
|
||||
updateDlFields(created);
|
||||
notify.success('DL field created.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: created })
|
||||
callback({ success: true, error: null, detail: null, data: created });
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing dl field via API (PUT - full update).
|
||||
|
|
@ -240,46 +234,44 @@ const updateDlField = async (
|
|||
field: DLField,
|
||||
callback?: (response: APIResponse<DLField>) => void,
|
||||
): Promise<DLField | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (field.id) {
|
||||
field.id = undefined
|
||||
field.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/dl_fields/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(field),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<DLField>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<DLField>(json);
|
||||
|
||||
updateDlFields(updated)
|
||||
notify.success(`DL field '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateDlFields(updated);
|
||||
notify.success(`DL field '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially updates an existing dl field via API (PATCH).
|
||||
|
|
@ -293,46 +285,44 @@ const patchDlField = async (
|
|||
patch: Partial<DLField>,
|
||||
callback?: (response: APIResponse<DLField>) => void,
|
||||
): Promise<DLField | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (patch.id) {
|
||||
patch.id = undefined
|
||||
patch.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/dl_fields/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<DLField>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<DLField>(json);
|
||||
|
||||
updateDlFields(updated)
|
||||
notify.success(`DL field '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateDlFields(updated);
|
||||
notify.success(`DL field '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a dl field by ID via API.
|
||||
|
|
@ -345,36 +335,35 @@ const deleteDlField = async (
|
|||
callback?: (response: APIResponse<boolean>) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removeDlField(id)
|
||||
notify.success('DL field deleted.')
|
||||
lastError.value = null
|
||||
removeDlField(id);
|
||||
notify.success('DL field deleted.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: true })
|
||||
callback({ success: true, error: null, detail: null, data: true });
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return false
|
||||
if (throwInstead.value) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the last error message.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* useDlFields composable
|
||||
|
|
@ -396,4 +385,4 @@ export const useDlFields = () => ({
|
|||
deleteDlField,
|
||||
clearError,
|
||||
throwInstead,
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* Vue composable for handling YouTube-style keyboard shortcuts in video player
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import {
|
||||
handlePlayPause,
|
||||
handleRewind,
|
||||
|
|
@ -20,136 +20,135 @@ import {
|
|||
handleToggleCaptions,
|
||||
shouldHandleKeyboardShortcut,
|
||||
isModifierKey,
|
||||
} from '~/utils/keyboard'
|
||||
import type { KeyboardShortcutContext } from '~/types/video'
|
||||
|
||||
} from '~/utils/keyboard';
|
||||
import type { KeyboardShortcutContext } from '~/types/video';
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
videoElement: Ref<HTMLVideoElement | null | undefined>
|
||||
enabled?: Ref<boolean>
|
||||
closePlayer?: () => void
|
||||
onHelpToggle?: () => void
|
||||
videoElement: Ref<HTMLVideoElement | null | undefined>;
|
||||
enabled?: Ref<boolean>;
|
||||
closePlayer?: () => void;
|
||||
onHelpToggle?: () => void;
|
||||
}
|
||||
|
||||
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
||||
const { videoElement, enabled, closePlayer, onHelpToggle } = options
|
||||
const showHelp = ref(false)
|
||||
const { videoElement, enabled, closePlayer, onHelpToggle } = options;
|
||||
const showHelp = ref(false);
|
||||
|
||||
const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
// Don't handle if composable is disabled
|
||||
if (enabled && !enabled.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't handle if user is typing in an input
|
||||
if (!shouldHandleKeyboardShortcut(event)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const video = videoElement.value
|
||||
const video = videoElement.value;
|
||||
if (!video) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if modifier keys are pressed (except for shortcuts that need them)
|
||||
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
const ctx: KeyboardShortcutContext = { video }
|
||||
const key = event.key.toLowerCase();
|
||||
const ctx: KeyboardShortcutContext = { video };
|
||||
|
||||
try {
|
||||
switch (key) {
|
||||
// Play/Pause
|
||||
case ' ':
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
handlePlayPause(ctx)
|
||||
break
|
||||
event.preventDefault();
|
||||
handlePlayPause(ctx);
|
||||
break;
|
||||
|
||||
// Rewind 10 seconds (J key)
|
||||
case 'j':
|
||||
event.preventDefault()
|
||||
handleRewind(ctx, 10)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleRewind(ctx, 10);
|
||||
break;
|
||||
|
||||
// Forward 10 seconds (L key)
|
||||
case 'l':
|
||||
event.preventDefault()
|
||||
handleForward(ctx, 10)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleForward(ctx, 10);
|
||||
break;
|
||||
|
||||
// Seek backward 5 seconds (left arrow)
|
||||
case 'arrowleft':
|
||||
event.preventDefault()
|
||||
handleSeekBackward(ctx, 5)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleSeekBackward(ctx, 5);
|
||||
break;
|
||||
|
||||
// Seek forward 5 seconds (right arrow)
|
||||
case 'arrowright':
|
||||
event.preventDefault()
|
||||
handleSeekForward(ctx, 5)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleSeekForward(ctx, 5);
|
||||
break;
|
||||
|
||||
// Increase volume (up arrow)
|
||||
case 'arrowup':
|
||||
event.preventDefault()
|
||||
handleVolumeChange(ctx, 0.1)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleVolumeChange(ctx, 0.1);
|
||||
break;
|
||||
|
||||
// Decrease volume (down arrow)
|
||||
case 'arrowdown':
|
||||
event.preventDefault()
|
||||
handleVolumeChange(ctx, -0.1)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleVolumeChange(ctx, -0.1);
|
||||
break;
|
||||
|
||||
// Mute/Unmute
|
||||
case 'm':
|
||||
event.preventDefault()
|
||||
handleMute(ctx)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleMute(ctx);
|
||||
break;
|
||||
|
||||
// Toggle fullscreen
|
||||
case 'f':
|
||||
event.preventDefault()
|
||||
handleFullscreen(video)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleFullscreen(video);
|
||||
break;
|
||||
|
||||
// Picture-in-Picture
|
||||
case 'p':
|
||||
event.preventDefault()
|
||||
await handlePictureInPicture(video)
|
||||
break
|
||||
event.preventDefault();
|
||||
await handlePictureInPicture(video);
|
||||
break;
|
||||
|
||||
// Toggle captions
|
||||
case 'c':
|
||||
event.preventDefault()
|
||||
handleToggleCaptions(video)
|
||||
break
|
||||
event.preventDefault();
|
||||
handleToggleCaptions(video);
|
||||
break;
|
||||
|
||||
// Frame advance (period key) / Increase playback speed (> or ')
|
||||
case '.':
|
||||
case "'": {
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
if ('.' === key) {
|
||||
handleFrameStep(ctx, 'forward')
|
||||
handleFrameStep(ctx, 'forward');
|
||||
} else {
|
||||
handlePlaybackSpeedChange(ctx, 0.25)
|
||||
handlePlaybackSpeedChange(ctx, 0.25);
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
// Frame rewind (comma key) / Decrease playback speed (< or ;)
|
||||
case ',':
|
||||
case ';': {
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
if (',' === key) {
|
||||
handleFrameStep(ctx, 'backward')
|
||||
handleFrameStep(ctx, 'backward');
|
||||
} else {
|
||||
handlePlaybackSpeedChange(ctx, -0.25)
|
||||
handlePlaybackSpeedChange(ctx, -0.25);
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
// Jump to percentage (0-9 keys)
|
||||
|
|
@ -163,55 +162,55 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
|
|||
case '7':
|
||||
case '8':
|
||||
case '9': {
|
||||
event.preventDefault()
|
||||
const percent = parseInt(key) * 10
|
||||
handleSeekToPercent(ctx, percent)
|
||||
break
|
||||
event.preventDefault();
|
||||
const percent = parseInt(key) * 10;
|
||||
handleSeekToPercent(ctx, percent);
|
||||
break;
|
||||
}
|
||||
|
||||
// Jump to start (Home)
|
||||
case 'home':
|
||||
event.preventDefault()
|
||||
video.currentTime = 0
|
||||
break
|
||||
event.preventDefault();
|
||||
video.currentTime = 0;
|
||||
break;
|
||||
|
||||
// Jump to end (End)
|
||||
case 'end':
|
||||
event.preventDefault()
|
||||
video.currentTime = video.duration
|
||||
break
|
||||
event.preventDefault();
|
||||
video.currentTime = video.duration;
|
||||
break;
|
||||
|
||||
// Show/hide help (Shift+/)
|
||||
case '?':
|
||||
case '/': {
|
||||
event.preventDefault()
|
||||
showHelp.value = !showHelp.value
|
||||
onHelpToggle?.()
|
||||
break
|
||||
event.preventDefault();
|
||||
showHelp.value = !showHelp.value;
|
||||
onHelpToggle?.();
|
||||
break;
|
||||
}
|
||||
|
||||
// Close player (Escape - already handled elsewhere but kept for completeness)
|
||||
case 'escape':
|
||||
event.preventDefault()
|
||||
closePlayer?.()
|
||||
break
|
||||
event.preventDefault();
|
||||
closePlayer?.();
|
||||
break;
|
||||
|
||||
default:
|
||||
// No action for unrecognized keys
|
||||
break
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling keyboard shortcut:', error)
|
||||
console.error('Error handling keyboard shortcut:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
|
||||
return {
|
||||
showHelp,
|
||||
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 {
|
||||
/**
|
||||
|
|
@ -6,56 +6,58 @@ export interface MediaQueryOptions {
|
|||
* Example: "(max-width: 640px) or (hover: none)"
|
||||
* If provided, takes precedence over maxWidth.
|
||||
*/
|
||||
query?: string
|
||||
query?: string;
|
||||
|
||||
/**
|
||||
* Max width in px considered true.
|
||||
* Ignored if `query` is provided. Default: 768.
|
||||
*/
|
||||
maxWidth?: number
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive state of a CSS media query.
|
||||
*/
|
||||
export function useMediaQuery(options: MediaQueryOptions = {}): Readonly<Ref<boolean>> {
|
||||
const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)`
|
||||
const matches = ref(false)
|
||||
const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)`;
|
||||
const matches = ref(false);
|
||||
|
||||
let mql: MediaQueryList | null = null
|
||||
let onChange: ((ev: MediaQueryListEvent) => void) | null = null
|
||||
let mql: MediaQueryList | null = null;
|
||||
let onChange: ((ev: MediaQueryListEvent) => void) | null = null;
|
||||
|
||||
const setup = () => {
|
||||
if ('undefined' === typeof window || !window.matchMedia) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
mql = window.matchMedia(query)
|
||||
matches.value = mql.matches
|
||||
mql = window.matchMedia(query);
|
||||
matches.value = mql.matches;
|
||||
|
||||
onChange = ev => { matches.value = ev.matches }
|
||||
onChange = (ev) => {
|
||||
matches.value = ev.matches;
|
||||
};
|
||||
if ('addEventListener' in mql) {
|
||||
mql.addEventListener('change', onChange as EventListener)
|
||||
return
|
||||
mql.addEventListener('change', onChange as EventListener);
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error legacy Safari
|
||||
mql.addListener(onChange)
|
||||
}
|
||||
mql.addListener(onChange);
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
if (!mql || !onChange) return
|
||||
if (!mql || !onChange) return;
|
||||
if ('removeEventListener' in mql) {
|
||||
mql.removeEventListener('change', onChange as EventListener)
|
||||
mql.removeEventListener('change', onChange as EventListener);
|
||||
} else {
|
||||
// @ts-expect-error legacy Safari
|
||||
mql.removeListener(onChange)
|
||||
mql.removeListener(onChange);
|
||||
}
|
||||
mql = null
|
||||
onChange = null
|
||||
}
|
||||
mql = null;
|
||||
onChange = null;
|
||||
};
|
||||
|
||||
onMounted(setup)
|
||||
onBeforeUnmount(teardown)
|
||||
onMounted(setup);
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
return shallowReadonly(matches)
|
||||
return shallowReadonly(matches);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +1,83 @@
|
|||
import { useStorage } from '@vueuse/core'
|
||||
import { POSITION, useToast } from "vue-toastification"
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { POSITION, useToast } from 'vue-toastification';
|
||||
|
||||
export type notificationType = 'info' | 'success' | 'warning' | 'error'
|
||||
export type notificationTarget = 'toast' | 'browser'
|
||||
export type notificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
export type notificationTarget = 'toast' | 'browser';
|
||||
|
||||
export interface notification {
|
||||
id: string;
|
||||
message: string
|
||||
level: notificationType
|
||||
seen: boolean
|
||||
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
|
||||
message: string;
|
||||
level: notificationType;
|
||||
seen: boolean;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
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()
|
||||
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);
|
||||
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> => {
|
||||
if (!('Notification' in window)) {
|
||||
return 'denied'
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return 'granted'
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
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 notificationStore = useNotificationStore()
|
||||
const sendMessage = (
|
||||
type: notificationType,
|
||||
id: string,
|
||||
message: string,
|
||||
opts?: notificationOptions,
|
||||
): void => {
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value ||
|
||||
!('Notification' in window) || 'granted' !== Notification.permission;
|
||||
const useToastNotification =
|
||||
!window.isSecureContext ||
|
||||
'toast' === toastTarget.value ||
|
||||
!('Notification' in window) ||
|
||||
'granted' !== Notification.permission;
|
||||
|
||||
if (useToastNotification) {
|
||||
switch (type) {
|
||||
case 'info':
|
||||
toast.info(message, opts)
|
||||
toast.info(message, opts);
|
||||
break;
|
||||
case 'success':
|
||||
toast.success(message, opts)
|
||||
toast.success(message, opts);
|
||||
break;
|
||||
case 'warning':
|
||||
toast.warning(message, opts)
|
||||
toast.warning(message, opts);
|
||||
break;
|
||||
case 'error':
|
||||
toast.error(message, opts)
|
||||
toast.error(message, opts);
|
||||
break;
|
||||
default:
|
||||
toast.error(`Unknown notification type: ${type}. ${message}`, opts)
|
||||
toast.error(`Unknown notification type: ${type}. ${message}`, opts);
|
||||
break;
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new Notification('YTPTube', {
|
||||
|
|
@ -77,32 +85,32 @@ const sendMessage = (type: notificationType, id: string, message: string, opts?:
|
|||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
tag: `ytptube-${type}`,
|
||||
silent: false
|
||||
})
|
||||
silent: false,
|
||||
});
|
||||
|
||||
setTimeout(() => notification.close(), 5000)
|
||||
setTimeout(() => notification.close(), 5000);
|
||||
|
||||
notification.onclick = () => {
|
||||
notificationStore.markRead(id)
|
||||
window.focus()
|
||||
notification.close()
|
||||
}
|
||||
}
|
||||
notificationStore.markRead(id);
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
};
|
||||
|
||||
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
||||
const notificationStore = useNotificationStore()
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
if (!opts) {
|
||||
opts = {}
|
||||
opts = {};
|
||||
}
|
||||
|
||||
let id: string = ''
|
||||
let id: string = '';
|
||||
const force = opts?.force || false;
|
||||
const store = opts?.store || true;
|
||||
const lowPriority = opts?.lowPriority || false;
|
||||
|
||||
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)) {
|
||||
|
|
@ -113,20 +121,20 @@ const notify = (type: notificationType, message: string, opts?: notificationOpti
|
|||
return;
|
||||
}
|
||||
|
||||
opts.closeOnClick = toastDismissOnClick.value
|
||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
||||
opts.closeOnClick = toastDismissOnClick.value;
|
||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
opts.onClick = (closeToast: Function) => {
|
||||
if (opts?.closeOnClick !== false) {
|
||||
closeToast()
|
||||
closeToast();
|
||||
}
|
||||
if (notificationStore) {
|
||||
notificationStore.markRead(id)
|
||||
notificationStore.markRead(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendMessage(type, id, message, opts)
|
||||
}
|
||||
sendMessage(type, id, message, opts);
|
||||
};
|
||||
|
||||
export const useNotification = () => {
|
||||
return {
|
||||
|
|
@ -136,5 +144,5 @@ export const useNotification = () => {
|
|||
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
||||
notify,
|
||||
requestBrowserPermission,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { ref, readonly } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import type { notification } from '~/types/notification'
|
||||
import type { APIResponse, Pagination } from '~/types/responses'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type { notification } from '~/types/notification';
|
||||
import type { APIResponse, Pagination } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* List of all notifications in memory.
|
||||
*/
|
||||
const notifications = ref<Array<notification>>([])
|
||||
const notifications = ref<Array<notification>>([]);
|
||||
/**
|
||||
* Pagination state for notifications list.
|
||||
*/
|
||||
|
|
@ -19,31 +19,31 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* List of allowed notification events.
|
||||
*/
|
||||
const events = ref<Array<string>>([])
|
||||
const events = ref<Array<string>>([]);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const addInProgress = ref<boolean>(false);
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
const notify = useNotification();
|
||||
|
||||
/**
|
||||
* Sorts notifications by name (A-Z).
|
||||
|
|
@ -51,8 +51,8 @@ const notify = useNotification()
|
|||
* @returns Sorted array of notifications
|
||||
*/
|
||||
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.
|
||||
|
|
@ -61,13 +61,12 @@ const sortNotifications = (items: Array<notification>): Array<notification> => {
|
|||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
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.
|
||||
|
|
@ -76,23 +75,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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.filter(existing => existing.id !== item.id),
|
||||
...notifications.value.filter((existing) => existing.id !== item.id),
|
||||
item,
|
||||
])
|
||||
]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a notification from the list by ID.
|
||||
|
|
@ -116,67 +115,67 @@ const updateNotifications = (item: notification): void => {
|
|||
* @param id Notification ID
|
||||
*/
|
||||
const removeNotification = (id: number) => {
|
||||
const initialLength = notifications.value.length
|
||||
notifications.value = notifications.value.filter(item => item.id !== id)
|
||||
const initialLength = notifications.value.length;
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
* @param page Page number
|
||||
* @param perPage Items per page
|
||||
*/
|
||||
const loadNotifications = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
await loadNotificationEvents()
|
||||
const loadNotifications = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
await loadNotificationEvents();
|
||||
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/notifications/?page=${page}`
|
||||
let url = `/api/notifications/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<notification>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } = await parse_list_response<notification>(json);
|
||||
|
||||
notifications.value = sortNotifications(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
notifications.value = sortNotifications(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
*/
|
||||
const loadNotificationEvents = async (): Promise<void> => {
|
||||
if (events.value.length > 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/notifications/events/')
|
||||
await ensureSuccess(response)
|
||||
const response = await request('/api/notifications/events/');
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
events.value = Array.isArray(json?.events) ? json.events : []
|
||||
lastError.value = null
|
||||
const json = await response.json();
|
||||
events.value = Array.isArray(json?.events) ? json.events : [];
|
||||
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.
|
||||
|
|
@ -185,21 +184,20 @@ const loadNotificationEvents = async (): Promise<void> => {
|
|||
*/
|
||||
const getNotification = async (id: number): Promise<notification | null> => {
|
||||
try {
|
||||
const response = await request(`/api/notifications/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/notifications/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const item = await parse_api_response<notification>(json)
|
||||
const json = await response.json();
|
||||
const item = await parse_api_response<notification>(json);
|
||||
|
||||
lastError.value = null
|
||||
return item
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -211,42 +209,40 @@ const createNotification = async (
|
|||
item: Omit<notification, 'id'>,
|
||||
callback?: (response: APIResponse<notification>) => void,
|
||||
): Promise<notification | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request('/api/notifications/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(item),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const created = await parse_api_response<notification>(json)
|
||||
const json = await response.json();
|
||||
const created = await parse_api_response<notification>(json);
|
||||
|
||||
updateNotifications(created)
|
||||
notify.success('Notification target created.')
|
||||
lastError.value = null
|
||||
updateNotifications(created);
|
||||
notify.success('Notification target created.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: created })
|
||||
callback({ success: true, error: null, detail: null, data: created });
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing notification via API (PUT - full update).
|
||||
|
|
@ -260,45 +256,43 @@ const updateNotification = async (
|
|||
item: notification,
|
||||
callback?: (response: APIResponse<notification>) => void,
|
||||
): Promise<notification | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (item.id) {
|
||||
item.id = undefined
|
||||
item.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(item),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<notification>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<notification>(json);
|
||||
|
||||
updateNotifications(updated)
|
||||
notify.success(`Notification target '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateNotifications(updated);
|
||||
notify.success(`Notification target '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially updates an existing notification via API (PATCH).
|
||||
|
|
@ -312,45 +306,43 @@ const patchNotification = async (
|
|||
patch: Partial<notification>,
|
||||
callback?: (response: APIResponse<notification>) => void,
|
||||
): Promise<notification | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
if (patch.id) {
|
||||
patch.id = undefined
|
||||
patch.id = undefined;
|
||||
}
|
||||
const response = await request(`/api/notifications/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<notification>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<notification>(json);
|
||||
|
||||
updateNotifications(updated)
|
||||
notify.success(`Notification target '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateNotifications(updated);
|
||||
notify.success(`Notification target '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a notification by ID via API.
|
||||
|
|
@ -363,43 +355,42 @@ const deleteNotification = async (
|
|||
callback?: (response: APIResponse<boolean>) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/notifications/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/notifications/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removeNotification(id)
|
||||
notify.success('Notification target deleted.')
|
||||
lastError.value = null
|
||||
removeNotification(id);
|
||||
notify.success('Notification target deleted.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: true })
|
||||
callback({ success: true, error: null, detail: null, data: true });
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return false
|
||||
if (throwInstead.value) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a notification URL is for Apprise (non-HTTP).
|
||||
* @param url Notification URL
|
||||
* @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.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* useNotifications composable
|
||||
|
|
@ -423,5 +414,5 @@ export const useNotifications = () => ({
|
|||
deleteNotification,
|
||||
clearError,
|
||||
throwInstead,
|
||||
isApprise
|
||||
})
|
||||
isApprise,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { ref, readonly } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import type { Preset, PresetRequest } from '~/types/presets'
|
||||
import type { APIResponse, Pagination } from '~/types/responses'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type { Preset, PresetRequest } from '~/types/presets';
|
||||
import type { APIResponse, Pagination } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* List of all presets in memory.
|
||||
*/
|
||||
const presets = ref<Array<Preset>>([])
|
||||
const presets = ref<Array<Preset>>([]);
|
||||
/**
|
||||
* Pagination state for presets list.
|
||||
*/
|
||||
|
|
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const addInProgress = ref<boolean>(false);
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
const notify = useNotification();
|
||||
|
||||
/**
|
||||
* Sorts presets by priority (descending), then name (A-Z).
|
||||
|
|
@ -49,12 +49,12 @@ const notify = useNotification()
|
|||
const sortPresets = (items: Array<Preset>): Array<Preset> => {
|
||||
return [...items].sort((a, b) => {
|
||||
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.
|
||||
|
|
@ -63,13 +63,12 @@ const sortPresets = (items: Array<Preset>): Array<Preset> => {
|
|||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
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.
|
||||
|
|
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const updatePresets = (preset: Preset): void => {
|
||||
const isNew = !presets.value.some(item => item.id === preset.id)
|
||||
presets.value = sortPresets([
|
||||
...presets.value.filter(item => item.id !== preset.id),
|
||||
preset,
|
||||
])
|
||||
const isNew = !presets.value.some((item) => item.id === preset.id);
|
||||
presets.value = sortPresets([...presets.value.filter((item) => item.id !== preset.id), preset]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a preset from the presets list by ID.
|
||||
|
|
@ -118,12 +114,12 @@ const updatePresets = (preset: Preset): void => {
|
|||
* @param id Preset ID
|
||||
*/
|
||||
const removePreset = (id: number) => {
|
||||
const initialLength = presets.value.length
|
||||
presets.value = presets.value.filter(item => item.id !== id)
|
||||
const initialLength = presets.value.length;
|
||||
presets.value = presets.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
|
|
@ -131,31 +127,32 @@ const removePreset = (id: number) => {
|
|||
* @param page Page number
|
||||
* @param perPage Items per page
|
||||
*/
|
||||
const loadPresets = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
isLoading.value = true
|
||||
const loadPresets = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/presets/?page=${page}`
|
||||
let url = `/api/presets/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<Preset>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } = await parse_list_response<Preset>(json);
|
||||
|
||||
presets.value = sortPresets(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
presets.value = sortPresets(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
|
|
@ -164,21 +161,20 @@ const loadPresets = async (page: number = 1, perPage: number | undefined = undef
|
|||
*/
|
||||
const getPreset = async (id: number): Promise<Preset | null> => {
|
||||
try {
|
||||
const response = await request(`/api/presets/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/presets/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const preset = await parse_api_response<Preset>(json)
|
||||
const json = await response.json();
|
||||
const preset = await parse_api_response<Preset>(json);
|
||||
|
||||
lastError.value = null
|
||||
return preset
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -190,43 +186,41 @@ const createPreset = async (
|
|||
preset: PresetRequest,
|
||||
callback?: (response: APIResponse<Preset>) => void,
|
||||
): Promise<Preset | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request('/api/presets/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(preset),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const created = await parse_api_response<Preset>(json)
|
||||
const json = await response.json();
|
||||
const created = await parse_api_response<Preset>(json);
|
||||
|
||||
updatePresets(created)
|
||||
notify.success('Preset created.')
|
||||
lastError.value = null
|
||||
updatePresets(created);
|
||||
notify.success('Preset created.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: created })
|
||||
callback({ success: true, error: null, detail: null, data: created });
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing preset via API (PUT - full update).
|
||||
|
|
@ -240,50 +234,48 @@ const updatePreset = async (
|
|||
preset: Preset,
|
||||
callback?: (response: APIResponse<Preset>) => void,
|
||||
): Promise<Preset | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const payload = { ...preset }
|
||||
const payload = { ...preset };
|
||||
if (payload.id) {
|
||||
payload.id = undefined
|
||||
payload.id = undefined;
|
||||
}
|
||||
if ('default' in payload) {
|
||||
payload.default = false
|
||||
payload.default = false;
|
||||
}
|
||||
const response = await request(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Preset>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Preset>(json);
|
||||
|
||||
updatePresets(updated)
|
||||
notify.success(`Preset '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updatePresets(updated);
|
||||
notify.success(`Preset '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially updates an existing preset via API (PATCH).
|
||||
|
|
@ -297,50 +289,48 @@ const patchPreset = async (
|
|||
patch: Partial<Preset>,
|
||||
callback?: (response: APIResponse<Preset>) => void,
|
||||
): Promise<Preset | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const payload = { ...patch }
|
||||
const payload = { ...patch };
|
||||
if (payload.id) {
|
||||
payload.id = undefined
|
||||
payload.id = undefined;
|
||||
}
|
||||
if ('default' in payload) {
|
||||
payload.default = false
|
||||
payload.default = false;
|
||||
}
|
||||
const response = await request(`/api/presets/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
await ensureSuccess(response)
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Preset>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Preset>(json);
|
||||
|
||||
updatePresets(updated)
|
||||
notify.success(`Preset '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updatePresets(updated);
|
||||
notify.success(`Preset '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a preset by ID via API.
|
||||
|
|
@ -353,36 +343,35 @@ const deletePreset = async (
|
|||
callback?: (response: APIResponse<boolean>) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/presets/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removePreset(id)
|
||||
notify.success('Preset deleted.')
|
||||
lastError.value = null
|
||||
removePreset(id);
|
||||
notify.success('Preset deleted.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: true })
|
||||
callback({ success: true, error: null, detail: null, data: true });
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return false
|
||||
if (throwInstead.value) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the last error message.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* usePresets composable
|
||||
|
|
@ -404,4 +393,4 @@ export const usePresets = () => ({
|
|||
deletePreset,
|
||||
clearError,
|
||||
throwInstead,
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { ref, readonly } from 'vue';
|
||||
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type {
|
||||
TaskDefinitionDetailed,
|
||||
TaskDefinitionDocument,
|
||||
TaskDefinitionSummary,
|
||||
} from '~/types/task_definitions'
|
||||
import type { Pagination } from '~/types/responses'
|
||||
} from '~/types/task_definitions';
|
||||
import type { Pagination } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
@ -23,25 +23,25 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* Indicates if a request is in progress.
|
||||
*/
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(false);
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
const notify = useNotification();
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
return [...items].sort((a, b) => {
|
||||
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.
|
||||
|
|
@ -65,80 +65,85 @@ const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitio
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.clone().json().catch(() => null)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates or adds a summary in the definitions list, keeping sort order.
|
||||
* @param summary TaskDefinitionSummary to update/add
|
||||
*/
|
||||
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.filter(item => item.id !== summary.id),
|
||||
...definitions.value.filter((item) => item.id !== summary.id),
|
||||
summary,
|
||||
])
|
||||
]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a summary from the definitions list by ID.
|
||||
* @param id Task definition ID
|
||||
*/
|
||||
const removeSummary = (id: number) => {
|
||||
const initialLength = definitions.value.length
|
||||
definitions.value = definitions.value.filter(item => item.id !== id)
|
||||
const initialLength = definitions.value.length;
|
||||
definitions.value = definitions.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
* Updates definitions and lastError.
|
||||
*/
|
||||
const loadDefinitions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
isLoading.value = true
|
||||
const loadDefinitions = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/tasks/definitions/?page=${page}`
|
||||
let url = `/api/tasks/definitions/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<TaskDefinitionSummary>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } =
|
||||
await parse_list_response<TaskDefinitionSummary>(json);
|
||||
|
||||
definitions.value = sortSummaries(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
definitions.value = sortSummaries(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
|
|
@ -147,36 +152,37 @@ const loadDefinitions = async (page: number = 1, perPage: number | undefined = u
|
|||
*/
|
||||
const getDefinition = async (id: number): Promise<TaskDefinitionDetailed | null> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/definitions/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/definitions/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const payload = await response.json()
|
||||
const detailed = await parse_api_response<TaskDefinitionDetailed>(payload)
|
||||
lastError.value = null
|
||||
return detailed
|
||||
const payload = await response.json();
|
||||
const detailed = await parse_api_response<TaskDefinitionDetailed>(payload);
|
||||
lastError.value = null;
|
||||
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.
|
||||
* @param definition TaskDefinitionDocument to create
|
||||
* @returns Created TaskDefinitionDetailed or null on error
|
||||
*/
|
||||
const createDefinition = async (definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
|
||||
const createDefinition = async (
|
||||
definition: TaskDefinitionDocument,
|
||||
): Promise<TaskDefinitionDetailed | null> => {
|
||||
try {
|
||||
const response = await request('/api/tasks/definitions/', {
|
||||
method: 'POST',
|
||||
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({
|
||||
id: payload.id,
|
||||
|
|
@ -186,18 +192,17 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
|
|||
enabled: payload.enabled,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
})
|
||||
});
|
||||
|
||||
notify.success('Task definition created.')
|
||||
lastError.value = null
|
||||
return payload
|
||||
notify.success('Task definition created.');
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -205,16 +210,19 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
|
|||
* @param definition Updated TaskDefinitionDocument
|
||||
* @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 {
|
||||
const response = await request(`/api/tasks/definitions/${id}`, {
|
||||
method: 'PUT',
|
||||
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({
|
||||
id: payload.id,
|
||||
|
|
@ -224,18 +232,17 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
|
|||
enabled: payload.enabled,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
})
|
||||
});
|
||||
|
||||
notify.success('Task definition updated.')
|
||||
lastError.value = null
|
||||
return payload
|
||||
notify.success('Task definition updated.');
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -244,20 +251,19 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
|
|||
*/
|
||||
const deleteDefinition = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removeSummary(id)
|
||||
notify.success('Task definition deleted.')
|
||||
lastError.value = null
|
||||
return true
|
||||
removeSummary(id);
|
||||
notify.success('Task definition deleted.');
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -265,16 +271,19 @@ const deleteDefinition = async (id: number): Promise<boolean> => {
|
|||
* @param enabled New enabled status
|
||||
* @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 {
|
||||
const response = await request(`/api/tasks/definitions/${id}`, {
|
||||
method: 'PATCH',
|
||||
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({
|
||||
id: payload.id,
|
||||
|
|
@ -284,23 +293,22 @@ const toggleEnabled = async (id: number, enabled: boolean): Promise<TaskDefiniti
|
|||
enabled: payload.enabled,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
})
|
||||
});
|
||||
|
||||
notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`)
|
||||
lastError.value = null
|
||||
return payload
|
||||
notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`);
|
||||
lastError.value = null;
|
||||
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.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* useTaskDefinitions composable
|
||||
|
|
@ -321,6 +329,6 @@ export const useTaskDefinitions = () => ({
|
|||
toggleEnabled,
|
||||
clearError,
|
||||
throwInstead,
|
||||
})
|
||||
});
|
||||
|
||||
export default useTaskDefinitions
|
||||
export default useTaskDefinitions;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||
import { ref, readonly } from 'vue';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
|
||||
import type {
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskInspectRequest,
|
||||
TaskInspectResponse,
|
||||
TaskMetadataResponse,
|
||||
} from '~/types/tasks'
|
||||
import type { APIResponse, Pagination } from '~/types/responses'
|
||||
} from '~/types/tasks';
|
||||
import type { APIResponse, Pagination } from '~/types/responses';
|
||||
|
||||
/**
|
||||
* List of all tasks in memory.
|
||||
*/
|
||||
const tasks = ref<Array<Task>>([])
|
||||
const tasks = ref<Array<Task>>([]);
|
||||
/**
|
||||
* Pagination state for tasks list.
|
||||
*/
|
||||
|
|
@ -24,31 +24,31 @@ const pagination = ref<Pagination>({
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
})
|
||||
});
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const addInProgress = ref<boolean>(false);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
const throwInstead = ref(false)
|
||||
const throwInstead = ref(false);
|
||||
/**
|
||||
* Notification composable for showing success/error messages.
|
||||
*/
|
||||
const notify = useNotification()
|
||||
const notify = useNotification();
|
||||
|
||||
/**
|
||||
* Sorts tasks by name (A-Z).
|
||||
|
|
@ -56,8 +56,8 @@ const notify = useNotification()
|
|||
* @returns Sorted array of tasks
|
||||
*/
|
||||
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.
|
||||
|
|
@ -66,13 +66,12 @@ const sortTasks = (items: Array<Task>): Array<Task> => {
|
|||
*/
|
||||
const readJson = async (response: Response): Promise<unknown> => {
|
||||
try {
|
||||
const clone = response.clone()
|
||||
return await clone.json()
|
||||
const clone = response.clone();
|
||||
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.
|
||||
|
|
@ -81,23 +80,23 @@ const readJson = async (response: Response): Promise<unknown> => {
|
|||
*/
|
||||
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await readJson(response)
|
||||
const message = await parse_api_error(payload)
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await readJson(response);
|
||||
const message = await parse_api_error(payload);
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles errors by updating lastError and showing a notification.
|
||||
* @param error Error object or unknown
|
||||
*/
|
||||
const handleError = (error: unknown): void => {
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
lastError.value = message
|
||||
notify.error(message)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
lastError.value = message;
|
||||
notify.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const updateTasksList = (item: Task): void => {
|
||||
const isNew = !tasks.value.some(existing => existing.id === item.id)
|
||||
tasks.value = sortTasks([
|
||||
...tasks.value.filter(existing => existing.id !== item.id),
|
||||
item,
|
||||
])
|
||||
const isNew = !tasks.value.some((existing) => existing.id === item.id);
|
||||
tasks.value = sortTasks([...tasks.value.filter((existing) => existing.id !== item.id), item]);
|
||||
if (isNew) {
|
||||
pagination.value.total++
|
||||
pagination.value.total++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a task from the list by ID.
|
||||
|
|
@ -121,43 +117,44 @@ const updateTasksList = (item: Task): void => {
|
|||
* @param id Task ID
|
||||
*/
|
||||
const removeTask = (id: number) => {
|
||||
const initialLength = tasks.value.length
|
||||
tasks.value = tasks.value.filter(item => item.id !== id)
|
||||
const initialLength = tasks.value.length;
|
||||
tasks.value = tasks.value.filter((item) => item.id !== id);
|
||||
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.
|
||||
* @param page Page number
|
||||
* @param perPage Items per page
|
||||
*/
|
||||
const loadTasks = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||
isLoading.value = true
|
||||
const loadTasks = async (
|
||||
page: number = 1,
|
||||
perPage: number | undefined = undefined,
|
||||
): Promise<void> => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let url = `/api/tasks/?page=${page}`
|
||||
let url = `/api/tasks/?page=${page}`;
|
||||
if (perPage !== undefined) {
|
||||
url += `&per_page=${perPage}`
|
||||
url += `&per_page=${perPage}`;
|
||||
}
|
||||
const response = await request(url)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(url);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const { items, pagination: paginationData } = await parse_list_response<Task>(json)
|
||||
const json = await response.json();
|
||||
const { items, pagination: paginationData } = await parse_list_response<Task>(json);
|
||||
|
||||
tasks.value = sortTasks(items)
|
||||
pagination.value = paginationData
|
||||
lastError.value = null
|
||||
tasks.value = sortTasks(items);
|
||||
pagination.value = paginationData;
|
||||
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.
|
||||
|
|
@ -166,21 +163,20 @@ const loadTasks = async (page: number = 1, perPage: number | undefined = undefin
|
|||
*/
|
||||
const getTask = async (id: number): Promise<Task | null> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}`)
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/${id}`);
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const task = await parse_api_response<Task>(json)
|
||||
const json = await response.json();
|
||||
const task = await parse_api_response<Task>(json);
|
||||
|
||||
lastError.value = null
|
||||
return task
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -189,57 +185,57 @@ const getTask = async (id: number): Promise<Task | null> => {
|
|||
* @returns Created task(s) or null on error
|
||||
*/
|
||||
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,
|
||||
): Promise<Task | Task[] | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request('/api/tasks/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(task),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const created = await parse_api_response<Task | Array<Task>>(json)
|
||||
const json = await response.json();
|
||||
const created = await parse_api_response<Task | Array<Task>>(json);
|
||||
|
||||
if (Array.isArray(created)) {
|
||||
notify.success(`${created.length} tasks created.`)
|
||||
created.forEach(t => updateTasksList(t))
|
||||
lastError.value = null
|
||||
notify.success(`${created.length} tasks created.`);
|
||||
created.forEach((t) => updateTasksList(t));
|
||||
lastError.value = null;
|
||||
|
||||
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)
|
||||
notify.success('Task created.')
|
||||
lastError.value = null
|
||||
updateTasksList(created);
|
||||
notify.success('Task created.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: created })
|
||||
callback({ success: true, error: null, detail: null, data: created });
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an existing task via API (PUT - full update).
|
||||
|
|
@ -253,45 +249,43 @@ const updateTask = async (
|
|||
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
||||
callback?: (response: APIResponse<Task>) => void,
|
||||
): Promise<Task | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
// 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}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(taskData),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Task>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Task>(json);
|
||||
|
||||
updateTasksList(updated)
|
||||
notify.success(`Task '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateTasksList(updated);
|
||||
notify.success(`Task '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Partially updates an existing task via API (PATCH).
|
||||
|
|
@ -305,42 +299,40 @@ const patchTask = async (
|
|||
patch: TaskPatch,
|
||||
callback?: (response: APIResponse<Task>) => void,
|
||||
): Promise<Task | null> => {
|
||||
addInProgress.value = true
|
||||
addInProgress.value = true;
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
await ensureSuccess(response)
|
||||
});
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const updated = await parse_api_response<Task>(json)
|
||||
const json = await response.json();
|
||||
const updated = await parse_api_response<Task>(json);
|
||||
|
||||
updateTasksList(updated)
|
||||
notify.success(`Task '${updated.name}' updated.`)
|
||||
lastError.value = null
|
||||
updateTasksList(updated);
|
||||
notify.success(`Task '${updated.name}' updated.`);
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: updated })
|
||||
callback({ success: true, error: null, detail: null, data: updated });
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return null
|
||||
if (throwInstead.value) throw error;
|
||||
return null;
|
||||
} finally {
|
||||
addInProgress.value = false;
|
||||
}
|
||||
finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a task by ID via API.
|
||||
|
|
@ -353,55 +345,55 @@ const deleteTask = async (
|
|||
callback?: (response: APIResponse<boolean>) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
removeTask(id)
|
||||
notify.success('Task deleted.')
|
||||
lastError.value = null
|
||||
removeTask(id);
|
||||
notify.success('Task deleted.');
|
||||
lastError.value = null;
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true, error: null, detail: null, data: true })
|
||||
callback({ success: true, error: null, detail: null, data: true });
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||
handleError(error)
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
|
||||
handleError(error);
|
||||
|
||||
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
|
||||
return false
|
||||
if (throwInstead.value) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Inspects a task handler to check if it can process the given URL.
|
||||
* @param request Task inspect request parameters
|
||||
* @returns Inspect result or null on error
|
||||
*/
|
||||
const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInspectResponse | null> => {
|
||||
const inspectTaskHandler = async (
|
||||
request: TaskInspectRequest,
|
||||
): Promise<TaskInspectResponse | null> => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
});
|
||||
|
||||
const json = await response.json()
|
||||
lastError.value = null
|
||||
return json as TaskInspectResponse
|
||||
const json = await response.json();
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -410,22 +402,21 @@ const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInsp
|
|||
*/
|
||||
const markTaskItems = async (id: number): Promise<string | null> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const message = json.message || 'All items marked as downloaded.'
|
||||
const json = await response.json();
|
||||
const message = json.message || 'All items marked as downloaded.';
|
||||
|
||||
notify.success(message)
|
||||
lastError.value = null
|
||||
return message
|
||||
notify.success(message);
|
||||
lastError.value = null;
|
||||
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.
|
||||
|
|
@ -434,22 +425,21 @@ const markTaskItems = async (id: number): Promise<string | null> => {
|
|||
*/
|
||||
const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const message = json.message || 'All items removed from archive.'
|
||||
const json = await response.json();
|
||||
const message = json.message || 'All items removed from archive.';
|
||||
|
||||
notify.success(message)
|
||||
lastError.value = null
|
||||
return message
|
||||
notify.success(message);
|
||||
lastError.value = null;
|
||||
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).
|
||||
|
|
@ -458,56 +448,55 @@ const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
|||
*/
|
||||
const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => {
|
||||
try {
|
||||
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' })
|
||||
await ensureSuccess(response)
|
||||
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' });
|
||||
await ensureSuccess(response);
|
||||
|
||||
const json = await response.json()
|
||||
const metadata = await parse_api_response<TaskMetadataResponse>(json)
|
||||
const json = await response.json();
|
||||
const metadata = await parse_api_response<TaskMetadataResponse>(json);
|
||||
|
||||
notify.success('Metadata generation completed.')
|
||||
lastError.value = null
|
||||
return metadata
|
||||
notify.success('Metadata generation completed.');
|
||||
lastError.value = null;
|
||||
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.
|
||||
*/
|
||||
const clearError = () => lastError.value = null
|
||||
const clearError = () => (lastError.value = null);
|
||||
|
||||
/**
|
||||
* Checks if a task is currently in progress.
|
||||
* @param id Task ID
|
||||
* @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.
|
||||
* @param id Task ID
|
||||
*/
|
||||
const setTaskInProgress = (id: number): void => {
|
||||
inProgressIds.value.add(id)
|
||||
}
|
||||
inProgressIds.value.add(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the in progress state for a task.
|
||||
* @param id Task ID
|
||||
*/
|
||||
const clearTaskInProgress = (id: number): void => {
|
||||
inProgressIds.value.delete(id)
|
||||
}
|
||||
inProgressIds.value.delete(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets all state to initial values (for testing only).
|
||||
*/
|
||||
const __resetForTesting = () => {
|
||||
tasks.value = []
|
||||
tasks.value = [];
|
||||
pagination.value = {
|
||||
page: 1,
|
||||
per_page: 50,
|
||||
|
|
@ -515,13 +504,13 @@ const __resetForTesting = () => {
|
|||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
}
|
||||
isLoading.value = false
|
||||
addInProgress.value = false
|
||||
lastError.value = null
|
||||
throwInstead.value = false
|
||||
inProgressIds.value = new Set()
|
||||
}
|
||||
};
|
||||
isLoading.value = false;
|
||||
addInProgress.value = false;
|
||||
lastError.value = null;
|
||||
throwInstead.value = false;
|
||||
inProgressIds.value = new Set();
|
||||
};
|
||||
|
||||
/**
|
||||
* useTasks composable
|
||||
|
|
@ -552,4 +541,4 @@ export const useTasks = () => ({
|
|||
clearError,
|
||||
throwInstead,
|
||||
__resetForTesting,
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -46,10 +47,10 @@
|
|||
const props = defineProps({
|
||||
error: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const showStacks = ref(false)
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const showStacks = ref(false);
|
||||
|
||||
onMounted(() => console.error(props.error))
|
||||
onMounted(() => console.error(props.error));
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
<template>
|
||||
|
||||
<template v-if="simpleMode">
|
||||
<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>
|
||||
|
||||
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
|
||||
@reload_bg="() => loadImage(true)" direction="right" />
|
||||
<SettingsPanel
|
||||
:isOpen="show_settings"
|
||||
:isLoading="loadingImage"
|
||||
@close="closeSettings()"
|
||||
@reload_bg="() => loadImage(true)"
|
||||
direction="right"
|
||||
/>
|
||||
|
||||
<template v-if="!simpleMode">
|
||||
<Shutdown v-if="app_shutdown" />
|
||||
|
|
@ -14,17 +21,25 @@
|
|||
<NewVersion v-if="newVersionIsAvailable" />
|
||||
<nav class="navbar is-mobile is-dark">
|
||||
<div class="navbar-brand pl-5">
|
||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'" id="top">
|
||||
<NuxtLink
|
||||
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="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" />
|
||||
</span>
|
||||
<span class="has-text-bold" :class="connectionStatusColor">
|
||||
YTPTube
|
||||
</span>
|
||||
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
||||
<span class="has-text-bold" :class="connectionStatusColor"> YTPTube </span>
|
||||
<span class="has-text-bold" v-if="config?.app?.instance_title"
|
||||
>: {{ config.app.instance_title }}</span
|
||||
>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
|
|
@ -37,12 +52,20 @@
|
|||
|
||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||
<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>Files</span>
|
||||
</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>Presets</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -53,30 +76,45 @@
|
|||
<span>Tasks</span>
|
||||
</a>
|
||||
<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>List</span>
|
||||
</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>Definitions</span>
|
||||
</NuxtLink>
|
||||
</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"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span>Notifications</span>
|
||||
</span>
|
||||
</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>Conditions</span>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown">
|
||||
|
|
@ -86,19 +124,31 @@
|
|||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app?.file_logging">
|
||||
<NuxtLink
|
||||
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>Logs</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app.console_enabled">
|
||||
<NuxtLink
|
||||
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>Console</span>
|
||||
</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>Custom fields</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -122,36 +172,47 @@
|
|||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }"
|
||||
@click="show_settings = !show_settings">
|
||||
<button
|
||||
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 v-if="isMobile">WebUI Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||
<Message v-if="!config.is_loaded" class="mt-5"
|
||||
<NuxtPage
|
||||
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 }"
|
||||
: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">
|
||||
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
|
||||
page.
|
||||
<NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload
|
||||
the page.
|
||||
</p>
|
||||
<p v-if="!config.is_loading">
|
||||
Failed to load the application configuration. This likely indicates a problem with the backend. Try to
|
||||
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)">reload
|
||||
configuration</NuxtLink> or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page
|
||||
</NuxtLink>.
|
||||
Failed to load the application configuration. This likely indicates a problem with the
|
||||
backend. Try to
|
||||
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)"
|
||||
>reload configuration</NuxtLink
|
||||
>
|
||||
or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page </NuxtLink>.
|
||||
</p>
|
||||
<template v-if="socket.error">
|
||||
<hr>
|
||||
<hr />
|
||||
<p class="has-text-danger">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
|
||||
|
|
@ -161,7 +222,7 @@
|
|||
</p>
|
||||
</template>
|
||||
</Message>
|
||||
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
|
||||
<Markdown @closeModel="() => (doc.file = '')" :file="doc.file" v-if="doc.file" />
|
||||
<ClientOnly>
|
||||
<Dialog />
|
||||
</ClientOnly>
|
||||
|
|
@ -179,15 +240,23 @@
|
|||
<div class="columns is-multiline is-variable is-8">
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<div class="mb-3">
|
||||
<NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank"
|
||||
class="has-text-weight-semibold is-size-6">
|
||||
<NuxtLink
|
||||
href="https://github.com/ArabCoders/ytptube"
|
||||
target="_blank"
|
||||
class="has-text-weight-semibold is-size-6"
|
||||
>
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fab fa-github" /></span>
|
||||
<span>YTPTube</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span 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}`">
|
||||
<span
|
||||
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' }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -196,20 +265,37 @@
|
|||
<span class="icon-text">
|
||||
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
||||
<span>Update available:</span>
|
||||
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
|
||||
target="_blank" class="has-text-weight-semibold ml-1">
|
||||
<NuxtLink
|
||||
: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 }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<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 }"
|
||||
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">
|
||||
<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>{{ updateCheckMessage }}</span>
|
||||
</span>
|
||||
|
|
@ -219,8 +305,12 @@
|
|||
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-clock" /></span>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')">
|
||||
<span
|
||||
class="has-tooltip"
|
||||
v-tooltip="
|
||||
'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')
|
||||
"
|
||||
>
|
||||
{{ moment.unix(config.app?.started).fromNow() }}
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -228,16 +318,28 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-3-tablet">
|
||||
<div 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;">
|
||||
<div
|
||||
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>
|
||||
<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"><i class="fab fa-github" /></span>
|
||||
<span>yt-dlp</span>
|
||||
</span>
|
||||
</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">
|
||||
<span class="icon-text">
|
||||
|
|
@ -245,8 +347,11 @@
|
|||
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
|
||||
<span>Update available:</span>
|
||||
</span>
|
||||
<NuxtLink :href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
|
||||
target="_blank" class="has-text-weight-semibold ml-1">
|
||||
<NuxtLink
|
||||
: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 }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
|
|
@ -258,7 +363,8 @@
|
|||
<p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p>
|
||||
<div
|
||||
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">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-list" /></span>
|
||||
|
|
@ -298,370 +404,377 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, readonly } from 'vue'
|
||||
import 'assets/css/bulma.css'
|
||||
import 'assets/css/style.css'
|
||||
import 'assets/css/all.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import moment from 'moment'
|
||||
import type { YTDLPOption } from '~/types/ytdlp'
|
||||
import { useDialog } from '~/composables/useDialog'
|
||||
import Dialog from '~/components/Dialog.vue'
|
||||
import Simple from '~/components/Simple.vue'
|
||||
import Shutdown from '~/components/shutdown.vue'
|
||||
import Markdown from '~/components/Markdown.vue'
|
||||
import type { version_check } from '~/types'
|
||||
import { ref, onMounted, watch, readonly } from 'vue';
|
||||
import 'assets/css/bulma.css';
|
||||
import 'assets/css/style.css';
|
||||
import 'assets/css/all.css';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import moment from 'moment';
|
||||
import type { YTDLPOption } from '~/types/ytdlp';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import Dialog from '~/components/Dialog.vue';
|
||||
import Simple from '~/components/Simple.vue';
|
||||
import Shutdown from '~/components/shutdown.vue';
|
||||
import Markdown from '~/components/Markdown.vue';
|
||||
import type { version_check } from '~/types';
|
||||
|
||||
const selectedTheme = useStorage('theme', 'auto')
|
||||
const socket = useSocketStore()
|
||||
const config = useConfigStore()
|
||||
const loadedImage = ref()
|
||||
const loadingImage = ref(false)
|
||||
const bg_enable = useStorage('random_bg', true)
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
||||
const showMenu = ref(false)
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const app_shutdown = ref<boolean>(false)
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||
const show_settings = ref(false)
|
||||
const doc = ref<{ file: string }>({ file: '' })
|
||||
const checkingUpdates = ref(false)
|
||||
const updateCheckMessage = ref('Up to date - Click to check')
|
||||
const selectedTheme = useStorage('theme', 'auto');
|
||||
const socket = useSocketStore();
|
||||
const config = useConfigStore();
|
||||
const loadedImage = ref();
|
||||
const loadingImage = ref(false);
|
||||
const bg_enable = useStorage('random_bg', true);
|
||||
const bg_opacity = useStorage('random_bg_opacity', 0.95);
|
||||
const showMenu = ref(false);
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const app_shutdown = ref<boolean>(false);
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||
const show_settings = ref(false);
|
||||
const doc = ref<{ file: string }>({ file: '' });
|
||||
const checkingUpdates = ref(false);
|
||||
const updateCheckMessage = ref('Up to date - Click to check');
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (checkingUpdates.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = 'Up to date - Click to check'
|
||||
const msg = 'Up to date - Click to check';
|
||||
|
||||
try {
|
||||
checkingUpdates.value = true
|
||||
updateCheckMessage.value = 'Checking...'
|
||||
checkingUpdates.value = true;
|
||||
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) {
|
||||
await response.json()
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
return
|
||||
await response.json();
|
||||
updateCheckMessage.value = 'Check failed';
|
||||
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||
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) {
|
||||
config.app.new_version = data.app.new_version
|
||||
config.app.new_version = data.app.new_version;
|
||||
}
|
||||
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)
|
||||
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) {
|
||||
updateCheckMessage.value = 'Up to date ✓'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
updateCheckMessage.value = 'Up to date ✓';
|
||||
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||
} 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)
|
||||
updateCheckMessage.value = 'Up to date ✓'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
updateCheckMessage.value = 'Up to date ✓';
|
||||
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||
} else {
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
updateCheckMessage.value = 'Check failed';
|
||||
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Update check failed:', e)
|
||||
updateCheckMessage.value = 'Check failed'
|
||||
setTimeout(() => updateCheckMessage.value = msg, 3000)
|
||||
console.error('Update check failed:', e);
|
||||
updateCheckMessage.value = 'Check failed';
|
||||
setTimeout(() => (updateCheckMessage.value = msg), 3000);
|
||||
} finally {
|
||||
checkingUpdates.value = false
|
||||
checkingUpdates.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreferredColorScheme = (scheme: string) => {
|
||||
if (!scheme || scheme === 'auto') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
for (let s = 0; s < document.styleSheets.length; s++) {
|
||||
const styleSheet = document.styleSheets[s]
|
||||
const styleSheet = document.styleSheets[s];
|
||||
if (!styleSheet?.cssRules) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
let rules: CSSRuleList
|
||||
let rules: CSSRuleList;
|
||||
try {
|
||||
rules = styleSheet.cssRules
|
||||
rules = styleSheet.cssRules;
|
||||
} catch (e) {
|
||||
// Cross-origin stylesheet
|
||||
console.debug("Unable to access stylesheet rules:", e)
|
||||
continue
|
||||
console.debug('Unable to access stylesheet rules:', e);
|
||||
continue;
|
||||
}
|
||||
|
||||
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")) {
|
||||
const media = rule.media
|
||||
if (rule instanceof CSSMediaRule && rule.media.mediaText.includes('prefers-color-scheme')) {
|
||||
const media = rule.media;
|
||||
|
||||
const safeDelete = (medium: string) => {
|
||||
if (media.mediaText.includes(medium)) {
|
||||
try {
|
||||
media.deleteMedium(medium)
|
||||
media.deleteMedium(medium);
|
||||
} catch (e) {
|
||||
console.debug(`Failed to delete medium "${medium}"`, e)
|
||||
console.debug(`Failed to delete medium "${medium}"`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
switch (scheme) {
|
||||
case "light":
|
||||
if (!media.mediaText.includes("original-prefers-color-scheme")) {
|
||||
media.appendMedium("original-prefers-color-scheme")
|
||||
case 'light':
|
||||
if (!media.mediaText.includes('original-prefers-color-scheme')) {
|
||||
media.appendMedium('original-prefers-color-scheme');
|
||||
}
|
||||
safeDelete("(prefers-color-scheme: light)")
|
||||
safeDelete("(prefers-color-scheme: dark)")
|
||||
break
|
||||
safeDelete('(prefers-color-scheme: light)');
|
||||
safeDelete('(prefers-color-scheme: dark)');
|
||||
break;
|
||||
|
||||
case "dark":
|
||||
if (!media.mediaText.includes("(prefers-color-scheme: light)")) {
|
||||
media.appendMedium("(prefers-color-scheme: light)")
|
||||
case 'dark':
|
||||
if (!media.mediaText.includes('(prefers-color-scheme: light)')) {
|
||||
media.appendMedium('(prefers-color-scheme: light)');
|
||||
}
|
||||
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
|
||||
media.appendMedium("(prefers-color-scheme: dark)")
|
||||
if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
|
||||
media.appendMedium('(prefers-color-scheme: dark)');
|
||||
}
|
||||
safeDelete("original-prefers-color-scheme")
|
||||
break
|
||||
safeDelete('original-prefers-color-scheme');
|
||||
break;
|
||||
|
||||
default:
|
||||
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) {
|
||||
media.appendMedium("(prefers-color-scheme: dark)")
|
||||
if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
|
||||
media.appendMedium('(prefers-color-scheme: dark)');
|
||||
}
|
||||
safeDelete("(prefers-color-scheme: light)")
|
||||
safeDelete("original-prefers-color-scheme")
|
||||
break
|
||||
safeDelete('(prefers-color-scheme: light)');
|
||||
safeDelete('original-prefers-color-scheme');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Error modifying media rule:", e)
|
||||
console.debug('Error modifying media rule:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
applyPreferredColorScheme(selectedTheme.value)
|
||||
} catch { }
|
||||
applyPreferredColorScheme(selectedTheme.value);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await config.loadConfig()
|
||||
} catch { }
|
||||
await config.loadConfig();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const opts = await request('/api/yt-dlp/options')
|
||||
const opts = await request('/api/yt-dlp/options');
|
||||
if (opts.ok) {
|
||||
config.ytdlp_options = await opts.json() as Array<YTDLPOption>
|
||||
config.ytdlp_options = (await opts.json()) as Array<YTDLPOption>;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
socket.connect()
|
||||
} catch { }
|
||||
socket.connect();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await handleImage(bg_enable.value)
|
||||
} catch { }
|
||||
})
|
||||
await handleImage(bg_enable.value);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
watch(selectedTheme, value => {
|
||||
watch(selectedTheme, (value) => {
|
||||
try {
|
||||
if ('auto' === value) {
|
||||
applyPreferredColorScheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
return
|
||||
applyPreferredColorScheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||
);
|
||||
return;
|
||||
}
|
||||
applyPreferredColorScheme(value)
|
||||
} catch { }
|
||||
})
|
||||
applyPreferredColorScheme(value);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
watch(bg_enable, async v => await handleImage(v))
|
||||
watch(bg_opacity, v => {
|
||||
watch(bg_enable, async (v) => await handleImage(v));
|
||||
watch(bg_opacity, (v) => {
|
||||
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) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.documentElement
|
||||
const body = document.querySelector('body')
|
||||
const html = document.documentElement;
|
||||
const body = document.querySelector('body');
|
||||
|
||||
const style = {
|
||||
"background-color": "unset",
|
||||
"display": 'block',
|
||||
"min-height": '100%',
|
||||
"min-width": '100%',
|
||||
"background-image": `url(${loadedImage.value})`,
|
||||
} as any
|
||||
'background-color': 'unset',
|
||||
display: 'block',
|
||||
'min-height': '100%',
|
||||
'min-width': '100%',
|
||||
'background-image': `url(${loadedImage.value})`,
|
||||
} as any;
|
||||
|
||||
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim())
|
||||
html.classList.add('bg-fanart')
|
||||
body?.setAttribute("style", `opacity: ${bg_opacity.value}`)
|
||||
})
|
||||
html.setAttribute(
|
||||
'style',
|
||||
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) => {
|
||||
if (false === enabled) {
|
||||
if (!loadedImage.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const html = document.documentElement
|
||||
const body = document.querySelector('body')
|
||||
const html = document.documentElement;
|
||||
const body = document.querySelector('body');
|
||||
|
||||
if (html.getAttribute("style")) {
|
||||
html.removeAttribute("style")
|
||||
if (html.getAttribute('style')) {
|
||||
html.removeAttribute('style');
|
||||
}
|
||||
|
||||
if (body?.getAttribute("style")) {
|
||||
body.removeAttribute("style")
|
||||
if (body?.getAttribute('style')) {
|
||||
body.removeAttribute('style');
|
||||
}
|
||||
|
||||
loadedImage.value = ''
|
||||
return
|
||||
loadedImage.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedImage.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await loadImage()
|
||||
}
|
||||
await loadImage();
|
||||
};
|
||||
|
||||
const loadImage = async (force = false) => {
|
||||
if (loadingImage.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingImage.value = true
|
||||
loadingImage.value = true;
|
||||
|
||||
let url = '/api/random/background'
|
||||
let url = '/api/random/background';
|
||||
if (force) {
|
||||
url += '?force=true'
|
||||
url += '?force=true';
|
||||
}
|
||||
|
||||
const imgRequest = await request(url)
|
||||
const imgRequest = await request(url);
|
||||
if (200 !== imgRequest.status) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
loadedImage.value = URL.createObjectURL(await imgRequest.blob())
|
||||
loadedImage.value = URL.createObjectURL(await imgRequest.blob());
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(e);
|
||||
} finally {
|
||||
loadingImage.value = false
|
||||
loadingImage.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => {
|
||||
showMenu.value = false
|
||||
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
||||
showMenu.value = false;
|
||||
document.querySelectorAll('div.has-dropdown').forEach((el) => el.classList.remove('is-active'));
|
||||
if (callback) {
|
||||
callback()
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const useVersionUpdate = () => {
|
||||
const newVersionIsAvailable = ref(false)
|
||||
const nuxtApp = useNuxtApp()
|
||||
const newVersionIsAvailable = ref(false);
|
||||
const nuxtApp = useNuxtApp();
|
||||
nuxtApp.hooks.addHooks({
|
||||
'app:manifest:update': () => {
|
||||
newVersionIsAvailable.value = true
|
||||
}
|
||||
newVersionIsAvailable.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
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 { alertDialog, confirmDialog: cDialog } = useDialog()
|
||||
const { alertDialog, confirmDialog: cDialog } = useDialog();
|
||||
|
||||
if (false === config.app.is_native) {
|
||||
await alertDialog({
|
||||
title: 'Shutdown Unavailable',
|
||||
message: 'The shutdown feature is only available when running as native application.',
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = await cDialog({
|
||||
title: 'Shutdown Application',
|
||||
message: 'Are you sure you want to shutdown the application?',
|
||||
})
|
||||
});
|
||||
|
||||
if (false === status) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/system/shutdown', { method: 'POST' })
|
||||
const resp = await fetch('/api/system/shutdown', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json()
|
||||
const body = await resp.json();
|
||||
await alertDialog({
|
||||
title: 'Shutdown Failed',
|
||||
message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`,
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
app_shutdown.value = true
|
||||
await nextTick()
|
||||
app_shutdown.value = true;
|
||||
await nextTick();
|
||||
} catch (e: any) {
|
||||
await alertDialog({
|
||||
title: 'Shutdown Failed',
|
||||
message: `Failed to shutdown the application: ${e.message || e}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
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(() => {
|
||||
switch (socket.connectionStatus) {
|
||||
case 'connected':
|
||||
return 'has-text-success'
|
||||
return 'has-text-success';
|
||||
case 'connecting':
|
||||
return 'has-text-warning'
|
||||
return 'has-text-warning';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'has-text-danger'
|
||||
return 'has-text-danger';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
|
||||
</script>
|
||||
|
|
@ -669,7 +782,9 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
|
|||
<style>
|
||||
#main_container,
|
||||
.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,
|
||||
|
|
@ -678,7 +793,6 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
|
|||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
||||
#main_container.settings-open,
|
||||
.basic-wrapper.settings-open {
|
||||
transform: translateX(0);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import 'assets/css/bulma.css'
|
||||
import 'assets/css/style.css'
|
||||
import 'assets/css/all.css'
|
||||
import 'assets/css/bulma.css';
|
||||
import 'assets/css/style.css';
|
||||
import 'assets/css/all.css';
|
||||
</script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
hr {
|
||||
background-color: unset;
|
||||
border-bottom: 1px solid var(--bulma-grey-light) !important
|
||||
border-bottom: 1px solid var(--bulma-grey-light) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -25,8 +25,13 @@ hr {
|
|||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter changelog entries">
|
||||
<input
|
||||
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>
|
||||
</p>
|
||||
|
||||
|
|
@ -40,7 +45,9 @@ hr {
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
|
@ -60,32 +67,40 @@ hr {
|
|||
<div class="content p-0 m-0">
|
||||
<h1 class="is-4">
|
||||
<span class="icon"><i class="fas fa-code-branch" /></span>
|
||||
{{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
||||
{{ log.tag }}
|
||||
<span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
|
||||
<template v-if="log.date">
|
||||
<span style="font-size:0.5em;">
|
||||
- <span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
||||
<span style="font-size: 0.5em">
|
||||
-
|
||||
<span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
|
||||
{{ moment(log.date).fromNow() }}
|
||||
</span></span>
|
||||
</span></span
|
||||
>
|
||||
</template>
|
||||
</h1>
|
||||
<hr>
|
||||
<hr />
|
||||
<ul>
|
||||
<li v-for="commit in log.commits" :key="commit.sha">
|
||||
<strong>
|
||||
{{ ucFirst(commit.message).replace(/\.$/, "") }}.
|
||||
</strong> -
|
||||
<strong> {{ ucFirst(commit.message).replace(/\.$/, '') }}. </strong> -
|
||||
<small>
|
||||
<NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank">
|
||||
<span class="has-tooltip" v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`">
|
||||
<span
|
||||
class="has-tooltip"
|
||||
v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`"
|
||||
>
|
||||
{{ moment(commit.date).fromNow() }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
<hr v-if="index < logs.length - 1">
|
||||
<hr v-if="index < logs.length - 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,9 +108,18 @@ hr {
|
|||
|
||||
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
|
||||
<div class="column is-12">
|
||||
<Message title="No Results" 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>
|
||||
<Message
|
||||
title="No Results"
|
||||
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>
|
||||
</Message>
|
||||
</div>
|
||||
|
|
@ -105,99 +129,105 @@ hr {
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import type { changelogs, changeset } from '~/types/changelogs'
|
||||
import moment from 'moment';
|
||||
import type { changelogs, changeset } from '~/types/changelogs';
|
||||
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
useHead({ title: 'CHANGELOG' })
|
||||
useHead({ title: 'CHANGELOG' });
|
||||
|
||||
const PROJECT = 'ytptube'
|
||||
const REPO = `https://github.com/arabcoders/${PROJECT}`
|
||||
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`
|
||||
const PROJECT = 'ytptube';
|
||||
const REPO = `https://github.com/arabcoders/${PROJECT}`;
|
||||
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;
|
||||
|
||||
const logs = ref<changelogs>([])
|
||||
const app_version = ref('')
|
||||
const app_branch = ref('')
|
||||
const app_sha = ref('')
|
||||
const isLoading = ref(true)
|
||||
const query = ref<string>('')
|
||||
const toggleFilter = ref<boolean>(false)
|
||||
const logs = ref<changelogs>([]);
|
||||
const app_version = ref('');
|
||||
const app_branch = ref('');
|
||||
const app_sha = ref('');
|
||||
const isLoading = ref(true);
|
||||
const query = ref<string>('');
|
||||
const toggleFilter = ref<boolean>(false);
|
||||
|
||||
watch(toggleFilter, () => {
|
||||
if (!toggleFilter.value) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const filteredLogs = computed<changelogs>(() => {
|
||||
const q = query.value?.toLowerCase()
|
||||
if (!q) return logs.value
|
||||
const q = query.value?.toLowerCase();
|
||||
if (!q) return logs.value;
|
||||
|
||||
return logs.value.map(log => {
|
||||
const tagMatches = log.tag.toLowerCase().includes(q)
|
||||
return logs.value
|
||||
.map((log) => {
|
||||
const tagMatches = log.tag.toLowerCase().includes(q);
|
||||
|
||||
const filteredCommits = log.commits?.filter(commit =>
|
||||
commit.message.toLowerCase().includes(q) ||
|
||||
commit.author.toLowerCase().includes(q) ||
|
||||
commit.full_sha.toLowerCase().includes(q)
|
||||
) ?? []
|
||||
const filteredCommits =
|
||||
log.commits?.filter(
|
||||
(commit) =>
|
||||
commit.message.toLowerCase().includes(q) ||
|
||||
commit.author.toLowerCase().includes(q) ||
|
||||
commit.full_sha.toLowerCase().includes(q),
|
||||
) ?? [];
|
||||
|
||||
if (tagMatches || filteredCommits.length > 0) {
|
||||
return { ...log, commits: tagMatches ? log.commits : filteredCommits }
|
||||
}
|
||||
if (tagMatches || filteredCommits.length > 0) {
|
||||
return { ...log, commits: tagMatches ? log.commits : filteredCommits };
|
||||
}
|
||||
|
||||
return null
|
||||
}).filter((log): log is changeset => log !== null)
|
||||
})
|
||||
return null;
|
||||
})
|
||||
.filter((log): log is changeset => log !== null);
|
||||
});
|
||||
|
||||
const loadContent = async () => {
|
||||
if ('' === app_version.value || logs.value.length > 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
const changes = await fetch(REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value))
|
||||
logs.value = await changes.json()
|
||||
const changes = await fetch(
|
||||
REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
|
||||
);
|
||||
logs.value = await changes.json();
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json()
|
||||
console.error(e);
|
||||
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
logs.value = logs.value.slice(0, 10)
|
||||
await nextTick();
|
||||
logs.value = logs.value.slice(0, 10);
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to fetch changelog. ${e.message}`)
|
||||
console.error(e);
|
||||
toast.error(`Failed to fetch changelog. ${e.message}`);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isInstalled = (log: changeset) => {
|
||||
const installed = String(app_sha.value)
|
||||
const installed = String(app_sha.value);
|
||||
|
||||
if (log.full_sha.startsWith(installed)) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const commit of log?.commits ?? []) {
|
||||
if (commit.full_sha.startsWith(installed)) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await awaiter(config.isLoaded)
|
||||
app_branch.value = config.app.app_branch
|
||||
app_version.value = config.app.app_version
|
||||
app_sha.value = config.app.app_commit_sha
|
||||
loadContent()
|
||||
})
|
||||
await awaiter(config.isLoaded);
|
||||
app_branch.value = config.app.app_branch;
|
||||
app_version.value = config.app.app_version;
|
||||
app_sha.value = config.app.app_commit_sha;
|
||||
loadContent();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
@ -17,8 +19,13 @@
|
|||
<div class="is-pulled-right" v-if="!toggleForm">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<input
|
||||
type="search"
|
||||
v-model.lazy="query"
|
||||
class="input"
|
||||
id="filter"
|
||||
placeholder="Filter displayed content"
|
||||
/>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</p>
|
||||
|
||||
|
|
@ -30,25 +37,44 @@
|
|||
</p>
|
||||
|
||||
<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 v-if="!isMobile">New Condition</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-table': display_style !== 'list',
|
||||
'fa-table-list': display_style === 'list',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="async () => await loadContent(page)"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
|
||||
<button
|
||||
class="button is-info"
|
||||
@click="async () => await loadContent(page)"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading"
|
||||
v-if="items && items.length > 0"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
|
|
@ -63,21 +89,40 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
||||
<Pager
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="
|
||||
async (newPage) => {
|
||||
page = newPage;
|
||||
await loadContent(newPage);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="(item as Condition)"
|
||||
@cancel="resetForm(true)" @submit="updateItem" />
|
||||
<ConditionForm
|
||||
:addInProgress="conditions.addInProgress.value"
|
||||
:reference="itemRef"
|
||||
:item="item as Condition"
|
||||
@cancel="resetForm(true)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</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="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="80%">
|
||||
|
|
@ -97,11 +142,21 @@
|
|||
{{ cond.name }}
|
||||
</div>
|
||||
<div class="is-unselectable">
|
||||
<span class="icon-text is-clickable" @click="toggleEnabled(cond)"
|
||||
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'">
|
||||
<span
|
||||
class="icon-text is-clickable"
|
||||
@click="toggleEnabled(cond)"
|
||||
v-tooltip="
|
||||
'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'
|
||||
"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-power-off"
|
||||
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" />
|
||||
<i
|
||||
class="fa-solid fa-power-off"
|
||||
:class="{
|
||||
'has-text-success': cond.enabled !== false,
|
||||
'has-text-danger': cond.enabled === false,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||
</span>
|
||||
|
|
@ -147,19 +202,28 @@
|
|||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<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 v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
|
|
@ -185,13 +249,22 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="control" @click="toggleEnabled(cond)">
|
||||
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||
<span
|
||||
class="icon"
|
||||
:class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="
|
||||
`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
||||
"
|
||||
>
|
||||
<i class="fa-solid fa-power-off" />
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -208,16 +281,25 @@
|
|||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>{{ cond.cli }}</span>
|
||||
</p>
|
||||
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
||||
<p
|
||||
class="is-text-overflow"
|
||||
v-if="cond.extras && Object.keys(cond.extras).length > 0"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-list" /></span>
|
||||
<span>Extras:
|
||||
<span
|
||||
>Extras:
|
||||
<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>
|
||||
</p>
|
||||
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
||||
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
|
||||
<p
|
||||
class="is-clickable"
|
||||
:class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
||||
v-if="cond.description"
|
||||
@click="toggleExpand(cond.id, 'description')"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
||||
<span>{{ cond.description }}</span>
|
||||
</p>
|
||||
|
|
@ -242,44 +324,65 @@
|
|||
</template>
|
||||
</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">
|
||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||
Loading data. Please wait...
|
||||
</Message>
|
||||
<Message title="No Results" 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>
|
||||
<Message
|
||||
title="No Results"
|
||||
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>
|
||||
</Message>
|
||||
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
|
||||
There are no custom defined conditions yet. Click the <span class="icon"><i class="fas fa-add" /></span>
|
||||
<strong>New Condition</strong> button to add your first condition.
|
||||
There are no custom defined conditions yet. Click the
|
||||
<span class="icon"><i class="fas fa-add" /></span> <strong>New Condition</strong> button
|
||||
to add your first condition.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
<Message class="is-info" :body_class="'pl-0'">
|
||||
<ul>
|
||||
<li>Filtering is based on yt-dlp’s <code>--match-filter</code> logic. Any expression that works with
|
||||
yt-dlp will also work here, including the same boolean operators. We added 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>
|
||||
Filtering is based on yt-dlp’s <code>--match-filter</code> logic. Any expression that
|
||||
works with yt-dlp will also work here, including the same boolean operators. We added
|
||||
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>
|
||||
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>
|
||||
For example, i follow specific channel that sometimes region lock some videos, by using the following
|
||||
filter i am able to bypass it <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.
|
||||
For example, i follow specific channel that sometimes region lock some videos, by
|
||||
using the following filter i am able to bypass it
|
||||
<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>
|
||||
The data which the filter is applied on is the same data that yt-dlp returns, simply, click on the
|
||||
information button, and check the data to craft your filter. You will get instant feedback if the
|
||||
filter matches or not.
|
||||
The data which the filter is applied on is the same data that yt-dlp returns, simply,
|
||||
click on the information button, and check the data to craft your filter. You will get
|
||||
instant feedback if the filter matches or not.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
|
|
@ -289,32 +392,32 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { Condition } from '~/types/conditions'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useConditions } from '~/composables/useConditions'
|
||||
import type { APIResponse } from '~/types/responses'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { Condition } from '~/types/conditions';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useConditions } from '~/composables/useConditions';
|
||||
import type { APIResponse } from '~/types/responses';
|
||||
|
||||
type ConditionItemWithUI = Condition & { raw?: boolean }
|
||||
type ConditionItemWithUI = Condition & { raw?: boolean };
|
||||
|
||||
const box = useConfirm()
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid')
|
||||
const conditions = useConditions()
|
||||
const route = useRoute()
|
||||
const box = useConfirm();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
|
||||
const conditions = useConditions();
|
||||
const route = useRoute();
|
||||
|
||||
const items = conditions.conditions as Ref<ConditionItemWithUI[]>
|
||||
const paging = conditions.pagination
|
||||
const isLoading = conditions.isLoading
|
||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1)
|
||||
const item = ref<Partial<Condition>>({})
|
||||
const itemRef = ref<number | null | undefined>(null)
|
||||
const toggleForm = ref(false)
|
||||
const query = ref<string>('')
|
||||
const toggleFilter = ref(false)
|
||||
const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
|
||||
const paging = conditions.pagination;
|
||||
const isLoading = conditions.isLoading;
|
||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
||||
const item = ref<Partial<Condition>>({});
|
||||
const itemRef = ref<number | null | undefined>(null);
|
||||
const toggleForm = ref(false);
|
||||
const query = ref<string>('');
|
||||
const toggleFilter = ref(false);
|
||||
|
||||
const remove_keys = ['raw', 'toggle_description']
|
||||
const expandedItems = reactive<Record<number, Set<string>>>({})
|
||||
const remove_keys = ['raw', 'toggle_description'];
|
||||
const expandedItems = reactive<Record<number, Set<string>>>({});
|
||||
|
||||
const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
||||
const q = query.value?.toLowerCase();
|
||||
|
|
@ -323,87 +426,95 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
|
|||
});
|
||||
|
||||
const loadContent = async (page: number = 1): Promise<void> => {
|
||||
await conditions.loadConditions(page)
|
||||
await nextTick()
|
||||
await conditions.loadConditions(page);
|
||||
await nextTick();
|
||||
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) => {
|
||||
if (!itemId) return
|
||||
if (!itemId) return;
|
||||
|
||||
if (!expandedItems[itemId]) {
|
||||
expandedItems[itemId] = new Set()
|
||||
expandedItems[itemId] = new Set();
|
||||
}
|
||||
|
||||
if (expandedItems[itemId].has(field)) {
|
||||
expandedItems[itemId].delete(field)
|
||||
expandedItems[itemId].delete(field);
|
||||
} else {
|
||||
expandedItems[itemId].add(field)
|
||||
expandedItems[itemId].add(field);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isExpanded = (itemId: number | undefined, field: string): boolean => {
|
||||
if (!itemId) return false
|
||||
return expandedItems[itemId]?.has(field) ?? false
|
||||
}
|
||||
if (!itemId) return false;
|
||||
return expandedItems[itemId]?.has(field) ?? false;
|
||||
};
|
||||
|
||||
watch(toggleFilter, val => {
|
||||
watch(toggleFilter, (val) => {
|
||||
if (!val) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const resetForm = (closeForm = false): void => {
|
||||
item.value = {}
|
||||
itemRef.value = null
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
toggleForm.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (cond: Condition): Promise<void> => {
|
||||
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 }: {
|
||||
reference: number | null | undefined,
|
||||
item: Condition
|
||||
const updateItem = async ({
|
||||
reference,
|
||||
item: updatedItem,
|
||||
}: {
|
||||
reference: number | null | undefined;
|
||||
item: Condition;
|
||||
}): Promise<void> => {
|
||||
updatedItem = cleanObject(updatedItem, remove_keys) as Condition
|
||||
updatedItem = cleanObject(updatedItem, remove_keys) as Condition;
|
||||
const cb = (resp: APIResponse) => {
|
||||
if (resp.success) {
|
||||
resetForm(true)
|
||||
resetForm(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (reference) {
|
||||
await conditions.patchCondition(reference, updatedItem, cb)
|
||||
await conditions.patchCondition(reference, updatedItem, cb);
|
||||
} else {
|
||||
await conditions.createCondition(updatedItem, cb)
|
||||
await conditions.createCondition(updatedItem, cb);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = (_item: Condition): void => {
|
||||
item.value = JSON.parse(JSON.stringify(_item)) as Condition
|
||||
itemRef.value = _item.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
item.value = JSON.parse(JSON.stringify(_item)) as Condition;
|
||||
itemRef.value = _item.id;
|
||||
toggleForm.value = true;
|
||||
};
|
||||
|
||||
const toggleEnabled = async (cond: Condition): Promise<void> => {
|
||||
const new_state = !cond.enabled
|
||||
await conditions.patchCondition(cond.id!, { enabled: new_state })
|
||||
}
|
||||
const new_state = !cond.enabled;
|
||||
await conditions.patchCondition(cond.id!, { enabled: new_state });
|
||||
};
|
||||
|
||||
const exportItem = (cond: Condition): void => copyText(encode({
|
||||
...Object.fromEntries(Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k))),
|
||||
_type: 'condition',
|
||||
_version: '1.2',
|
||||
}))
|
||||
const exportItem = (cond: Condition): void =>
|
||||
copyText(
|
||||
encode({
|
||||
...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>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
</span>
|
||||
</h1>
|
||||
<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
|
||||
it's settings.
|
||||
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing
|
||||
the web interface and it's settings.
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
|
|
@ -43,22 +43,45 @@
|
|||
</p>
|
||||
</header>
|
||||
<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 class="card-content p-1 m-1">
|
||||
<div class="field is-grouped">
|
||||
<div class="control is-expanded">
|
||||
<TextareaAutocomplete v-if="isMultiLineInput" ref="commandTextarea" v-model="command"
|
||||
: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" />
|
||||
<TextareaAutocomplete
|
||||
v-if="isMultiLineInput"
|
||||
ref="commandTextarea"
|
||||
v-model="command"
|
||||
: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>
|
||||
<p class="control">
|
||||
<button class="button is-primary" type="button" :disabled="isLoading || !hasValidCommand"
|
||||
@click="runCommand">
|
||||
<button
|
||||
class="button is-primary"
|
||||
type="button"
|
||||
:disabled="isLoading || !hasValidCommand"
|
||||
@click="runCommand"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'" />
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
|
|
@ -70,7 +93,10 @@
|
|||
<div class="column is-12">
|
||||
<div class="card">
|
||||
<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="ml-2">Commands History</span>
|
||||
</p>
|
||||
|
|
@ -78,9 +104,15 @@
|
|||
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
|
||||
<i class="fa-solid fa-trash" />
|
||||
</span>
|
||||
<span v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'" class="icon ml-2"
|
||||
@click="isHistoryCollapsed = !isHistoryCollapsed">
|
||||
<i class="fa-solid" :class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'" />
|
||||
<span
|
||||
v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'"
|
||||
class="icon ml-2"
|
||||
@click="isHistoryCollapsed = !isHistoryCollapsed"
|
||||
>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
</header>
|
||||
|
|
@ -89,16 +121,21 @@
|
|||
Commands history is empty.
|
||||
</Message>
|
||||
<div class="table-container" v-if="commandHistory.length > 0">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed"
|
||||
>
|
||||
<tbody>
|
||||
<tr v-for="(cmd, index) in commandHistory" :key="index">
|
||||
<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, ' ') }}
|
||||
</code>
|
||||
</td>
|
||||
<td style="width: 40px; text-align: center;">
|
||||
<td style="width: 40px; text-align: center">
|
||||
<button class="delete" @click="removeFromHistory(index)" />
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -112,154 +149,166 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils'
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
import { useDialog } from '~/composables/useDialog'
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils';
|
||||
import InputAutocomplete from '~/components/InputAutocomplete.vue';
|
||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||
|
||||
const config = useConfigStore()
|
||||
const toast = useNotification()
|
||||
const dialog = useDialog()
|
||||
const config = useConfigStore();
|
||||
const toast = useNotification();
|
||||
const dialog = useDialog();
|
||||
|
||||
const terminal = ref<Terminal>()
|
||||
const terminalFit = ref<FitAddon>()
|
||||
const command = ref<string>('')
|
||||
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
|
||||
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null)
|
||||
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null)
|
||||
const isLoading = ref<boolean>(false)
|
||||
const storedCommand = useStorage<string>('console_command', '')
|
||||
const commandHistory = useStorage<string[]>('console_command_history', [])
|
||||
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false)
|
||||
const sseController = ref<AbortController | null>(null)
|
||||
const MAX_HISTORY_ITEMS = 50
|
||||
const terminal = ref<Terminal>();
|
||||
const terminalFit = ref<FitAddon>();
|
||||
const command = ref<string>('');
|
||||
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
|
||||
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
|
||||
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
|
||||
const isLoading = ref<boolean>(false);
|
||||
const storedCommand = useStorage<string>('console_command', '');
|
||||
const commandHistory = useStorage<string[]>('console_command_history', []);
|
||||
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false);
|
||||
const sseController = ref<AbortController | null>(null);
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
|
||||
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
|
||||
.map(flag => ({ value: flag, description: opt.description || '' }))
|
||||
))
|
||||
const ytDlpOptions = computed<AutoCompleteOptions>(() =>
|
||||
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 isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'))
|
||||
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0);
|
||||
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'));
|
||||
|
||||
watch(() => isLoading.value, async value => {
|
||||
if (value) {
|
||||
return
|
||||
}
|
||||
if (command.value.trim()) {
|
||||
addToHistory(command.value.trim())
|
||||
}
|
||||
command.value = ''
|
||||
await nextTick();
|
||||
focusInput()
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
() => isLoading.value,
|
||||
async (value) => {
|
||||
if (value) {
|
||||
return;
|
||||
}
|
||||
if (command.value.trim()) {
|
||||
addToHistory(command.value.trim());
|
||||
}
|
||||
command.value = '';
|
||||
await nextTick();
|
||||
focusInput();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(() => config.app.console_enabled, async () => {
|
||||
if (config.app.console_enabled) {
|
||||
return
|
||||
}
|
||||
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
|
||||
await navigateTo('/')
|
||||
})
|
||||
watch(
|
||||
() => config.app.console_enabled,
|
||||
async () => {
|
||||
if (config.app.console_enabled) {
|
||||
return;
|
||||
}
|
||||
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 target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const isTextarea = 'TEXTAREA' === target.tagName
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
const isTextarea = 'TEXTAREA' === target.tagName;
|
||||
|
||||
if (event.key !== 'Enter') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
|
||||
event.preventDefault()
|
||||
runCommand()
|
||||
return
|
||||
event.preventDefault();
|
||||
runCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && !isTextarea) {
|
||||
event.preventDefault()
|
||||
const cursorPos = target.selectionStart || command.value.length
|
||||
command.value = command.value.substring(0, cursorPos) + '\n' + command.value.substring(target.selectionEnd || cursorPos)
|
||||
await nextTick()
|
||||
event.preventDefault();
|
||||
const cursorPos = target.selectionStart || command.value.length;
|
||||
command.value =
|
||||
command.value.substring(0, cursorPos) +
|
||||
'\n' +
|
||||
command.value.substring(target.selectionEnd || cursorPos);
|
||||
await nextTick();
|
||||
if (commandTextarea.value) {
|
||||
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement
|
||||
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||
if (textarea) {
|
||||
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1)
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent): Promise<void> => {
|
||||
const pastedText = event.clipboardData?.getData('text') || ''
|
||||
const pastedText = event.clipboardData?.getData('text') || '';
|
||||
if (!pastedText.includes('\n')) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const target = event.target as HTMLInputElement
|
||||
const currentValue = command.value || ''
|
||||
const start = target.selectionStart || currentValue.length
|
||||
const end = target.selectionEnd || currentValue.length
|
||||
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end)
|
||||
await nextTick()
|
||||
event.preventDefault();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const currentValue = command.value || '';
|
||||
const start = target.selectionStart || currentValue.length;
|
||||
const end = target.selectionEnd || currentValue.length;
|
||||
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end);
|
||||
await nextTick();
|
||||
|
||||
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) {
|
||||
const newPos = start + pastedText.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
const newPos = start + pastedText.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle_event = () => {
|
||||
if (!terminal.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
terminalFit.value?.fit()
|
||||
}
|
||||
terminalFit.value?.fit();
|
||||
};
|
||||
|
||||
const handleStreamMessage = (event: EventSourceMessage) => {
|
||||
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) {
|
||||
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 {
|
||||
payload = null
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ('output' === event.event) {
|
||||
terminal.value.writeln(payload?.line ?? '')
|
||||
return
|
||||
terminal.value.writeln(payload?.line ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('close' === event.event) {
|
||||
isLoading.value = false
|
||||
sseController.value?.abort()
|
||||
isLoading.value = false;
|
||||
sseController.value?.abort();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startStream = async (cmd: string) => {
|
||||
sseController.value?.abort()
|
||||
const controller = new AbortController()
|
||||
sseController.value = controller
|
||||
isLoading.value = true
|
||||
sseController.value?.abort();
|
||||
const controller = new AbortController();
|
||||
sseController.value = controller;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await fetchEventSource(uri('/api/system/terminal'), {
|
||||
|
|
@ -267,53 +316,53 @@ const startStream = async (cmd: string) => {
|
|||
body: JSON.stringify({ command: cmd }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
onopen: async (response) => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
let message = response.statusText || 'Failed to start command stream.'
|
||||
let message = response.statusText || 'Failed to start command stream.';
|
||||
try {
|
||||
message = await parse_api_error(response.clone().json())
|
||||
message = await parse_api_error(response.clone().json());
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.text()
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
message = text
|
||||
message = text;
|
||||
}
|
||||
} 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,
|
||||
onerror: (error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
terminal.value?.writeln(`Error: ${error}`)
|
||||
isLoading.value = false
|
||||
terminal.value?.writeln(`Error: ${error}`);
|
||||
isLoading.value = false;
|
||||
},
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
terminal.value?.writeln(`Error: ${error}`)
|
||||
isLoading.value = false
|
||||
terminal.value?.writeln(`Error: ${error}`);
|
||||
isLoading.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (controller === sseController.value) {
|
||||
sseController.value = null
|
||||
sseController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTerminal = async () => {
|
||||
if (terminal.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.value = new Terminal({
|
||||
|
|
@ -325,124 +374,128 @@ const ensureTerminal = async () => {
|
|||
rows: 10,
|
||||
disableStdin: true,
|
||||
scrollback: 1000,
|
||||
})
|
||||
});
|
||||
|
||||
terminalFit.value = new FitAddon()
|
||||
terminal.value.loadAddon(terminalFit.value)
|
||||
terminalFit.value = new FitAddon();
|
||||
terminal.value.loadAddon(terminalFit.value);
|
||||
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
|
||||
if (terminal_window.value) {
|
||||
terminal.value.open(terminal_window.value)
|
||||
terminal.value.open(terminal_window.value);
|
||||
}
|
||||
|
||||
terminalFit.value.fit();
|
||||
}
|
||||
};
|
||||
|
||||
const runCommand = async () => {
|
||||
if (!hasValidCommand.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (true !== config.app.console_enabled) {
|
||||
await navigateTo('/')
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd = command.value.trim().replace(/\n/g, ' ').trim()
|
||||
let cmd = command.value.trim().replace(/\n/g, ' ').trim();
|
||||
|
||||
if (cmd.startsWith('yt-dlp')) {
|
||||
cmd = cmd.replace(/^yt-dlp/, '').trim()
|
||||
await nextTick()
|
||||
cmd = cmd.replace(/^yt-dlp/, '').trim();
|
||||
await nextTick();
|
||||
if ('' === cmd) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ensureTerminal()
|
||||
await ensureTerminal();
|
||||
|
||||
if ('clear' === cmd) {
|
||||
clearOutput(true)
|
||||
return
|
||||
clearOutput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await startStream(cmd)
|
||||
terminal.value?.writeln(`user@YTPTube ~`)
|
||||
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
|
||||
storedCommand.value = ''
|
||||
}
|
||||
await startStream(cmd);
|
||||
terminal.value?.writeln(`user@YTPTube ~`);
|
||||
terminal.value?.writeln(`$ yt-dlp ${command.value}`);
|
||||
storedCommand.value = '';
|
||||
};
|
||||
|
||||
const clearOutput = async (withCommand: boolean = false) => {
|
||||
if (terminal.value) {
|
||||
terminal.value.clear()
|
||||
terminal.value.clear();
|
||||
}
|
||||
|
||||
if (true === withCommand) {
|
||||
command.value = ''
|
||||
command.value = '';
|
||||
}
|
||||
|
||||
focusInput()
|
||||
}
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const focusInput = async () => {
|
||||
await nextTick()
|
||||
await nextTick();
|
||||
let elm;
|
||||
if (isMultiLineInput.value) {
|
||||
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement
|
||||
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
|
||||
} 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) => {
|
||||
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) => {
|
||||
command.value = cmd
|
||||
await nextTick()
|
||||
focusInput()
|
||||
}
|
||||
command.value = cmd;
|
||||
await nextTick();
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (commandHistory.value.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const { status } = await dialog.confirmDialog({
|
||||
title: 'Confirm Action',
|
||||
message: `Clear commands history?`,
|
||||
confirmColor: 'is-danger',
|
||||
})
|
||||
});
|
||||
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 () => {
|
||||
document.addEventListener('resize', handle_event);
|
||||
focusInput()
|
||||
focusInput();
|
||||
|
||||
disableOpacity()
|
||||
disableOpacity();
|
||||
|
||||
await ensureTerminal()
|
||||
await ensureTerminal();
|
||||
|
||||
if (storedCommand.value) {
|
||||
command.value = storedCommand.value
|
||||
await nextTick()
|
||||
command.value = storedCommand.value;
|
||||
await nextTick();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sseController.value?.abort()
|
||||
document.removeEventListener('resize', handle_event)
|
||||
enableOpacity()
|
||||
sseController.value?.abort();
|
||||
document.removeEventListener('resize', handle_event);
|
||||
enableOpacity();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
@ -17,8 +19,13 @@
|
|||
<div class="is-pulled-right" v-if="!toggleForm">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<input
|
||||
type="search"
|
||||
v-model.lazy="query"
|
||||
class="input"
|
||||
id="filter"
|
||||
placeholder="Filter displayed content"
|
||||
/>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</p>
|
||||
|
||||
|
|
@ -30,25 +37,44 @@
|
|||
</p>
|
||||
|
||||
<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 v-if="!isMobile">New Field</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-table': display_style !== 'list',
|
||||
'fa-table-list': display_style === 'list',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="async () => await loadContent(page)"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
|
||||
<button
|
||||
class="button is-info"
|
||||
@click="async () => await loadContent(page)"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading"
|
||||
v-if="items && items.length > 0"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
|
|
@ -63,21 +89,40 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
||||
<Pager
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="
|
||||
async (newPage) => {
|
||||
page = newPage;
|
||||
await loadContent(newPage);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<DLFieldForm :addInProgress="dlFields.addInProgress.value" :reference="itemRef" :item="(item as DLField)"
|
||||
@cancel="resetForm(true)" @submit="updateItem" />
|
||||
<DLFieldForm
|
||||
:addInProgress="dlFields.addInProgress.value"
|
||||
:reference="itemRef"
|
||||
:item="item as DLField"
|
||||
@cancel="resetForm(true)"
|
||||
@submit="updateItem"
|
||||
/>
|
||||
</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="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="80%">
|
||||
|
|
@ -111,19 +156,28 @@
|
|||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<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 v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
|
|
@ -149,7 +203,11 @@
|
|||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -187,19 +245,32 @@
|
|||
</template>
|
||||
</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">
|
||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||
Loading data. Please wait...
|
||||
</Message>
|
||||
<Message title="No Results" 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>
|
||||
<Message
|
||||
title="No Results"
|
||||
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>
|
||||
</Message>
|
||||
<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>
|
||||
<strong>New Field</strong> button to add your first field.
|
||||
There are no custom defined fields yet. Click the
|
||||
<span class="icon"><i class="fas fa-add" /></span> <strong>New Field</strong> button to
|
||||
add your first field.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -207,92 +278,98 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { DLField } from '~/types/dl_fields'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useDlFields } from '~/composables/useDlFields'
|
||||
import type { APIResponse } from '~/types/responses'
|
||||
import { copyText, encode } from '~/utils'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDlFields } from '~/composables/useDlFields';
|
||||
import type { APIResponse } from '~/types/responses';
|
||||
import { copyText, encode } from '~/utils';
|
||||
|
||||
const box = useConfirm()
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid')
|
||||
const dlFields = useDlFields()
|
||||
const route = useRoute()
|
||||
const box = useConfirm();
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
|
||||
const dlFields = useDlFields();
|
||||
const route = useRoute();
|
||||
|
||||
const items = dlFields.dlFields as Ref<DLField[]>
|
||||
const paging = dlFields.pagination
|
||||
const isLoading = dlFields.isLoading
|
||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1)
|
||||
const item = ref<Partial<DLField>>({})
|
||||
const itemRef = ref<number | null | undefined>(null)
|
||||
const toggleForm = ref(false)
|
||||
const query = ref<string>('')
|
||||
const toggleFilter = ref(false)
|
||||
const items = dlFields.dlFields as Ref<DLField[]>;
|
||||
const paging = dlFields.pagination;
|
||||
const isLoading = dlFields.isLoading;
|
||||
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
|
||||
const item = ref<Partial<DLField>>({});
|
||||
const itemRef = ref<number | null | undefined>(null);
|
||||
const toggleForm = ref(false);
|
||||
const query = ref<string>('');
|
||||
const toggleFilter = ref(false);
|
||||
|
||||
const filteredItems = computed<DLField[]>(() => {
|
||||
const q = query.value?.toLowerCase()
|
||||
if (!q) return items.value
|
||||
return items.value.filter(entry => deepIncludes(entry, q, new WeakSet()))
|
||||
})
|
||||
const q = query.value?.toLowerCase();
|
||||
if (!q) return items.value;
|
||||
return items.value.filter((entry) => deepIncludes(entry, q, new WeakSet()));
|
||||
});
|
||||
|
||||
const loadContent = async (pageNumber: number = 1): Promise<void> => {
|
||||
await dlFields.loadDlFields(pageNumber)
|
||||
await nextTick()
|
||||
await dlFields.loadDlFields(pageNumber);
|
||||
await nextTick();
|
||||
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) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const resetForm = (closeForm = false): void => {
|
||||
item.value = {}
|
||||
itemRef.value = null
|
||||
item.value = {};
|
||||
itemRef.value = null;
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
toggleForm.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (field: DLField): Promise<void> => {
|
||||
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 }: {
|
||||
reference: number | null | undefined,
|
||||
item: DLField
|
||||
const updateItem = async ({
|
||||
reference,
|
||||
item: updatedItem,
|
||||
}: {
|
||||
reference: number | null | undefined;
|
||||
item: DLField;
|
||||
}): Promise<void> => {
|
||||
const cb = (resp: APIResponse) => {
|
||||
if (resp.success) {
|
||||
resetForm(true)
|
||||
resetForm(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (reference) {
|
||||
await dlFields.patchDlField(reference, updatedItem, cb)
|
||||
await dlFields.patchDlField(reference, updatedItem, cb);
|
||||
} else {
|
||||
await dlFields.createDlField(updatedItem, cb)
|
||||
await dlFields.createDlField(updatedItem, cb);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = (field: DLField): void => {
|
||||
item.value = { ...field }
|
||||
itemRef.value = field.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
item.value = { ...field };
|
||||
itemRef.value = field.id;
|
||||
toggleForm.value = true;
|
||||
};
|
||||
|
||||
const exportItem = (field: DLField): void => copyText(encode({
|
||||
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
|
||||
_type: 'dl_field',
|
||||
_version: '1.0',
|
||||
}))
|
||||
const exportItem = (field: DLField): void =>
|
||||
copyText(
|
||||
encode({
|
||||
...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>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@
|
|||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<input
|
||||
type="search"
|
||||
v-model.lazy="query"
|
||||
class="input"
|
||||
id="filter"
|
||||
placeholder="Filter displayed content"
|
||||
/>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</p>
|
||||
|
||||
|
|
@ -25,28 +30,49 @@
|
|||
</p>
|
||||
|
||||
<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 v-if="!isMobile">Pause</span>
|
||||
</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 v-if="!isMobile">Resume</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<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 v-if="!isMobile">Add</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => changeDisplay()">
|
||||
<span class="icon"><i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@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">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
|
|
@ -55,15 +81,19 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobile">
|
||||
<span class="subtitle">
|
||||
Queued and completed downloads are displayed here.
|
||||
</span>
|
||||
<span class="subtitle"> Queued and completed downloads are displayed here. </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewDownload v-if="config.showForm" :item="item_form" @clear_form="item_form = {}"
|
||||
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" />
|
||||
<NewDownload
|
||||
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">
|
||||
<ul>
|
||||
|
|
@ -86,132 +116,170 @@
|
|||
|
||||
<div class="tab-content">
|
||||
<div v-show="'queue' === activeTab">
|
||||
<Queue @getInfo="(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 = ''" />
|
||||
<Queue
|
||||
@getInfo="
|
||||
(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 v-show="'history' === activeTab">
|
||||
<History @getInfo="(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 = ''" />
|
||||
<History
|
||||
@getInfo="
|
||||
(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>
|
||||
|
||||
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
|
||||
:useUrl="info_view.useUrl" @closeModel="close_info()" />
|
||||
<GetInfo
|
||||
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"
|
||||
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||
@cancel="() => dialog_confirm.visible = false" />
|
||||
<ConfirmDialog
|
||||
v-if="dialog_confirm.visible"
|
||||
:visible="dialog_confirm.visible"
|
||||
:title="dialog_confirm.title"
|
||||
:html_message="dialog_confirm.html_message"
|
||||
:options="dialog_confirm.options"
|
||||
@confirm="dialog_confirm.confirm"
|
||||
@cancel="() => (dialog_confirm.visible = false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { item_request } from '~/types/item';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
|
||||
const config = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const config = useConfigStore();
|
||||
const stateStore = useStateStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||
const display_style = useStorage<string>('display_style', 'grid')
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||
const display_style = useStorage<string>('display_style', 'grid');
|
||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
const info_view = ref({
|
||||
url: '',
|
||||
preset: '',
|
||||
cli: '',
|
||||
useUrl: false,
|
||||
}) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }>
|
||||
const item_form = ref<item_request | object>({})
|
||||
const query = ref()
|
||||
const toggleFilter = ref(false)
|
||||
}) as Ref<{ url: string; preset: string; cli: string; useUrl: boolean }>;
|
||||
const item_form = ref<item_request | object>({});
|
||||
const query = ref();
|
||||
const toggleFilter = ref(false);
|
||||
const dialog_confirm = ref({
|
||||
visible: false,
|
||||
title: 'Confirm Action',
|
||||
confirm: () => { },
|
||||
confirm: () => {},
|
||||
html_message: '',
|
||||
options: [],
|
||||
})
|
||||
});
|
||||
|
||||
// Tab management with URL state
|
||||
type TabType = 'queue' | 'history'
|
||||
type TabType = 'queue' | 'history';
|
||||
|
||||
const activeTab = ref<TabType>('queue')
|
||||
const activeTab = ref<TabType>('queue');
|
||||
|
||||
const getInitialTab = (): TabType => {
|
||||
const tabParam = route.query.tab as string
|
||||
return (tabParam === 'queue' || tabParam === 'history') ? tabParam : 'queue'
|
||||
}
|
||||
const tabParam = route.query.tab as string;
|
||||
return tabParam === 'queue' || tabParam === 'history' ? tabParam : 'queue';
|
||||
};
|
||||
|
||||
const setActiveTab = async (tab: TabType): Promise<void> => {
|
||||
activeTab.value = tab
|
||||
await router.push({ query: { ...route.query, tab }, replace: true })
|
||||
}
|
||||
activeTab.value = tab;
|
||||
await router.push({ query: { ...route.query, tab }, replace: true });
|
||||
};
|
||||
|
||||
watch(() => route.query.tab, (newTab) => {
|
||||
if (!['queue', 'history'].includes(newTab as string)) {
|
||||
return
|
||||
}
|
||||
activeTab.value = newTab as TabType
|
||||
})
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(newTab) => {
|
||||
if (!['queue', 'history'].includes(newTab as string)) {
|
||||
return;
|
||||
}
|
||||
activeTab.value = newTab as TabType;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const route = useRoute()
|
||||
const route = useRoute();
|
||||
|
||||
if (route.query?.simple !== undefined) {
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
|
||||
await nextTick()
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('simple')
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
||||
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
|
||||
await nextTick();
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('simple');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
activeTab.value = getInitialTab()
|
||||
useHead({ title: getTitle() })
|
||||
})
|
||||
activeTab.value = getInitialTab();
|
||||
useHead({ title: getTitle() });
|
||||
});
|
||||
|
||||
const queueCount = computed(() => stateStore.count('queue'))
|
||||
const historyCount = computed(() => stateStore.count('history'))
|
||||
const queueCount = computed(() => stateStore.count('queue'));
|
||||
const historyCount = computed(() => stateStore.count('history'));
|
||||
|
||||
watch(toggleFilter, () => {
|
||||
if (!toggleFilter.value) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const getTitle = (): string => {
|
||||
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, () => {
|
||||
if (!config.app.ui_update_title) {
|
||||
return
|
||||
}
|
||||
useHead({ title: getTitle() })
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => stateStore.history,
|
||||
() => {
|
||||
if (!config.app.ui_update_title) {
|
||||
return;
|
||||
}
|
||||
useHead({ title: getTitle() });
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(() => stateStore.queue, () => {
|
||||
if (!config.app.ui_update_title) {
|
||||
return
|
||||
}
|
||||
useHead({ title: getTitle() })
|
||||
}, { deep: true })
|
||||
watch(
|
||||
() => stateStore.queue,
|
||||
() => {
|
||||
if (!config.app.ui_update_title) {
|
||||
return;
|
||||
}
|
||||
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 = () => {
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.visible = true;
|
||||
dialog_confirm.value.html_message = `
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
|
||||
|
|
@ -223,49 +291,53 @@ const pauseDownload = () => {
|
|||
<li>This will not stop downloads that are currently in progress.</li>
|
||||
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
|
||||
</ul>
|
||||
</span>`
|
||||
</span>`;
|
||||
dialog_confirm.value.confirm = async () => {
|
||||
await request('/api/system/pause', { method: 'POST' })
|
||||
dialog_confirm.value.visible = false
|
||||
}
|
||||
}
|
||||
await request('/api/system/pause', { method: 'POST' });
|
||||
dialog_confirm.value.visible = false;
|
||||
};
|
||||
};
|
||||
|
||||
const close_info = () => {
|
||||
info_view.value.url = ''
|
||||
info_view.value.preset = ''
|
||||
info_view.value.useUrl = false
|
||||
}
|
||||
info_view.value.url = '';
|
||||
info_view.value.preset = '';
|
||||
info_view.value.useUrl = false;
|
||||
};
|
||||
|
||||
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
|
||||
info_view.value.url = url
|
||||
info_view.value.useUrl = useUrl
|
||||
info_view.value.preset = preset
|
||||
info_view.value.cli = cli
|
||||
}
|
||||
info_view.value.url = url;
|
||||
info_view.value.useUrl = useUrl;
|
||||
info_view.value.preset = preset;
|
||||
info_view.value.cli = cli;
|
||||
};
|
||||
|
||||
watch(() => info_view.value.url, v => {
|
||||
if (!bg_enable.value) {
|
||||
return
|
||||
}
|
||||
watch(
|
||||
() => info_view.value.url,
|
||||
(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>) => {
|
||||
if (!item) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.showForm) {
|
||||
config.showForm = false
|
||||
await nextTick()
|
||||
config.showForm = false;
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
item_form.value = item
|
||||
item_form.value = item;
|
||||
|
||||
await nextTick()
|
||||
config.showForm = true
|
||||
}
|
||||
await nextTick();
|
||||
config.showForm = true;
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,19 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
#logView>span:nth-child(even) {
|
||||
#logView > span:nth-child(even) {
|
||||
color: #ffc9d4;
|
||||
}
|
||||
|
||||
#logView>span:nth-child(odd) {
|
||||
#logView > span:nth-child(odd) {
|
||||
color: #e3c981;
|
||||
}
|
||||
|
||||
.logbox {
|
||||
background-color: #1f2229 !important;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
box-shadow:
|
||||
0 4px 8px 0 rgba(0, 0, 0, 0.2),
|
||||
0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
min-width: 100%;
|
||||
max-height: 73vh;
|
||||
overflow-y: scroll;
|
||||
|
|
@ -52,8 +54,13 @@ code {
|
|||
</div>
|
||||
|
||||
<div class="control has-icons-left" v-if="toggleFilter || query">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -63,9 +70,12 @@ code {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
</button>
|
||||
</p>
|
||||
|
|
@ -73,35 +83,50 @@ code {
|
|||
</div>
|
||||
|
||||
<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 class="column is-12">
|
||||
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
|
||||
<code id="logView" class="p-2 logline is-block"
|
||||
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }">
|
||||
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
|
||||
<code
|
||||
id="logView"
|
||||
class="p-2 logline is-block"
|
||||
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"
|
||||
>
|
||||
<span
|
||||
class="is-block m-0 notification is-info is-dark has-text-centered"
|
||||
v-if="reachedEnd && !query"
|
||||
>
|
||||
<span class="notification-title">
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
|
||||
No more logs available for this file.
|
||||
</span>
|
||||
</span>
|
||||
<span v-for="log in filteredItems" :key="log.id" class="is-block">
|
||||
<template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template>
|
||||
{{ log.line }}
|
||||
</span>
|
||||
<span class="is-block" v-if="filteredItems.length < 1">
|
||||
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
<template v-if="log?.datetime"
|
||||
>[<span class="has-tooltip" :title="log.datetime">{{
|
||||
moment(log.datetime).format('HH:mm:ss')
|
||||
}}</span
|
||||
>]</template
|
||||
>
|
||||
{{ log.line }}
|
||||
</span>
|
||||
<span class="is-block" v-if="filteredItems.length < 1">
|
||||
<span
|
||||
class="is-block m-0 notification is-warning is-dark has-text-centered"
|
||||
v-if="query"
|
||||
>
|
||||
<span class="notification-title is-danger">
|
||||
<span class="icon"><i class="fas fa-filter" /></span>
|
||||
No logs match this query: <u>{{ query }}</u>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else> <span class="has-text-danger">No logs available</span></span>
|
||||
</span>
|
||||
</code>
|
||||
<div ref="bottomMarker"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -110,263 +135,274 @@ code {
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
|
||||
import moment from 'moment'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { log_line } from '~/types/logs'
|
||||
import { parse_api_error, uri } from '~/utils'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
import type { EventSourceMessage } from '@microsoft/fetch-event-source';
|
||||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { log_line } from '~/types/logs';
|
||||
import { parse_api_error, uri } from '~/utils';
|
||||
|
||||
let scrollTimeout: NodeJS.Timeout | null = null
|
||||
let scrollTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const route = useRoute()
|
||||
const toast = useNotification();
|
||||
const config = useConfigStore();
|
||||
const route = useRoute();
|
||||
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer')
|
||||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
|
||||
const textWrap = useStorage<boolean>('logs_wrap', true)
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||
const sseController = ref<AbortController | null>(null)
|
||||
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
|
||||
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
|
||||
const textWrap = useStorage<boolean>('logs_wrap', true);
|
||||
const bg_enable = useStorage<boolean>('random_bg', true);
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
||||
const sseController = ref<AbortController | null>(null);
|
||||
|
||||
const logs = ref<Array<log_line>>([])
|
||||
const offset = ref<number>(0)
|
||||
const loading = ref<boolean>(false)
|
||||
const autoScroll = ref<boolean>(true)
|
||||
const reachedEnd = ref<boolean>(false)
|
||||
const logs = ref<Array<log_line>>([]);
|
||||
const offset = ref<number>(0);
|
||||
const loading = ref<boolean>(false);
|
||||
const autoScroll = ref<boolean>(true);
|
||||
const reachedEnd = ref<boolean>(false);
|
||||
|
||||
const query = ref<string>((() => {
|
||||
const filter = route.query.filter ?? ''
|
||||
if (!filter) {
|
||||
return ''
|
||||
}
|
||||
if (typeof filter === 'string') {
|
||||
return filter.trim()
|
||||
}
|
||||
if (Array.isArray(filter) && filter.length > 0) {
|
||||
return filter[0]?.trim() ?? ''
|
||||
}
|
||||
return ''
|
||||
})())
|
||||
const query = ref<string>(
|
||||
(() => {
|
||||
const filter = route.query.filter ?? '';
|
||||
if (!filter) {
|
||||
return '';
|
||||
}
|
||||
if (typeof filter === 'string') {
|
||||
return filter.trim();
|
||||
}
|
||||
if (Array.isArray(filter) && filter.length > 0) {
|
||||
return filter[0]?.trim() ?? '';
|
||||
}
|
||||
return '';
|
||||
})(),
|
||||
);
|
||||
|
||||
const toggleFilter = ref(false)
|
||||
const toggleFilter = ref(false);
|
||||
watch(toggleFilter, () => {
|
||||
if (!toggleFilter.value) {
|
||||
query.value = ''
|
||||
scrollToBottom(true)
|
||||
query.value = '';
|
||||
scrollToBottom(true);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => config.app.file_logging, async v => {
|
||||
if (v) {
|
||||
return
|
||||
}
|
||||
await navigateTo('/')
|
||||
})
|
||||
watch(
|
||||
() => config.app.file_logging,
|
||||
async (v) => {
|
||||
if (v) {
|
||||
return;
|
||||
}
|
||||
await navigateTo('/');
|
||||
},
|
||||
);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const raw = query.value.trim().toLowerCase()
|
||||
const contextMatch = raw.match(/context:(\d+)/)
|
||||
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0
|
||||
const searchTerm = raw.replace(/context:\d+/, '').trim()
|
||||
const raw = query.value.trim().toLowerCase();
|
||||
const contextMatch = raw.match(/context:(\d+)/);
|
||||
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0;
|
||||
const searchTerm = raw.replace(/context:\d+/, '').trim();
|
||||
|
||||
if (!searchTerm) {
|
||||
return logs.value
|
||||
return logs.value;
|
||||
}
|
||||
|
||||
const result: Array<log_line> = []
|
||||
const matchedIndexes = new Set()
|
||||
const result: Array<log_line> = [];
|
||||
const matchedIndexes = new Set();
|
||||
|
||||
logs.value.forEach((log, i) => {
|
||||
if (log.line.toLowerCase().includes(searchTerm)) {
|
||||
for (let j = Math.max(0, i - context); j <= Math.min(logs.value.length - 1, i + context); j++) {
|
||||
matchedIndexes.add(j)
|
||||
for (
|
||||
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) => {
|
||||
result.push(logs.value[index] as log_line)
|
||||
})
|
||||
Array.from(matchedIndexes)
|
||||
.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 () => {
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
|
||||
if (reachedEnd.value || query.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const req = await request(`/api/logs?offset=${offset.value}`)
|
||||
const req = await request(`/api/logs?offset=${offset.value}`);
|
||||
if (!req.ok) {
|
||||
toast.error('Failed to fetch logs');
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await req.json()
|
||||
const response = await req.json();
|
||||
if (response.error) {
|
||||
toast.error(response.error)
|
||||
return
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = response.logs ?? [];
|
||||
|
||||
if (lines.length) {
|
||||
logs.value.unshift(...response.logs)
|
||||
logs.value.unshift(...response.logs);
|
||||
}
|
||||
|
||||
if (response?.next_offset) {
|
||||
offset.value = response.next_offset
|
||||
offset.value = response.next_offset;
|
||||
}
|
||||
|
||||
if (response?.end_is_reached) {
|
||||
reachedEnd.value = true
|
||||
reachedEnd.value = true;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (autoScroll.value && bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'auto' })
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch logs:', err)
|
||||
console.error('Failed to fetch logs:', err);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!logContainer.value || query.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
const container = logContainer.value
|
||||
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50
|
||||
const nearTop = container.scrollTop < 50
|
||||
const container = logContainer.value;
|
||||
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
|
||||
const nearTop = container.scrollTop < 50;
|
||||
|
||||
autoScroll.value = nearBottom
|
||||
autoScroll.value = nearBottom;
|
||||
|
||||
if (nearTop && !loading.value && !scrollTimeout) {
|
||||
scrollTimeout = setTimeout(async () => {
|
||||
const previousHeight = container.scrollHeight
|
||||
await fetchLogs()
|
||||
const previousHeight = container.scrollHeight;
|
||||
await fetchLogs();
|
||||
nextTick(() => {
|
||||
const newHeight = container.scrollHeight
|
||||
container.scrollTop += newHeight - previousHeight
|
||||
})
|
||||
scrollTimeout = null
|
||||
}, 300)
|
||||
const newHeight = container.scrollHeight;
|
||||
container.scrollTop += newHeight - previousHeight;
|
||||
});
|
||||
scrollTimeout = null;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = (fast = false) => {
|
||||
autoScroll.value = true
|
||||
autoScroll.value = true;
|
||||
nextTick(() => {
|
||||
if (bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' })
|
||||
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStreamMessage = (event: EventSourceMessage) => {
|
||||
if ('log_lines' !== event.event) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.data) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: log_line | null = null
|
||||
let payload: log_line | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data) as log_line
|
||||
payload = JSON.parse(event.data) as log_line;
|
||||
} catch {
|
||||
payload = null
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
logs.value.push(payload)
|
||||
logs.value.push(payload);
|
||||
|
||||
nextTick(() => {
|
||||
if (autoScroll.value && bottomMarker.value) {
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
|
||||
bottomMarker.value.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const startLogStream = async () => {
|
||||
sseController.value?.abort()
|
||||
const controller = new AbortController()
|
||||
sseController.value = controller
|
||||
sseController.value?.abort();
|
||||
const controller = new AbortController();
|
||||
sseController.value = controller;
|
||||
|
||||
try {
|
||||
await fetchEventSource(uri('/api/logs/stream'), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
onopen: async (response) => {
|
||||
if (response.ok) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
let message = response.statusText || 'Failed to start log stream.'
|
||||
let message = response.statusText || 'Failed to start log stream.';
|
||||
try {
|
||||
message = await parse_api_error(response.clone().json())
|
||||
message = await parse_api_error(response.clone().json());
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.text()
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
message = text
|
||||
message = text;
|
||||
}
|
||||
} 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,
|
||||
onerror: (error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
console.error('Log stream error:', error)
|
||||
console.error('Log stream error:', error);
|
||||
},
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error('Log stream error:', error)
|
||||
console.error('Log stream error:', error);
|
||||
}
|
||||
} finally {
|
||||
if (controller === sseController.value) {
|
||||
sseController.value = null
|
||||
sseController.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchLogs()
|
||||
await startLogStream()
|
||||
await fetchLogs();
|
||||
await startLogStream();
|
||||
if (bg_enable.value) {
|
||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
||||
document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sseController.value?.abort()
|
||||
sseController.value?.abort();
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
@ -16,9 +18,17 @@
|
|||
</span>
|
||||
<div class="is-pulled-right" v-if="!toggleForm">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter && notifications && notifications.length > 0">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<p
|
||||
class="control has-icons-left"
|
||||
v-if="toggleFilter && notifications && notifications.length > 0"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
v-model.lazy="query"
|
||||
class="input"
|
||||
id="filter"
|
||||
placeholder="Filter displayed content"
|
||||
/>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</p>
|
||||
|
||||
|
|
@ -30,25 +40,44 @@
|
|||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
|
||||
v-tooltip="'Add new notification target.'">
|
||||
<button
|
||||
class="button is-primary"
|
||||
@click="
|
||||
resetForm(false);
|
||||
toggleForm = true;
|
||||
"
|
||||
v-tooltip="'Add new notification target.'"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-add" /></span>
|
||||
<span v-if="!isMobile">New Notification</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control" v-if="notifications.length > 0">
|
||||
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
|
||||
:class="{ 'is-loading': sendingTest }" :disabled="!notifications.length || sendingTest">
|
||||
<button
|
||||
class="button is-warning"
|
||||
@click="sendTest"
|
||||
v-tooltip="'Send test notification.'"
|
||||
:class="{ 'is-loading': sendingTest }"
|
||||
:disabled="!notifications.length || sendingTest"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-paper-plane" /></span>
|
||||
<span v-if="!isMobile">Send Test</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control" v-if="notifications.length > 0">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-table': display_style !== 'list',
|
||||
'fa-table-list': display_style === 'list',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
|
|
@ -56,8 +85,12 @@
|
|||
</p>
|
||||
|
||||
<p class="control" v-if="notifications.length > 0">
|
||||
<button class="button is-info" @click="loadContent(page)" :class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading || notifications.length < 1">
|
||||
<button
|
||||
class="button is-info"
|
||||
@click="loadContent(page)"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading || notifications.length < 1"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
|
|
@ -72,22 +105,42 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
|
||||
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
|
||||
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
|
||||
<Pager
|
||||
:page="paging.page"
|
||||
:last_page="paging.total_pages"
|
||||
:isLoading="isLoading"
|
||||
@navigate="
|
||||
async (newPage) => {
|
||||
page = newPage;
|
||||
await loadContent(newPage);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<NotificationForm :addInProgress="addInProgress" :reference="targetRef" :item="target"
|
||||
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
|
||||
<NotificationForm
|
||||
:addInProgress="addInProgress"
|
||||
:reference="targetRef"
|
||||
:item="target"
|
||||
@cancel="resetForm(true)"
|
||||
@submit="updateItem"
|
||||
:allowedEvents="allowedEvents"
|
||||
/>
|
||||
</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">
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="80%">
|
||||
|
|
@ -119,8 +172,13 @@
|
|||
</span>
|
||||
|
||||
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
||||
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||
<span
|
||||
class="icon"
|
||||
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="
|
||||
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
||||
"
|
||||
>
|
||||
<i class="fa-solid fa-power-off" />
|
||||
</span>
|
||||
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
|
|
@ -130,19 +188,28 @@
|
|||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<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 v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
|
|
@ -166,13 +233,22 @@
|
|||
<div class="card-header-icon">
|
||||
<div class="field is-grouped">
|
||||
<div class="control" @click="toggleEnabled(item)">
|
||||
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||
<span
|
||||
class="icon"
|
||||
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="
|
||||
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
|
||||
"
|
||||
>
|
||||
<i class="fa-solid fa-power-off" />
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -191,13 +267,13 @@
|
|||
</p>
|
||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span>
|
||||
<span>Headers: {{ item.request.headers.map((h) => h.key).join(', ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer mt-auto">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-warning is-fullwidth" @click="editItem(item);">
|
||||
<button class="button is-warning is-fullwidth" @click="editItem(item)">
|
||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
|
@ -214,47 +290,65 @@
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline"
|
||||
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)">
|
||||
<div
|
||||
class="columns is-multiline"
|
||||
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"
|
||||
>
|
||||
<div class="column is-12">
|
||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||
Loading data. Please wait...
|
||||
</Message>
|
||||
<Message title="No Results" 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>
|
||||
<Message
|
||||
title="No Results"
|
||||
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>
|
||||
</Message>
|
||||
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
|
||||
No notification targets found. Click on the <span class="icon"><i class="fas fa-add" /></span> <strong>New
|
||||
Notification</strong> button to add your first notification target.
|
||||
No notification targets found. Click on the
|
||||
<span class="icon"><i class="fas fa-add" /></span>
|
||||
<strong>New Notification</strong> button to add your first notification target.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">
|
||||
<Message class="is-info" :body_class="'pl-0'">
|
||||
<ul>
|
||||
<li>
|
||||
When you export notification target, We remove <code>Authorization</code> header key by default,
|
||||
However this might not be enough to remove credentials from the exported data. it's your responsibility
|
||||
to ensure that the exported data does not contain any sensitive information for sharing.
|
||||
When you export notification target, We remove <code>Authorization</code> header key
|
||||
by default, However this might not be enough to remove credentials from the exported
|
||||
data. it's your responsibility to ensure that the exported data does not contain any
|
||||
sensitive information for sharing.
|
||||
</li>
|
||||
<li>
|
||||
When you set the request type as <code>Form</code>, the event data will be JSON encoded and sent as
|
||||
<code>...&data_key=json_string</code>, only the <code>data</code> field will be JSON encoded.
|
||||
The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are.
|
||||
</li>
|
||||
<li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.
|
||||
When you set the request type as <code>Form</code>, the event data will be JSON
|
||||
encoded and sent as <code>...&data_key=json_string</code>, only the
|
||||
<code>data</code> field will be JSON encoded. The other keys <code>id</code>,
|
||||
<code>event</code> and <code>created_at</code> will be sent as they are.
|
||||
</li>
|
||||
<li>
|
||||
If you have selected specific presets or events, this will take priority, For example, 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
|
||||
We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with
|
||||
the request.
|
||||
</li>
|
||||
<li>
|
||||
If you have selected specific presets or events, this will take priority, For example,
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -265,29 +359,29 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { notification } from '~/types/notification'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
import type { ImportedItem } from '~/types'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification } from '~/types/notification';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useNotifications } from '~/composables/useNotifications';
|
||||
import type { ImportedItem } from '~/types';
|
||||
|
||||
const toast = useNotification()
|
||||
const box = useConfirm()
|
||||
const display_style = useStorage<string>('notification_display_style', 'cards')
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const toast = useNotification();
|
||||
const box = useConfirm();
|
||||
const display_style = useStorage<string>('notification_display_style', 'cards');
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
const notificationsStore = useNotifications()
|
||||
const notifications = notificationsStore.notifications
|
||||
const paging = notificationsStore.pagination
|
||||
const allowedEvents = notificationsStore.events
|
||||
const isLoading = notificationsStore.isLoading
|
||||
const addInProgress = notificationsStore.addInProgress
|
||||
const lastError = notificationsStore.lastError
|
||||
const notificationsStore = useNotifications();
|
||||
const notifications = notificationsStore.notifications;
|
||||
const paging = notificationsStore.pagination;
|
||||
const allowedEvents = notificationsStore.events;
|
||||
const isLoading = notificationsStore.isLoading;
|
||||
const addInProgress = notificationsStore.addInProgress;
|
||||
const lastError = notificationsStore.lastError;
|
||||
|
||||
const page = ref(1)
|
||||
const targetRef = ref<number | undefined>(undefined)
|
||||
const toggleForm = ref(false)
|
||||
const sendingTest = ref(false)
|
||||
const page = ref(1);
|
||||
const targetRef = ref<number | undefined>(undefined);
|
||||
const toggleForm = ref(false);
|
||||
const sendingTest = ref(false);
|
||||
|
||||
const defaultState = (): notification => ({
|
||||
name: '',
|
||||
|
|
@ -295,127 +389,137 @@ const defaultState = (): notification => ({
|
|||
presets: [],
|
||||
enabled: true,
|
||||
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
||||
})
|
||||
});
|
||||
|
||||
const target = ref<notification>(defaultState())
|
||||
const query = ref<string>('')
|
||||
const toggleFilter = ref(false)
|
||||
const target = ref<notification>(defaultState());
|
||||
const query = ref<string>('');
|
||||
const toggleFilter = ref(false);
|
||||
|
||||
const filteredTargets = computed<notification[]>(() => {
|
||||
const q = query.value?.toLowerCase()
|
||||
const items = notifications.value.map(item => ({ ...item })) as notification[]
|
||||
if (!q) return items
|
||||
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()))
|
||||
})
|
||||
const q = query.value?.toLowerCase();
|
||||
const items = notifications.value.map((item) => ({ ...item })) as notification[];
|
||||
if (!q) return items;
|
||||
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()));
|
||||
});
|
||||
|
||||
watch(toggleFilter, (val) => {
|
||||
if (!val) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const loadContent = async (pageNumber = page.value) => {
|
||||
await notificationsStore.loadNotifications(pageNumber)
|
||||
}
|
||||
await notificationsStore.loadNotifications(pageNumber);
|
||||
};
|
||||
|
||||
const resetForm = (closeForm = false) => {
|
||||
target.value = defaultState()
|
||||
targetRef.value = undefined
|
||||
target.value = defaultState();
|
||||
targetRef.value = undefined;
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
toggleForm.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item: notification) => {
|
||||
if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.id) {
|
||||
toast.error('Notification target not found.')
|
||||
return
|
||||
toast.error('Notification target not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
await notificationsStore.deleteNotification(item.id)
|
||||
}
|
||||
await notificationsStore.deleteNotification(item.id);
|
||||
};
|
||||
|
||||
const toggleEnabled = async (item: notification) => {
|
||||
if (!item.id) {
|
||||
toast.error('Notification target not found.')
|
||||
return
|
||||
toast.error('Notification target not found.');
|
||||
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) {
|
||||
await notificationsStore.updateNotification(reference, item)
|
||||
await notificationsStore.updateNotification(reference, item);
|
||||
} else {
|
||||
await notificationsStore.createNotification(item)
|
||||
await notificationsStore.createNotification(item);
|
||||
}
|
||||
|
||||
if (!lastError.value) {
|
||||
resetForm(true)
|
||||
resetForm(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = (item: notification) => {
|
||||
target.value = JSON.parse(JSON.stringify(item)) as notification
|
||||
targetRef.value = item.id ?? undefined
|
||||
toggleForm.value = true
|
||||
}
|
||||
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
||||
targetRef.value = item.id ?? undefined;
|
||||
toggleForm.value = true;
|
||||
};
|
||||
|
||||
const join_events = (events: Array<string>) => !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 join_events = (events: Array<string>) =>
|
||||
!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 () => {
|
||||
if (true !== (await box.confirm('Send test notification?'))) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendingTest.value = true
|
||||
const response = await request('/api/notifications/test', { method: 'POST' })
|
||||
sendingTest.value = true;
|
||||
const response = await request('/api/notifications/test', { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
const message = await parse_api_error(data)
|
||||
toast.error(`Failed to send test notification. ${message}`)
|
||||
return
|
||||
const data = await response.json();
|
||||
const message = await parse_api_error(data);
|
||||
toast.error(`Failed to send test notification. ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Test notification sent.')
|
||||
toast.success('Test notification sent.');
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
const message = error?.message || 'Unknown error'
|
||||
toast.error(`Failed to send test notification. ${message}`)
|
||||
console.error(error);
|
||||
const message = error?.message || 'Unknown error';
|
||||
toast.error(`Failed to send test notification. ${message}`);
|
||||
} 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 data: notification & ImportedItem = {
|
||||
...JSON.parse(JSON.stringify(item)),
|
||||
_type: 'notification',
|
||||
_version: '1.0',
|
||||
}
|
||||
};
|
||||
|
||||
const keys = ['id', 'raw']
|
||||
keys.forEach(k => {
|
||||
const keys = ['id', 'raw'];
|
||||
keys.forEach((k) => {
|
||||
if (Object.prototype.hasOwnProperty.call(data, k)) {
|
||||
const { [k]: _, ...rest } = data as any
|
||||
Object.assign(data, rest)
|
||||
const { [k]: _, ...rest } = data as any;
|
||||
Object.assign(data, rest);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
|
@ -17,8 +19,13 @@
|
|||
<div class="is-pulled-right" v-if="!toggleForm">
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
|
||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||
placeholder="Filter displayed content">
|
||||
<input
|
||||
type="search"
|
||||
v-model.lazy="query"
|
||||
class="input"
|
||||
id="filter"
|
||||
placeholder="Filter displayed content"
|
||||
/>
|
||||
<span class="icon is-left"><i class="fas fa-filter" /></span>
|
||||
</p>
|
||||
|
||||
|
|
@ -30,19 +37,33 @@
|
|||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"
|
||||
v-tooltip.bottom="'Toggle add form'">
|
||||
<button
|
||||
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 v-if="!isMobile">New Preset</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-table': display_style !== 'list',
|
||||
'fa-table-list': display_style === 'list',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
|
|
@ -50,8 +71,13 @@
|
|||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||
:disabled="isLoading" v-if="presets && presets.length > 0">
|
||||
<button
|
||||
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 v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
|
|
@ -59,26 +85,39 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile" v-if="!toggleForm">
|
||||
<span class="subtitle">Presets are pre-defined command options for yt-dlp that you want to apply to given
|
||||
download.</span>
|
||||
<span class="subtitle"
|
||||
>Presets are pre-defined command options for yt-dlp that you want to apply to given
|
||||
download.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns" v-if="toggleForm">
|
||||
<div class="column is-12">
|
||||
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true)"
|
||||
@submit="updateItem" :presets="presets" />
|
||||
<PresetForm
|
||||
:addInProgress="addInProgress"
|
||||
:reference="presetRef"
|
||||
:preset="preset"
|
||||
@cancel="resetForm(true)"
|
||||
@submit="updateItem"
|
||||
:presets="presets"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 650px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 650px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="80%">Preset</th>
|
||||
|
|
@ -109,19 +148,28 @@
|
|||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<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 v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
|
|
@ -139,9 +187,13 @@
|
|||
<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">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title is-block is-clickable"
|
||||
: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-title is-block is-clickable"
|
||||
: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="field is-grouped">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -169,34 +225,65 @@
|
|||
<span>Priority: {{ item.priority }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p :class="{ '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'">
|
||||
<p
|
||||
:class="{
|
||||
'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>{{ calcPath(item.folder) }}</span>
|
||||
</p>
|
||||
<p :class="{ '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'">
|
||||
<p
|
||||
:class="{
|
||||
'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>{{ item.template }}</span>
|
||||
</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')"
|
||||
: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>{{ item.cli }}</span>
|
||||
</p>
|
||||
<p :class="{ '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'">
|
||||
<p
|
||||
:class="{
|
||||
'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>{{ item.description }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content content m-1 p-1 is-overflow-auto" style="max-height: 300px;"
|
||||
v-if="item?.toggle_description">
|
||||
<div
|
||||
class="card-content content m-1 p-1 is-overflow-auto"
|
||||
style="max-height: 300px"
|
||||
v-if="item?.toggle_description"
|
||||
>
|
||||
<div class="is-pre-wrap">{{ item.description }}</div>
|
||||
</div>
|
||||
<div class="card-footer mt-auto">
|
||||
|
|
@ -218,15 +305,26 @@
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline"
|
||||
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)">
|
||||
<div
|
||||
class="columns is-multiline"
|
||||
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)"
|
||||
>
|
||||
<div class="column is-12">
|
||||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||
Loading data. Please wait...
|
||||
</Message>
|
||||
<Message title="No Results" 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>
|
||||
<Message
|
||||
title="No Results"
|
||||
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>
|
||||
</Message>
|
||||
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
|
||||
|
|
@ -239,8 +337,8 @@
|
|||
<div class="column is-12">
|
||||
<Message class="is-info">
|
||||
<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
|
||||
reasons.
|
||||
When you <b>export</b> preset, it doesn't include the <code>cookies</code> field
|
||||
contents for security reasons.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,142 +347,145 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { Preset } from '~/types/presets'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { usePresets } from '~/composables/usePresets'
|
||||
import { prettyName } from '~/utils'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { Preset } from '~/types/presets';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { usePresets } from '~/composables/usePresets';
|
||||
import { prettyName } from '~/utils';
|
||||
|
||||
type PresetWithUI = Preset & { raw?: boolean, toggle_description?: boolean }
|
||||
type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
|
||||
|
||||
const presetsStore = usePresets()
|
||||
const config = useConfigStore()
|
||||
const box = useConfirm()
|
||||
const presetsStore = usePresets();
|
||||
const config = useConfigStore();
|
||||
const box = useConfirm();
|
||||
|
||||
const display_style = useStorage<string>('preset_display_style', 'cards')
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
const display_style = useStorage<string>('preset_display_style', 'cards');
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 });
|
||||
|
||||
const query = ref<string>('')
|
||||
const toggleFilter = ref(false)
|
||||
const query = ref<string>('');
|
||||
const toggleFilter = ref(false);
|
||||
|
||||
const presets = presetsStore.presets as Ref<PresetWithUI[]>
|
||||
const preset = ref<Partial<Preset>>({})
|
||||
const presetRef = ref<number | null>(null)
|
||||
const toggleForm = ref(false)
|
||||
const isLoading = presetsStore.isLoading
|
||||
const addInProgress = presetsStore.addInProgress
|
||||
const remove_keys = ['raw', 'toggle_description']
|
||||
const expandedItems = ref<Record<string, Set<string>>>({})
|
||||
const presets = presetsStore.presets as Ref<PresetWithUI[]>;
|
||||
const preset = ref<Partial<Preset>>({});
|
||||
const presetRef = ref<number | null>(null);
|
||||
const toggleForm = ref(false);
|
||||
const isLoading = presetsStore.isLoading;
|
||||
const addInProgress = presetsStore.addInProgress;
|
||||
const remove_keys = ['raw', 'toggle_description'];
|
||||
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 q = query.value?.toLowerCase();
|
||||
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) => {
|
||||
if (itemId === undefined || itemId === null) return
|
||||
const key = String(itemId)
|
||||
if (itemId === undefined || itemId === null) return;
|
||||
const key = String(itemId);
|
||||
|
||||
if (!expandedItems.value[key]) {
|
||||
expandedItems.value[key] = new Set()
|
||||
expandedItems.value[key] = new Set();
|
||||
}
|
||||
|
||||
if (expandedItems.value[key].has(field)) {
|
||||
expandedItems.value[key].delete(field)
|
||||
expandedItems.value[key].delete(field);
|
||||
} else {
|
||||
expandedItems.value[key].add(field)
|
||||
expandedItems.value[key].add(field);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
|
||||
if (itemId === undefined || itemId === null) return false
|
||||
const key = String(itemId)
|
||||
return expandedItems.value[key]?.has(field) ?? false
|
||||
}
|
||||
|
||||
if (itemId === undefined || itemId === null) return false;
|
||||
const key = String(itemId);
|
||||
return expandedItems.value[key]?.has(field) ?? false;
|
||||
};
|
||||
|
||||
watch(toggleFilter, (val) => {
|
||||
if (!val) {
|
||||
query.value = ''
|
||||
query.value = '';
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const reloadContent = async () => {
|
||||
await presetsStore.loadPresets(1, 1000)
|
||||
}
|
||||
await presetsStore.loadPresets(1, 1000);
|
||||
};
|
||||
|
||||
const resetForm = (closeForm = false) => {
|
||||
preset.value = {}
|
||||
presetRef.value = null
|
||||
preset.value = {};
|
||||
presetRef.value = null;
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
toggleForm.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item: Preset) => {
|
||||
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.id) {
|
||||
await presetsStore.deletePreset(item.id)
|
||||
await presetsStore.deletePreset(item.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = async ({
|
||||
reference,
|
||||
preset: item,
|
||||
}: {
|
||||
reference: number | null
|
||||
preset: Preset
|
||||
reference: number | null;
|
||||
preset: Preset;
|
||||
}) => {
|
||||
item = cleanObject(item, remove_keys) as Preset
|
||||
item = cleanObject(item, remove_keys) as Preset;
|
||||
if (reference) {
|
||||
const updated = await presetsStore.updatePreset(reference, item)
|
||||
const updated = await presetsStore.updatePreset(reference, item);
|
||||
if (updated) {
|
||||
resetForm(true)
|
||||
resetForm(true);
|
||||
}
|
||||
} else {
|
||||
const created = await presetsStore.createPreset(item)
|
||||
const created = await presetsStore.createPreset(item);
|
||||
if (created) {
|
||||
resetForm(true)
|
||||
resetForm(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filterItem = (item: Preset) => {
|
||||
const rest = cleanObject(item, remove_keys)
|
||||
const rest = cleanObject(item, remove_keys);
|
||||
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) => {
|
||||
preset.value = JSON.parse(filterItem(item))
|
||||
presetRef.value = item.id ?? null
|
||||
toggleForm.value = true
|
||||
}
|
||||
preset.value = JSON.parse(filterItem(item));
|
||||
presetRef.value = item.id ?? null;
|
||||
toggleForm.value = true;
|
||||
};
|
||||
|
||||
onMounted(async () => await reloadContent())
|
||||
onMounted(async () => await reloadContent());
|
||||
|
||||
const exportItem = (item: Preset) => {
|
||||
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description']
|
||||
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
|
||||
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['_version'] = '2.6'
|
||||
userData['_type'] = 'preset';
|
||||
userData['_version'] = '2.6';
|
||||
|
||||
copyText(encode(userData))
|
||||
}
|
||||
copyText(encode(userData));
|
||||
};
|
||||
|
||||
const calcPath = (path?: string): string => {
|
||||
const loc = config.app.download_path || '/downloads'
|
||||
return path ? loc + '/' + sTrim(path, '/') : loc
|
||||
}
|
||||
const loc = config.app.download_path || '/downloads';
|
||||
return path ? loc + '/' + sTrim(path, '/') : loc;
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template></template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStorage } from '@vueuse/core'
|
||||
const simpleMode = useStorage<boolean>('simple_mode', true)
|
||||
import { useStorage } from '@vueuse/core';
|
||||
const simpleMode = useStorage<boolean>('simple_mode', true);
|
||||
|
||||
onMounted(async () => {
|
||||
simpleMode.value = true
|
||||
await nextTick()
|
||||
await navigateTo('/')
|
||||
})
|
||||
simpleMode.value = true;
|
||||
await nextTick();
|
||||
await navigateTo('/');
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -16,27 +16,37 @@
|
|||
</span>
|
||||
<div class="is-pulled-right" v-if="!isEditorOpen">
|
||||
<div class="field is-grouped">
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()">
|
||||
<button
|
||||
class="button is-primary"
|
||||
@click="isEditorOpen ? closeEditor() : openCreate()"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-add" /></span>
|
||||
<span v-if="!isMobile">New Definition</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button @click="() => inspect = true" class="button is-warning">
|
||||
<button @click="() => (inspect = true)" class="button is-warning">
|
||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||
<span v-if="!isMobile">Inspect</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
|
||||
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
|
||||
<button
|
||||
v-tooltip.bottom="'Change display style'"
|
||||
class="button has-tooltip-bottom"
|
||||
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid"
|
||||
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
|
||||
<i
|
||||
class="fa-solid"
|
||||
:class="{
|
||||
'fa-table': display_style !== 'list',
|
||||
'fa-table-list': display_style === 'list',
|
||||
}"
|
||||
/></span>
|
||||
<span v-if="!isMobile">
|
||||
{{ display_style === 'list' ? 'List' : 'Grid' }}
|
||||
</span>
|
||||
|
|
@ -44,8 +54,11 @@
|
|||
</p>
|
||||
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="async () => await loadDefinitions()"
|
||||
:class="{ 'is-loading': isLoading }">
|
||||
<button
|
||||
class="button is-info"
|
||||
@click="async () => await loadDefinitions()"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="!isMobile">Reload</span>
|
||||
</button>
|
||||
|
|
@ -63,8 +76,10 @@
|
|||
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed;">
|
||||
<table
|
||||
class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||
style="min-width: 850px; table-layout: fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr class="has-text-centered is-unselectable">
|
||||
<th width="40%">Name</th>
|
||||
|
|
@ -76,13 +91,25 @@
|
|||
<tbody>
|
||||
<tr v-for="definition in definitions" :key="definition.id">
|
||||
<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">
|
||||
<span class="icon-text is-clickable" @click="toggle(definition)"
|
||||
v-tooltip="'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'">
|
||||
<span
|
||||
class="icon-text is-clickable"
|
||||
@click="toggle(definition)"
|
||||
v-tooltip="
|
||||
'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'
|
||||
"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-power-off"
|
||||
:class="{ 'has-text-success': definition.enabled, 'has-text-danger': !definition.enabled }" />
|
||||
<i
|
||||
class="fa-solid fa-power-off"
|
||||
:class="{
|
||||
'has-text-success': definition.enabled,
|
||||
'has-text-danger': !definition.enabled,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</span>
|
||||
|
|
@ -90,26 +117,41 @@
|
|||
</td>
|
||||
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
||||
<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-rtime="definition.updated_at" />
|
||||
v-rtime="definition.updated_at"
|
||||
/>
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
<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 v-if="!isMobile">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<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 v-if="!isMobile">Delete</span>
|
||||
</button>
|
||||
|
|
@ -123,7 +165,10 @@
|
|||
</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="card">
|
||||
<header class="card-header">
|
||||
|
|
@ -133,13 +178,22 @@
|
|||
<div class="card-header-icon">
|
||||
<div class="field has-addons">
|
||||
<div class="control" @click="toggle(definition)">
|
||||
<span class="icon" :class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||
<span
|
||||
class="icon"
|
||||
:class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||
v-tooltip="
|
||||
`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`
|
||||
"
|
||||
>
|
||||
<i class="fa-solid fa-power-off" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button 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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -157,10 +211,14 @@
|
|||
<p>
|
||||
<span class="icon-text">
|
||||
<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')"
|
||||
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>
|
||||
</p>
|
||||
|
|
@ -193,47 +251,58 @@
|
|||
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
|
||||
Loading data. Please wait...
|
||||
</Message>
|
||||
<Message v-if="!isLoading && !isEditorOpen" title="No definitions" class="is-warning"
|
||||
icon="fas fa-exclamation-circle">
|
||||
There are no task definitions. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New
|
||||
Definition</strong> button to create your first task definition.
|
||||
<Message
|
||||
v-if="!isLoading && !isEditorOpen"
|
||||
title="No definitions"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns" v-if="isEditorOpen">
|
||||
<div class="column is-12">
|
||||
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition"
|
||||
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading"
|
||||
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor"
|
||||
@import-existing="importExistingDefinition" />
|
||||
<TaskDefinitionEditor
|
||||
:title="editorTitle"
|
||||
:document="workingDefinition"
|
||||
:initial-show-import="'create' === editorMode"
|
||||
:available-definitions="definitions"
|
||||
:loading="editorLoading"
|
||||
:submitting="editorSubmitting"
|
||||
@submit="submitDefinition"
|
||||
@cancel="closeEditor"
|
||||
@import-existing="importExistingDefinition"
|
||||
/>
|
||||
</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 />
|
||||
</Modal>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import moment from 'moment'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import moment from 'moment';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue'
|
||||
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'
|
||||
import { useDialog } from '~/composables/useDialog'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { copyText, encode } from '~/utils'
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery'
|
||||
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue';
|
||||
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useNotification } from '~/composables/useNotification';
|
||||
import { copyText, encode } from '~/utils';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
|
||||
import type {
|
||||
TaskDefinitionDetailed,
|
||||
TaskDefinitionDocument,
|
||||
TaskDefinitionSummary,
|
||||
} from '~/types/task_definitions'
|
||||
} from '~/types/task_definitions';
|
||||
|
||||
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
|
||||
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 definitionsRef = taskDefs.definitions
|
||||
const isLoading = taskDefs.isLoading
|
||||
const loadDefinitions = taskDefs.loadDefinitions
|
||||
const getDefinition = taskDefs.getDefinition
|
||||
const createDefinition = taskDefs.createDefinition
|
||||
const updateDefinition = taskDefs.updateDefinition
|
||||
const deleteDefinition = taskDefs.deleteDefinition
|
||||
const toggleEnabled = taskDefs.toggleEnabled
|
||||
const taskDefs = useTaskDefinitionsComposable();
|
||||
const definitionsRef = taskDefs.definitions;
|
||||
const isLoading = taskDefs.isLoading;
|
||||
const loadDefinitions = taskDefs.loadDefinitions;
|
||||
const getDefinition = taskDefs.getDefinition;
|
||||
const createDefinition = taskDefs.createDefinition;
|
||||
const updateDefinition = taskDefs.updateDefinition;
|
||||
const deleteDefinition = taskDefs.deleteDefinition;
|
||||
const toggleEnabled = taskDefs.toggleEnabled;
|
||||
|
||||
const definitions = computed(() => definitionsRef.value)
|
||||
const definitions = computed(() => definitionsRef.value);
|
||||
|
||||
const { confirmDialog } = useDialog()
|
||||
const toast = useNotification()
|
||||
const { confirmDialog } = useDialog();
|
||||
const toast = useNotification();
|
||||
|
||||
const isEditorOpen = ref<boolean>(false)
|
||||
const editorMode = ref<'create' | 'edit'>('create')
|
||||
const editorLoading = ref<boolean>(false)
|
||||
const editorSubmitting = ref<boolean>(false)
|
||||
const workingDefinition = ref<TaskDefinitionDocument | null>(null)
|
||||
const workingId = ref<number | null>(null)
|
||||
const inspect = ref<boolean>(false)
|
||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid')
|
||||
const isEditorOpen = ref<boolean>(false);
|
||||
const editorMode = ref<'create' | 'edit'>('create');
|
||||
const editorLoading = ref<boolean>(false);
|
||||
const editorSubmitting = ref<boolean>(false);
|
||||
const workingDefinition = ref<TaskDefinitionDocument | null>(null);
|
||||
const workingId = ref<number | null>(null);
|
||||
const inspect = ref<boolean>(false);
|
||||
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
||||
|
||||
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
||||
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>(() => {
|
||||
return 'create' === editorMode.value
|
||||
? 'Create Task Definition'
|
||||
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`
|
||||
})
|
||||
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
|
||||
});
|
||||
|
||||
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
||||
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument
|
||||
}
|
||||
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
|
||||
};
|
||||
|
||||
const openCreate = (): void => {
|
||||
editorMode.value = 'create'
|
||||
workingId.value = null
|
||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION)
|
||||
isEditorOpen.value = true
|
||||
editorLoading.value = false
|
||||
editorSubmitting.value = false
|
||||
}
|
||||
editorMode.value = 'create';
|
||||
workingId.value = null;
|
||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
||||
isEditorOpen.value = true;
|
||||
editorLoading.value = false;
|
||||
editorSubmitting.value = false;
|
||||
};
|
||||
|
||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
editorMode.value = 'edit'
|
||||
workingId.value = summary.id
|
||||
workingDefinition.value = null
|
||||
editorLoading.value = true
|
||||
editorSubmitting.value = false
|
||||
isEditorOpen.value = true
|
||||
editorMode.value = 'edit';
|
||||
workingId.value = summary.id;
|
||||
workingDefinition.value = null;
|
||||
editorLoading.value = true;
|
||||
editorSubmitting.value = false;
|
||||
isEditorOpen.value = true;
|
||||
|
||||
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id)
|
||||
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id);
|
||||
if (!detailed) {
|
||||
isEditorOpen.value = false
|
||||
editorLoading.value = false
|
||||
return
|
||||
isEditorOpen.value = false;
|
||||
editorLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const document: TaskDefinitionDocument = {
|
||||
|
|
@ -328,17 +397,17 @@ const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
|||
enabled: detailed.enabled,
|
||||
match_url: [...detailed.match_url],
|
||||
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
||||
}
|
||||
};
|
||||
|
||||
workingDefinition.value = document
|
||||
editorLoading.value = false
|
||||
}
|
||||
workingDefinition.value = document;
|
||||
editorLoading.value = false;
|
||||
};
|
||||
|
||||
const importExistingDefinition = async (id: number): Promise<void> => {
|
||||
const detailed = await getDefinition(id)
|
||||
const detailed = await getDefinition(id);
|
||||
if (!detailed) {
|
||||
toast.error('Failed to load task definition for import.')
|
||||
return
|
||||
toast.error('Failed to load task definition for import.');
|
||||
return;
|
||||
}
|
||||
|
||||
const document: TaskDefinitionDocument = {
|
||||
|
|
@ -347,89 +416,89 @@ const importExistingDefinition = async (id: number): Promise<void> => {
|
|||
enabled: detailed.enabled,
|
||||
match_url: [...detailed.match_url],
|
||||
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
||||
}
|
||||
};
|
||||
|
||||
editorMode.value = 'create'
|
||||
workingId.value = null
|
||||
workingDefinition.value = document
|
||||
isEditorOpen.value = true
|
||||
editorLoading.value = false
|
||||
}
|
||||
editorMode.value = 'create';
|
||||
workingId.value = null;
|
||||
workingDefinition.value = document;
|
||||
isEditorOpen.value = true;
|
||||
editorLoading.value = false;
|
||||
};
|
||||
|
||||
const closeEditor = (): void => {
|
||||
if (editorSubmitting.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
editorLoading.value = false
|
||||
}
|
||||
isEditorOpen.value = false;
|
||||
workingDefinition.value = null;
|
||||
workingId.value = null;
|
||||
editorLoading.value = false;
|
||||
};
|
||||
|
||||
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
||||
editorSubmitting.value = true
|
||||
editorSubmitting.value = true;
|
||||
try {
|
||||
if ('create' === editorMode.value) {
|
||||
const created = await createDefinition(definition)
|
||||
const created = await createDefinition(definition);
|
||||
if (created) {
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
isEditorOpen.value = false;
|
||||
workingDefinition.value = null;
|
||||
workingId.value = null;
|
||||
}
|
||||
}
|
||||
else if (workingId.value) {
|
||||
const updated = await updateDefinition(workingId.value, definition)
|
||||
} else if (workingId.value) {
|
||||
const updated = await updateDefinition(workingId.value, definition);
|
||||
if (updated) {
|
||||
isEditorOpen.value = false
|
||||
workingDefinition.value = null
|
||||
workingId.value = null
|
||||
isEditorOpen.value = false;
|
||||
workingDefinition.value = null;
|
||||
workingId.value = null;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
editorSubmitting.value = false;
|
||||
}
|
||||
finally {
|
||||
editorSubmitting.value = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||
const result = await confirmDialog({
|
||||
title: 'Delete Task Definition',
|
||||
message: `Are you sure you want to delete "${summary.name || summary.id}"?`,
|
||||
confirmColor: 'is-danger',
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.status) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteDefinition(summary.id)
|
||||
}
|
||||
await deleteDefinition(summary.id);
|
||||
};
|
||||
|
||||
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 detailed = await getDefinition(summary.id)
|
||||
const detailed = await getDefinition(summary.id);
|
||||
if (!detailed) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
return copyText(encode({
|
||||
_type: 'task_definition',
|
||||
_version: '2.0',
|
||||
name: detailed.name,
|
||||
priority: detailed.priority,
|
||||
enabled: detailed.enabled,
|
||||
match_url: detailed.match_url,
|
||||
definition: detailed.definition,
|
||||
}))
|
||||
}
|
||||
return copyText(
|
||||
encode({
|
||||
_type: 'task_definition',
|
||||
_version: '2.0',
|
||||
name: detailed.name,
|
||||
priority: detailed.priority,
|
||||
enabled: detailed.enabled,
|
||||
match_url: detailed.match_url,
|
||||
definition: detailed.definition,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!definitions.value.length) {
|
||||
await loadDefinitions()
|
||||
await loadDefinitions();
|
||||
}
|
||||
})
|
||||
});
|
||||
</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) => {
|
||||
const handler = () => {
|
||||
scrolledToBottom = (el.scrollHeight - el.scrollTop) - el.clientHeight <= 80;
|
||||
scrolledToBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 80;
|
||||
};
|
||||
events_handlers[index] = handler;
|
||||
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 });
|
||||
}
|
||||
};
|
||||
const on_unmounted = (el: HTMLElement) => {
|
||||
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
|
||||
const handler = events_handlers[index];
|
||||
|
|
@ -28,9 +28,9 @@ const on_unmounted = (el: HTMLElement) => {
|
|||
}
|
||||
});
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.directive('autoscroll', {
|
||||
mounted: on_mounted,
|
||||
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 => {
|
||||
if (!arg) {
|
||||
return 60 * 1000
|
||||
return 60 * 1000;
|
||||
}
|
||||
|
||||
const match = arg.match(/^(\d+)([smhd])$/)
|
||||
const match = arg.match(/^(\d+)([smhd])$/);
|
||||
|
||||
if (!match) {
|
||||
return 60 * 1000
|
||||
return 60 * 1000;
|
||||
}
|
||||
|
||||
const [, numStr, unit] = match
|
||||
const num = parseInt(String(numStr), 10)
|
||||
const [, numStr, unit] = match;
|
||||
const num = parseInt(String(numStr), 10);
|
||||
|
||||
switch (unit) {
|
||||
case 'd': return num * 24 * 3600 * 1000
|
||||
case 'h': return num * 3600 * 1000
|
||||
case 'm': return num * 60 * 1000
|
||||
case 's': return num * 1000
|
||||
default: return 60 * 1000
|
||||
case 'd':
|
||||
return num * 24 * 3600 * 1000;
|
||||
case 'h':
|
||||
return num * 3600 * 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', {
|
||||
mounted(el: RTimeElement, binding) {
|
||||
const intervalMs = parseInterval(binding.arg)
|
||||
const intervalMs = parseInterval(binding.arg);
|
||||
const update = () => {
|
||||
const val = binding.value
|
||||
const val = binding.value;
|
||||
if (Number.isFinite(val)) {
|
||||
el.textContent = moment.unix(val as number).fromNow()
|
||||
return
|
||||
el.textContent = moment.unix(val as number).fromNow();
|
||||
return;
|
||||
}
|
||||
el.textContent = moment(val).fromNow()
|
||||
}
|
||||
el.textContent = moment(val).fromNow();
|
||||
};
|
||||
|
||||
update()
|
||||
el._next_timer = window.setInterval(update, intervalMs)
|
||||
update();
|
||||
el._next_timer = window.setInterval(update, intervalMs);
|
||||
},
|
||||
updated(el: RTimeElement, binding) {
|
||||
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 val = binding.value
|
||||
const val = binding.value;
|
||||
if (Number.isFinite(val)) {
|
||||
el.textContent = moment.unix(val as number).fromNow()
|
||||
return
|
||||
el.textContent = moment.unix(val as number).fromNow();
|
||||
return;
|
||||
}
|
||||
el.textContent = moment(val).fromNow()
|
||||
}
|
||||
el.textContent = moment(val).fromNow();
|
||||
};
|
||||
|
||||
update()
|
||||
el._next_timer = window.setInterval(update, intervalMs)
|
||||
update();
|
||||
el._next_timer = window.setInterval(update, intervalMs);
|
||||
}
|
||||
},
|
||||
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 Toast, { type PluginOptions } from 'vue-toastification'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
import { defineNuxtPlugin } from '#app';
|
||||
import Toast, { type PluginOptions } from 'vue-toastification';
|
||||
import 'vue-toastification/dist/index.css';
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(Toast, {
|
||||
transition: "Vue-Toastification__bounce",
|
||||
transition: 'Vue-Toastification__bounce',
|
||||
//position: "bottom-right",
|
||||
maxToasts: 5,
|
||||
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="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>YTPTube Loading...</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
color: #111;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #121212;
|
||||
color: #eee;
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>YTPTube Loading...</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
color: #111;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html,
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loader-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: 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 {
|
||||
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 {
|
||||
100% {
|
||||
stroke-dashoffset: -700;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
svg.ytptube-loading {
|
||||
stroke: #ff4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin: 20px auto 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 8px solid transparent;
|
||||
border-top-color: #FF0000;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s linear infinite;
|
||||
filter: drop-shadow(0 0 6px rgba(255,0,0,0.8));
|
||||
}
|
||||
@keyframes stroke-move {
|
||||
100% {
|
||||
stroke-dashoffset: -700;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.spinner {
|
||||
border-top-color: #FF4444;
|
||||
filter: drop-shadow(0 0 8px #FF4444);
|
||||
margin: 20px auto 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: 8px solid transparent;
|
||||
border-top-color: #ff0000;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.2s linear infinite;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.spinner {
|
||||
border-top-color: #ff4444;
|
||||
filter: drop-shadow(0 0 8px #ff4444);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 14px;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
||||
<svg class="ytptube-loading" viewBox="0 0 420 105" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||
<!-- Y -->
|
||||
<path d="M10 10 L25 37.5 L10 65" />
|
||||
<path d="M25 37.5 L40 10" />
|
||||
<!-- T -->
|
||||
<path d="M50 10 L90 10" />
|
||||
<path d="M70 10 L70 65" />
|
||||
<!-- P -->
|
||||
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
||||
<!-- T -->
|
||||
<path d="M160 10 L200 10" />
|
||||
<path d="M180 10 L180 65" />
|
||||
<!-- U -->
|
||||
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
||||
<!-- B -->
|
||||
<path d="M270 10 L270 65" />
|
||||
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
||||
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
||||
<!-- E -->
|
||||
<path d="M320 10 L320 65" />
|
||||
<path d="M320 10 L360 10" />
|
||||
<path d="M320 37.5 L350 37.5" />
|
||||
<path d="M320 65 L360 65" />
|
||||
</svg>
|
||||
<div class="spinner"></div>
|
||||
<div class="subtitle">Loading, please wait...</div>
|
||||
</div>
|
||||
</body>
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 14px;
|
||||
font-size: 1rem;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader-container" role="img" aria-label="Loading YTPTube">
|
||||
<svg
|
||||
class="ytptube-loading"
|
||||
viewBox="0 0 420 105"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<!-- Y -->
|
||||
<path d="M10 10 L25 37.5 L10 65" />
|
||||
<path d="M25 37.5 L40 10" />
|
||||
<!-- T -->
|
||||
<path d="M50 10 L90 10" />
|
||||
<path d="M70 10 L70 65" />
|
||||
<!-- P -->
|
||||
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
|
||||
<!-- T -->
|
||||
<path d="M160 10 L200 10" />
|
||||
<path d="M180 10 L180 65" />
|
||||
<!-- U -->
|
||||
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
|
||||
<!-- B -->
|
||||
<path d="M270 10 L270 65" />
|
||||
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
|
||||
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
|
||||
<!-- E -->
|
||||
<path d="M320 10 L320 65" />
|
||||
<path d="M320 10 L360 10" />
|
||||
<path d="M320 37.5 L350 37.5" />
|
||||
<path d="M320 65 L360 65" />
|
||||
</svg>
|
||||
<div class="spinner"></div>
|
||||
<div class="subtitle">Loading, please wait...</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useStorage } from '@vueuse/core'
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { ConfigState } from '~/types/config';
|
||||
import type { DLField } from '~/types/dl_fields';
|
||||
import type { Preset } from '~/types/presets';
|
||||
|
|
@ -39,15 +39,15 @@ export const useConfigStore = defineStore('config', () => {
|
|||
},
|
||||
presets: [
|
||||
{
|
||||
'name': 'default',
|
||||
'description': 'Default preset',
|
||||
'folder': '',
|
||||
'template': '',
|
||||
'cookies': '',
|
||||
'cli': '',
|
||||
'default': true,
|
||||
'priority': 0
|
||||
}
|
||||
name: 'default',
|
||||
description: 'Default preset',
|
||||
folder: '',
|
||||
template: '',
|
||||
cookies: '',
|
||||
cli: '',
|
||||
default: true,
|
||||
priority: 0,
|
||||
},
|
||||
],
|
||||
dl_fields: [],
|
||||
folders: [],
|
||||
|
|
@ -61,12 +61,12 @@ export const useConfigStore = defineStore('config', () => {
|
|||
if (state.is_loading) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
|
||||
if (state.is_loaded && !force && last_reload > 0) {
|
||||
const age = (now - last_reload) / 1000
|
||||
const age = (now - last_reload) / 1000;
|
||||
if (age < CONFIG_TTL) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,126 +95,133 @@ export const useConfigStore = defineStore('config', () => {
|
|||
last_reload = now;
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to load configuration: ${e}`);
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
state.is_loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const add = (key: string, value: any) => {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = value
|
||||
}
|
||||
(state as any)[key] = value;
|
||||
};
|
||||
|
||||
const get = (key: string, defaultValue: any = null): any => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
|
||||
const parent = state[parentKey] as any
|
||||
return parent?.[subKey] ?? defaultValue
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
return parent?.[subKey] ?? defaultValue;
|
||||
}
|
||||
return (state as any)[key] ?? defaultValue
|
||||
}
|
||||
const isLoaded = () => state.is_loaded
|
||||
return (state as any)[key] ?? defaultValue;
|
||||
};
|
||||
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>) => {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key.includes('.')) {
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
|
||||
const parent = state[parentKey] as any
|
||||
parent[subKey] = data[key]
|
||||
return
|
||||
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
|
||||
const parent = state[parentKey] as any;
|
||||
parent[subKey] = data[key];
|
||||
return;
|
||||
}
|
||||
(state as any)[key] = data[key]
|
||||
})
|
||||
(state as any)[key] = data[key];
|
||||
});
|
||||
|
||||
state.is_loaded = true
|
||||
}
|
||||
state.is_loaded = true;
|
||||
};
|
||||
|
||||
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)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ('presets' === feature) {
|
||||
const item = data as Preset
|
||||
const current = get(feature, []) as Array<Preset>
|
||||
const item = data as Preset;
|
||||
const current = get(feature, []) as Array<Preset>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item)
|
||||
return
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex(i => i.id === item.id)
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1)
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find(i => i.id === item.id)
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item)
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ('dl_fields' === feature) {
|
||||
const item = data as DLField
|
||||
const current = get(feature, []) as Array<DLField>
|
||||
const item = data as DLField;
|
||||
const current = get(feature, []) as Array<DLField>;
|
||||
|
||||
if ('create' === action) {
|
||||
current.push(item)
|
||||
return
|
||||
current.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('delete' === action) {
|
||||
const index = current.findIndex(i => i.id === item.id)
|
||||
const index = current.findIndex((i) => i.id === item.id);
|
||||
if (-1 !== index) {
|
||||
current.splice(index, 1)
|
||||
current.splice(index, 1);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ('update' === action) {
|
||||
const target = current.find(i => i.id === item.id)
|
||||
const target = current.find((i) => i.id === item.id);
|
||||
if (target) {
|
||||
Object.assign(target, item)
|
||||
Object.assign(target, item);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
if ('replace' === action) {
|
||||
state.dl_fields = data as Array<DLField>
|
||||
return
|
||||
state.dl_fields = data as Array<DLField>;
|
||||
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]> } & {
|
||||
add: typeof add
|
||||
get: typeof get
|
||||
update: typeof update
|
||||
getAll: typeof getAll
|
||||
setAll: typeof setAll
|
||||
patch: typeof patch
|
||||
isLoaded: typeof isLoaded
|
||||
loadConfig: typeof loadConfig
|
||||
}
|
||||
add: typeof add;
|
||||
get: typeof get;
|
||||
update: typeof update;
|
||||
getAll: typeof getAll;
|
||||
setAll: typeof setAll;
|
||||
patch: typeof patch;
|
||||
isLoaded: typeof isLoaded;
|
||||
loadConfig: typeof loadConfig;
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,82 +1,83 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { notification, notificationType } from '~/composables/useNotification'
|
||||
import { defineStore } from 'pinia';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { notification, notificationType } from '~/composables/useNotification';
|
||||
|
||||
const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
|
||||
'error': { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' },
|
||||
'warning': { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
|
||||
'success': { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
|
||||
'info': { level: 0, color: 'is-info', icon: 'fas fa-circle-info' }
|
||||
}
|
||||
error: { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' },
|
||||
warning: { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
|
||||
success: { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
|
||||
info: { level: 0, color: 'is-info', icon: 'fas fa-circle-info' },
|
||||
};
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const notifications = useStorage<notification[]>('notifications', [])
|
||||
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
||||
const notifications = useStorage<notification[]>('notifications', []);
|
||||
const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
|
||||
|
||||
const severityLevel = computed<notificationType | null>(() => {
|
||||
const unread = notifications.value.filter(n => !n.seen)
|
||||
const unread = notifications.value.filter((n) => !n.seen);
|
||||
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 level = severityLevel.value
|
||||
return level ? _map[level].color : ''
|
||||
})
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].color : '';
|
||||
});
|
||||
|
||||
const severityIcon = computed<string>(() => {
|
||||
const level = severityLevel.value
|
||||
return level ? _map[level].icon : ''
|
||||
})
|
||||
const level = severityLevel.value;
|
||||
return level ? _map[level].icon : '';
|
||||
});
|
||||
|
||||
const sortedNotifications = computed<notification[]>(() => {
|
||||
return [...notifications.value].sort((a, b) => {
|
||||
const severityDiff = _map[b.level].level - _map[a.level].level
|
||||
const severityDiff = _map[b.level].level - _map[a.level].level;
|
||||
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 id = Array.from(
|
||||
window.crypto.getRandomValues(new Uint8Array(14 / 2)),
|
||||
(dec: any) => dec.toString(16).padStart(2, "0")
|
||||
).join('')
|
||||
const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
|
||||
dec.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
notifications.value.unshift({
|
||||
id: id,
|
||||
message,
|
||||
level,
|
||||
seen: seen,
|
||||
created: new Date()
|
||||
})
|
||||
created: new Date(),
|
||||
});
|
||||
|
||||
if (notifications.value.length > 99) {
|
||||
notifications.value.length = 99
|
||||
notifications.value.length = 99;
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const clear = () => notifications.value = []
|
||||
const markAllRead = () => notifications.value.forEach(n => n.seen = true)
|
||||
const clear = () => (notifications.value = []);
|
||||
const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
|
||||
|
||||
const markRead = (id: string) => {
|
||||
const n = notifications.value.find(n => n.id === id)
|
||||
const n = notifications.value.find((n) => n.id === id);
|
||||
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 {
|
||||
notifications,
|
||||
|
|
@ -90,6 +91,6 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||
markAllRead,
|
||||
clear,
|
||||
markRead,
|
||||
remove
|
||||
}
|
||||
})
|
||||
remove,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,383 +1,404 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { StoreItem } from "~/types/store";
|
||||
import { ref, readonly } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import type {
|
||||
ConfigUpdatePayload,
|
||||
WebSocketClientEmits,
|
||||
WebSocketEnvelope,
|
||||
WSEP as WSEP
|
||||
} from "~/types/sockets";
|
||||
WSEP as WSEP,
|
||||
} from '~/types/sockets';
|
||||
|
||||
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
type SocketHandler = (...args: unknown[]) => void
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>
|
||||
type KnownEvent = keyof WSEP
|
||||
type SocketHandler = (...args: unknown[]) => void;
|
||||
type HandlerRegistry = Map<SocketHandler, SocketHandler>;
|
||||
type KnownEvent = keyof WSEP;
|
||||
|
||||
export const useSocketStore = defineStore('socket', () => {
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const config = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const toast = useNotification()
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const config = useConfigStore();
|
||||
const stateStore = useStateStore();
|
||||
const toast = useNotification();
|
||||
|
||||
const socket = ref<WebSocket | null>(null)
|
||||
const isConnected = ref<boolean>(false)
|
||||
const connectionStatus = ref<connectionStatus>('disconnected')
|
||||
const error = ref<string | null>(null)
|
||||
const error_count = ref<number>(0)
|
||||
const wasHidden = ref<boolean>(false)
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null)
|
||||
const manualDisconnect = ref<boolean>(false)
|
||||
const reconnectAttempts = ref<number>(0)
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref<boolean>(false);
|
||||
const connectionStatus = ref<connectionStatus>('disconnected');
|
||||
const error = ref<string | null>(null);
|
||||
const error_count = ref<number>(0);
|
||||
const wasHidden = ref<boolean>(false);
|
||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const manualDisconnect = ref<boolean>(false);
|
||||
const reconnectAttempts = ref<number>(0);
|
||||
|
||||
const handlers = new Map<string, HandlerRegistry>()
|
||||
const 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) {
|
||||
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>(
|
||||
event: K | K[],
|
||||
callback: (event: K, payload: WSEP[K]) => void,
|
||||
withEvent: true
|
||||
): void
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void
|
||||
withEvent: true,
|
||||
): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
|
||||
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
|
||||
const events = Array.isArray(event) ? event : [event]
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
if (!handlers.has(eventName)) {
|
||||
handlers.set(eventName, new Map())
|
||||
handlers.set(eventName, new Map());
|
||||
}
|
||||
|
||||
const registry = handlers.get(eventName) as HandlerRegistry
|
||||
const handler = true === withEvent
|
||||
? (payload: unknown) => callback(eventName, payload)
|
||||
: (payload: unknown) => callback(payload)
|
||||
const registry = handlers.get(eventName) as HandlerRegistry;
|
||||
const handler =
|
||||
true === withEvent
|
||||
? (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(event: string | string[], callback?: SocketHandler): 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 {
|
||||
const events = Array.isArray(event) ? event : [event]
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
events.forEach((eventName) => {
|
||||
const registry = handlers.get(eventName)
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
registry.clear()
|
||||
handlers.delete(eventName)
|
||||
return
|
||||
registry.clear();
|
||||
handlers.delete(eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
registry.delete(callback)
|
||||
registry.delete(callback);
|
||||
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 registry = handlers.get(eventName)
|
||||
const registry = handlers.get(eventName);
|
||||
if (!registry) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
registry.forEach((handler) => handler(payload))
|
||||
}
|
||||
registry.forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
wasHidden.value = true
|
||||
return
|
||||
wasHidden.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === wasHidden.value && false === isConnected.value) {
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value)
|
||||
reconnectTimeout.value = null
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
if (false === isConnected.value) {
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...')
|
||||
reconnect()
|
||||
console.debug('[SocketStore] Page visible after background, reconnecting...');
|
||||
reconnect();
|
||||
}
|
||||
reconnectTimeout.value = null
|
||||
}, 100)
|
||||
reconnectTimeout.value = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
wasHidden.value = false
|
||||
}
|
||||
wasHidden.value = false;
|
||||
};
|
||||
|
||||
const setupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupVisibilityListener = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (null !== reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value)
|
||||
reconnectTimeout.value = null
|
||||
clearTimeout(reconnectTimeout.value);
|
||||
reconnectTimeout.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (true === manualDisconnect.value || true === isConnected.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.value >= 50) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== reconnectTimeout.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
reconnectAttempts.value += 1
|
||||
reconnectTimeout.value = null
|
||||
connect()
|
||||
}, 5000)
|
||||
}
|
||||
reconnectAttempts.value += 1;
|
||||
reconnectTimeout.value = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (true === isConnected.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
connect()
|
||||
connectionStatus.value = 'connecting'
|
||||
}
|
||||
connect();
|
||||
connectionStatus.value = 'connecting';
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
manualDisconnect.value = true
|
||||
manualDisconnect.value = true;
|
||||
if (null === socket.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
socket.value.close()
|
||||
socket.value = null
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
cleanupVisibilityListener()
|
||||
}
|
||||
socket.value.close();
|
||||
socket.value = null;
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
cleanupVisibilityListener();
|
||||
};
|
||||
|
||||
const buildWsUrl = (): string => {
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '')
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`
|
||||
const configuredBase = runtimeConfig.public.wss?.trim()
|
||||
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
|
||||
const wsPath = `${basePath}/ws?_=${Date.now()}`;
|
||||
const configuredBase = runtimeConfig.public.wss?.trim();
|
||||
|
||||
if (configuredBase) {
|
||||
return new URL(wsPath, configuredBase).toString()
|
||||
return new URL(wsPath, configuredBase).toString();
|
||||
}
|
||||
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws'
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString()
|
||||
}
|
||||
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
|
||||
return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
manualDisconnect.value = false
|
||||
connectionStatus.value = 'connecting'
|
||||
manualDisconnect.value = false;
|
||||
connectionStatus.value = 'connecting';
|
||||
|
||||
socket.value = new WebSocket(buildWsUrl())
|
||||
socket.value = new WebSocket(buildWsUrl());
|
||||
|
||||
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||
window.ws = socket.value
|
||||
window.ws = socket.value;
|
||||
}
|
||||
|
||||
socket.value.addEventListener('open', () => {
|
||||
isConnected.value = true
|
||||
connectionStatus.value = 'connected'
|
||||
error.value = null
|
||||
error_count.value = 0
|
||||
reconnectAttempts.value = 0
|
||||
dispatch('connect', null)
|
||||
})
|
||||
isConnected.value = true;
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
error_count.value = 0;
|
||||
reconnectAttempts.value = 0;
|
||||
dispatch('connect', null);
|
||||
});
|
||||
|
||||
socket.value.addEventListener('close', () => {
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
error.value = 'Disconnected from server.'
|
||||
dispatch('disconnect', null)
|
||||
scheduleReconnect()
|
||||
})
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Disconnected from server.';
|
||||
dispatch('disconnect', null);
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('error', () => {
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
error.value = 'Connection error: Unknown error'
|
||||
error_count.value += 1
|
||||
dispatch('connect_error', { message: 'Unknown error' })
|
||||
scheduleReconnect()
|
||||
})
|
||||
isConnected.value = false;
|
||||
connectionStatus.value = 'disconnected';
|
||||
error.value = 'Connection error: Unknown error';
|
||||
error_count.value += 1;
|
||||
dispatch('connect_error', { message: 'Unknown error' });
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
let payload: WebSocketEnvelope | null = null
|
||||
let payload: WebSocketEnvelope | null = null;
|
||||
try {
|
||||
payload = JSON.parse(event.data)
|
||||
payload = JSON.parse(event.data);
|
||||
} catch {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload?.event || 'string' != typeof payload.event) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
let data = payload.data
|
||||
let data = payload.data;
|
||||
if ('string' === typeof data) {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
data = JSON.parse(data);
|
||||
} 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', () => {
|
||||
error.value = null
|
||||
config.loadConfig(false)
|
||||
})
|
||||
error.value = null;
|
||||
config.loadConfig(false);
|
||||
});
|
||||
|
||||
on('item_added', (data: WSEP['item_added']) => {
|
||||
stateStore.add('queue', data.data._id, data.data)
|
||||
toast.success(`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`)
|
||||
})
|
||||
stateStore.add('queue', data.data._id, data.data);
|
||||
toast.success(
|
||||
`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
|
||||
);
|
||||
});
|
||||
|
||||
on(['log_info', 'log_success', 'log_warning', 'log_error'], (event, data: WSEP['log_info']) => {
|
||||
const message = 'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '')
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {}
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra)
|
||||
break
|
||||
case 'log_success':
|
||||
toast.success(message, extra)
|
||||
break
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra)
|
||||
break
|
||||
case 'log_error':
|
||||
toast.error(message, extra)
|
||||
break
|
||||
}
|
||||
}, true)
|
||||
on(
|
||||
['log_info', 'log_success', 'log_warning', 'log_error'],
|
||||
(event, data: WSEP['log_info']) => {
|
||||
const message =
|
||||
'string' === typeof data?.message
|
||||
? data.message
|
||||
: String((data?.data as Record<string, unknown>)?.message ?? '');
|
||||
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
|
||||
switch (event) {
|
||||
case 'log_info':
|
||||
toast.info(message, extra);
|
||||
break;
|
||||
case 'log_success':
|
||||
toast.success(message, extra);
|
||||
break;
|
||||
case 'log_warning':
|
||||
toast.warning(message, extra);
|
||||
break;
|
||||
case 'log_error':
|
||||
toast.error(message, extra);
|
||||
break;
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('item_cancelled', (data: WSEP['item_cancelled']) => {
|
||||
const id = data.data._id
|
||||
const id = data.data._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)) {
|
||||
stateStore.remove('queue', id)
|
||||
stateStore.remove('queue', id);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
on('item_deleted', (data: WSEP['item_deleted']) => {
|
||||
const id = data.data._id
|
||||
const id = data.data._id;
|
||||
|
||||
if (true !== stateStore.has('history', id)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
stateStore.remove('history', id)
|
||||
})
|
||||
stateStore.remove('history', id);
|
||||
});
|
||||
|
||||
on('item_updated', (data: WSEP['item_updated']) => {
|
||||
const id = data.data._id
|
||||
const id = data.data._id;
|
||||
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.update('history', id, data.data)
|
||||
return
|
||||
stateStore.update('history', id, data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
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']) => {
|
||||
const to = data.data.to
|
||||
const id = data.data.item._id
|
||||
const to = data.data.to;
|
||||
const id = data.data.item._id;
|
||||
|
||||
if ('queue' === to) {
|
||||
if (true === stateStore.has('history', id)) {
|
||||
stateStore.remove('history', id)
|
||||
stateStore.remove('history', id);
|
||||
}
|
||||
stateStore.add('queue', id, data.data.item)
|
||||
stateStore.add('queue', id, data.data.item);
|
||||
}
|
||||
|
||||
if ('history' === to) {
|
||||
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']) => {
|
||||
const pausedState = Boolean(data.data.paused)
|
||||
config.update('paused', pausedState)
|
||||
on(
|
||||
['paused', 'resumed'],
|
||||
(event, data: WSEP['paused']) => {
|
||||
const pausedState = Boolean(data.data.paused);
|
||||
config.update('paused', pausedState);
|
||||
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.')
|
||||
return
|
||||
}
|
||||
if ('resumed' === event) {
|
||||
toast.success('Download queue resumed.');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('Download queue paused.', { timeout: 10000 })
|
||||
}, true)
|
||||
toast.warning('Download queue paused.', { timeout: 10000 });
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
on('config_update', (data: WSEP['config_update']) => {
|
||||
const configUpdate = data.data as ConfigUpdatePayload
|
||||
const configUpdate = data.data as ConfigUpdatePayload;
|
||||
if (!configUpdate) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data)
|
||||
})
|
||||
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
|
||||
});
|
||||
|
||||
return {
|
||||
connect, reconnect, disconnect,
|
||||
on, off, emit,
|
||||
connect,
|
||||
reconnect,
|
||||
disconnect,
|
||||
on,
|
||||
off,
|
||||
emit,
|
||||
isConnected,
|
||||
getSessionId,
|
||||
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
||||
error: readonly(error) as Readonly<Ref<string | null>>,
|
||||
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
import { request } from '~/utils'
|
||||
import { defineStore } from 'pinia';
|
||||
import type { item_request } from '~/types/item';
|
||||
import type { StoreItem } from '~/types/store';
|
||||
import { request } from '~/utils';
|
||||
|
||||
type StateType = 'queue' | 'history'
|
||||
type KeyType = string
|
||||
type StateType = 'queue' | 'history';
|
||||
type KeyType = string;
|
||||
|
||||
interface State {
|
||||
queue: Record<KeyType, StoreItem>
|
||||
history: Record<KeyType, StoreItem>
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history: Record<KeyType, StoreItem>;
|
||||
pagination: {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useStateStore = defineStore('state', () => {
|
||||
|
|
@ -35,110 +35,121 @@ export const useStateStore = defineStore('state', () => {
|
|||
isLoaded: false,
|
||||
isLoading: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total += 1
|
||||
state.pagination.total += 1;
|
||||
}
|
||||
state[type][key] = value
|
||||
}
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const update = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||
state[type][key] = value
|
||||
}
|
||||
state[type][key] = value;
|
||||
};
|
||||
|
||||
const remove = (type: StateType, key: KeyType): void => {
|
||||
if (!state[type][key]) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total -= 1
|
||||
state.pagination.total -= 1;
|
||||
}
|
||||
|
||||
const { [key]: _, ...rest } = state[type]
|
||||
state[type] = rest
|
||||
}
|
||||
const { [key]: _, ...rest } = state[type];
|
||||
state[type] = rest;
|
||||
};
|
||||
|
||||
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
|
||||
return state[type][key] || defaultValue
|
||||
}
|
||||
const get = (
|
||||
type: StateType,
|
||||
key: KeyType,
|
||||
defaultValue: StoreItem | null = null,
|
||||
): StoreItem | null => {
|
||||
return state[type][key] || defaultValue;
|
||||
};
|
||||
|
||||
const has = (type: StateType, key: KeyType): boolean => {
|
||||
return !!state[type][key]
|
||||
}
|
||||
return !!state[type][key];
|
||||
};
|
||||
|
||||
const clearAll = (type: StateType): void => {
|
||||
state[type] = {}
|
||||
state[type] = {};
|
||||
if ('queue' === type) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
state.pagination.total = 0
|
||||
state.pagination.page = 1
|
||||
state.pagination.total_pages = 0
|
||||
state.pagination.has_next = false
|
||||
state.pagination.has_prev = false
|
||||
}
|
||||
state.pagination.total = 0;
|
||||
state.pagination.page = 1;
|
||||
state.pagination.total_pages = 0;
|
||||
state.pagination.has_next = false;
|
||||
state.pagination.has_prev = false;
|
||||
};
|
||||
|
||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||
state[type] = data
|
||||
}
|
||||
state[type] = data;
|
||||
};
|
||||
|
||||
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
||||
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 => {
|
||||
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) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
||||
state.pagination.isLoading = true
|
||||
state.pagination.isLoading = true;
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
type: 'done',
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
order
|
||||
}
|
||||
order,
|
||||
};
|
||||
|
||||
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 data = await response.json()
|
||||
const response = await request(`/api/history?${search}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.pagination) {
|
||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false, }
|
||||
const items: Record<KeyType, StoreItem> = {}
|
||||
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
|
||||
const items: Record<KeyType, StoreItem> = {};
|
||||
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) {
|
||||
console.error(`Failed to load ${type} page ${page}:`, error)
|
||||
state.pagination.isLoading = false
|
||||
console.error(`Failed to load ${type} page ${page}:`, error);
|
||||
state.pagination.isLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
|
|
@ -146,11 +157,11 @@ export const useStateStore = defineStore('state', () => {
|
|||
}
|
||||
|
||||
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> => {
|
||||
if ('history' !== type) {
|
||||
|
|
@ -158,31 +169,31 @@ export const useStateStore = defineStore('state', () => {
|
|||
}
|
||||
|
||||
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> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
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) => {
|
||||
state.pagination.total = count
|
||||
state.pagination.total = count;
|
||||
if (count > 0 && !state.pagination.isLoaded) {
|
||||
state.pagination.isLoaded = false
|
||||
state.pagination.isLoaded = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load queue data from REST API.
|
||||
|
|
@ -192,19 +203,19 @@ export const useStateStore = defineStore('state', () => {
|
|||
*/
|
||||
const loadQueue = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await request('/api/history/live')
|
||||
const data = await response.json() as {
|
||||
"queue": Record<KeyType, StoreItem>,
|
||||
"history_count": number,
|
||||
}
|
||||
const response = await request('/api/history/live');
|
||||
const data = (await response.json()) as {
|
||||
queue: Record<KeyType, StoreItem>;
|
||||
history_count: number;
|
||||
};
|
||||
|
||||
state.queue = data.queue || {}
|
||||
setHistoryCount(data.history_count)
|
||||
state.queue = data.queue || {};
|
||||
setHistoryCount(data.history_count);
|
||||
} catch (error) {
|
||||
console.error('Failed to load queue:', error)
|
||||
throw error
|
||||
console.error('Failed to load queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const addDownload = async (data: item_request): Promise<void> => {
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
socket.emit('add_url', data)
|
||||
return
|
||||
socket.emit('add_url', data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
toast.error(error.error || 'Failed to add download')
|
||||
throw new Error(error.error || 'Failed to add download')
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to add download');
|
||||
throw new Error(error.error || 'Failed to add download');
|
||||
}
|
||||
|
||||
toast.success('Download added successfully')
|
||||
await loadQueue()
|
||||
toast.success('Download added successfully');
|
||||
await loadQueue();
|
||||
} 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')) {
|
||||
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.
|
||||
|
|
@ -252,47 +263,47 @@ export const useStateStore = defineStore('state', () => {
|
|||
* @returns Promise that resolves when items are started
|
||||
*/
|
||||
const startItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach(id => socket.emit('item_start', id))
|
||||
return
|
||||
ids.forEach((id) => socket.emit('item_start', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
toast.error(error.error || 'Failed to start items')
|
||||
throw new Error(error.error || 'Failed to start items')
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to start items');
|
||||
throw new Error(error.error || 'Failed to start items');
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('started' === result[id]) {
|
||||
const item = get('queue', id)
|
||||
const item = get('queue', id);
|
||||
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) {
|
||||
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')) {
|
||||
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.
|
||||
|
|
@ -301,47 +312,47 @@ export const useStateStore = defineStore('state', () => {
|
|||
* @returns Promise that resolves when items are paused
|
||||
*/
|
||||
const pauseItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach(id => socket.emit('item_pause', id))
|
||||
return
|
||||
ids.forEach((id) => socket.emit('item_pause', id));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/history/pause', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
toast.error(error.error || 'Failed to pause items')
|
||||
throw new Error(error.error || 'Failed to pause items')
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to pause items');
|
||||
throw new Error(error.error || 'Failed to pause items');
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('paused' === result[id]) {
|
||||
const item = get('queue', id)
|
||||
const item = get('queue', id);
|
||||
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) {
|
||||
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')) {
|
||||
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.
|
||||
|
|
@ -350,19 +361,19 @@ export const useStateStore = defineStore('state', () => {
|
|||
* @returns Promise that resolves when items are cancelled
|
||||
*/
|
||||
const cancelItems = async (ids: string[]): Promise<void> => {
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach(id => socket.emit('item_cancel', id))
|
||||
return
|
||||
ids.forEach((id) => socket.emit('item_cancel', id));
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToMove: Record<string, StoreItem> = {}
|
||||
const itemsToMove: Record<string, StoreItem> = {};
|
||||
for (const id of ids) {
|
||||
const item = get('queue', id)
|
||||
const item = get('queue', id);
|
||||
if (item) {
|
||||
itemsToMove[id] = { ...item }
|
||||
itemsToMove[id] = { ...item };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,34 +381,34 @@ export const useStateStore = defineStore('state', () => {
|
|||
const response = await request('/api/history/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
})
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
toast.error(error.error || 'Failed to cancel items')
|
||||
throw new Error(error.error || 'Failed to cancel items')
|
||||
const error = await response.json();
|
||||
toast.error(error.error || 'Failed to cancel items');
|
||||
throw new Error(error.error || 'Failed to cancel items');
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
|
||||
for (const id of ids) {
|
||||
if ('ok' === result[id] && itemsToMove[id]) {
|
||||
remove('queue', id)
|
||||
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem
|
||||
add('history', id, cancelledItem)
|
||||
remove('queue', id);
|
||||
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
|
||||
add('history', id, cancelledItem);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`)
|
||||
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
|
||||
} 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')) {
|
||||
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.
|
||||
|
|
@ -407,23 +418,27 @@ export const useStateStore = defineStore('state', () => {
|
|||
* @param removeFile - Whether to remove files from disk (default: false)
|
||||
* @returns Promise that resolves when items are removed
|
||||
*/
|
||||
const removeItems = async (type: StateType, ids: string[], removeFile: boolean = false): Promise<void> => {
|
||||
const socket = useSocketStore()
|
||||
const toast = useNotification()
|
||||
const removeItems = async (
|
||||
type: StateType,
|
||||
ids: string[],
|
||||
removeFile: boolean = false,
|
||||
): Promise<void> => {
|
||||
const socket = useSocketStore();
|
||||
const toast = useNotification();
|
||||
|
||||
if (socket.isConnected) {
|
||||
ids.forEach(id => socket.emit('item_delete', { id, remove_file: removeFile }))
|
||||
return
|
||||
ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteItems(type, { ids, removeFile })
|
||||
await deleteItems(type, { ids, removeFile });
|
||||
} catch (error) {
|
||||
console.error('Failed to remove items:', error)
|
||||
toast.error('Failed to remove items')
|
||||
throw error
|
||||
console.error('Failed to remove items:', error);
|
||||
toast.error('Failed to remove items');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete items by specific IDs or status filter.
|
||||
|
|
@ -438,55 +453,55 @@ export const useStateStore = defineStore('state', () => {
|
|||
*/
|
||||
const deleteItems = async (
|
||||
type: StateType,
|
||||
options: { ids?: string[]; status?: string; removeFile?: boolean } = {}
|
||||
options: { ids?: string[]; status?: string; removeFile?: boolean } = {},
|
||||
): Promise<number> => {
|
||||
const { ids, status, removeFile = true } = options
|
||||
const { ids, status, removeFile = true } = options;
|
||||
|
||||
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 {
|
||||
const body: Record<string, unknown> = {
|
||||
type: type === 'queue' ? 'queue' : 'done',
|
||||
remove_file: removeFile
|
||||
}
|
||||
remove_file: removeFile,
|
||||
};
|
||||
|
||||
if (ids) {
|
||||
body.ids = ids
|
||||
body.ids = ids;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body.status = status
|
||||
body.status = status;
|
||||
}
|
||||
|
||||
const response = await request('/api/history', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json() as {
|
||||
items: Record<string, string>,
|
||||
deleted: number,
|
||||
error?: string,
|
||||
message?: string,
|
||||
}
|
||||
const result = (await response.json()) as {
|
||||
items: Record<string, string>;
|
||||
deleted: number;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (result.error || result.message || !response.ok) {
|
||||
throw new Error(result.error || result.message || 'Failed to delete items.')
|
||||
throw new Error(result.error || result.message || 'Failed to delete items.');
|
||||
}
|
||||
|
||||
for (const id of Object.keys(result.items)) {
|
||||
remove(type, id)
|
||||
remove(type, id);
|
||||
}
|
||||
|
||||
return result.deleted
|
||||
return result.deleted;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete items:`, error)
|
||||
throw error
|
||||
console.error(`Failed to delete items:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
|
|
@ -512,5 +527,5 @@ export const useStateStore = defineStore('state', () => {
|
|||
cancelItems,
|
||||
removeItems,
|
||||
deleteItems,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
|
|
|||
9
ui/app/types/autocomplete.d.ts
vendored
9
ui/app/types/autocomplete.d.ts
vendored
|
|
@ -1,9 +1,8 @@
|
|||
interface Option {
|
||||
value: string
|
||||
description: string
|
||||
value: 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 = {
|
||||
tag: string
|
||||
full_sha: string
|
||||
date: string
|
||||
commits: Commit[]
|
||||
}
|
||||
tag: string;
|
||||
full_sha: string;
|
||||
date: string;
|
||||
commits: Commit[];
|
||||
};
|
||||
|
||||
type Commit = {
|
||||
sha: string
|
||||
full_sha: string
|
||||
message: string
|
||||
author: string
|
||||
date: string
|
||||
}
|
||||
sha: string;
|
||||
full_sha: string;
|
||||
message: string;
|
||||
author: 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 {
|
||||
id?: number
|
||||
name: string
|
||||
filter: string
|
||||
cli: string
|
||||
extras: Record<string, unknown>
|
||||
enabled: boolean
|
||||
priority: number
|
||||
description: string
|
||||
id?: number;
|
||||
name: string;
|
||||
filter: string;
|
||||
cli: string;
|
||||
extras: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
export interface ConditionTestRequest {
|
||||
url: string
|
||||
condition: string
|
||||
preset?: string
|
||||
url: string;
|
||||
condition: string;
|
||||
preset?: string;
|
||||
}
|
||||
|
||||
export interface ConditionTestResponse {
|
||||
status: boolean
|
||||
condition: string
|
||||
data: Record<string, unknown>
|
||||
status: boolean;
|
||||
condition: string;
|
||||
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 { DLField } from "./dl_fields"
|
||||
import type { DLField } from './dl_fields';
|
||||
|
||||
type AppConfig = {
|
||||
/** Path where downloaded files will be saved */
|
||||
download_path: string
|
||||
download_path: string;
|
||||
/** 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 */
|
||||
ui_update_title: boolean
|
||||
ui_update_title: boolean;
|
||||
/** 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" */
|
||||
ytdlp_version: string
|
||||
ytdlp_version: string;
|
||||
/** Maximum number of concurrent download workers */
|
||||
max_workers: number
|
||||
max_workers: number;
|
||||
/** 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: string
|
||||
default_preset: string;
|
||||
/** Instance title for the app, null if not set */
|
||||
instance_title: string | null
|
||||
instance_title: string | null;
|
||||
/** Indicates if the console is enabled */
|
||||
console_enabled: boolean
|
||||
console_enabled: boolean;
|
||||
/** Indicates if the file browser control is enabled */
|
||||
browser_control_enabled: boolean
|
||||
browser_control_enabled: boolean;
|
||||
/** Indicates if file logging is enabled */
|
||||
file_logging: boolean
|
||||
file_logging: boolean;
|
||||
/** 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) */
|
||||
is_native: boolean
|
||||
is_native: boolean;
|
||||
/** 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_commit_sha: string
|
||||
app_commit_sha: string;
|
||||
/** 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: string
|
||||
app_branch: string;
|
||||
/** When the app started */
|
||||
started: number,
|
||||
started: number;
|
||||
/** Application environment, e.g. "production", "development" */
|
||||
app_env: "production" | "development"
|
||||
app_env: 'production' | 'development';
|
||||
/** Default number of items per page for pagination */
|
||||
default_pagination: number
|
||||
default_pagination: number;
|
||||
/** 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: string
|
||||
new_version: string;
|
||||
/** New yt-dlp version available, empty string if none */
|
||||
yt_new_version: string
|
||||
}
|
||||
yt_new_version: string;
|
||||
};
|
||||
|
||||
type ConfigState = {
|
||||
/** Show or hide the download form */
|
||||
showForm: RemovableRef<boolean>
|
||||
showForm: RemovableRef<boolean>;
|
||||
/** Application configuration */
|
||||
app: AppConfig
|
||||
app: AppConfig;
|
||||
/** List of presets */
|
||||
presets: Array<Preset>
|
||||
presets: Array<Preset>;
|
||||
/** List of custom download fields */
|
||||
dl_fields: Array<DLField>
|
||||
dl_fields: Array<DLField>;
|
||||
/** List of folders where files can be saved */
|
||||
folders: Array<string>
|
||||
folders: Array<string>;
|
||||
/** List of yt-dlp options */
|
||||
ytdlp_options: Array<YTDLPOption>
|
||||
ytdlp_options: Array<YTDLPOption>;
|
||||
/** Indicates if downloads are currently paused */
|
||||
paused: boolean
|
||||
paused: boolean;
|
||||
/** Indicates if the configuration has been loaded */
|
||||
is_loaded: boolean
|
||||
is_loaded: boolean;
|
||||
/** 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 = {
|
||||
/** The id of the field */
|
||||
|
|
@ -27,7 +27,7 @@ type DLField = {
|
|||
|
||||
/** Additional options for the field */
|
||||
extras: Record<string, any>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for creating/updating DLField
|
||||
|
|
@ -39,6 +39,6 @@ type DLFieldRequest = {
|
|||
kind: DLFieldType;
|
||||
value?: string;
|
||||
extras?: Record<string, any>;
|
||||
}
|
||||
};
|
||||
|
||||
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 = {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
}
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
};
|
||||
|
||||
type FileItem = {
|
||||
type: 'file' | 'dir' | 'link'
|
||||
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
mime: string
|
||||
mtime: string
|
||||
ctime: string
|
||||
is_dir: boolean
|
||||
is_file: boolean
|
||||
is_symlink: boolean
|
||||
}
|
||||
type: 'file' | 'dir' | 'link';
|
||||
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string;
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
mtime: string;
|
||||
ctime: string;
|
||||
is_dir: boolean;
|
||||
is_file: boolean;
|
||||
is_symlink: boolean;
|
||||
};
|
||||
|
||||
type FileBrowserResponse = {
|
||||
path: string
|
||||
contents: FileItem[]
|
||||
pagination: Pagination
|
||||
}
|
||||
path: string;
|
||||
contents: FileItem[];
|
||||
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 {
|
||||
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: string,
|
||||
_version: string
|
||||
}
|
||||
_type: string;
|
||||
_version: string;
|
||||
};
|
||||
|
||||
type version_check = {
|
||||
app: {
|
||||
status: 'up_to_date' | 'update_available' | 'error',
|
||||
current_version: string,
|
||||
new_version: string
|
||||
},
|
||||
status: 'up_to_date' | 'update_available' | 'error';
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
};
|
||||
ytdlp: {
|
||||
status: 'up_to_date' | 'update_available' | 'error',
|
||||
current_version: string,
|
||||
new_version: string
|
||||
}
|
||||
}
|
||||
status: 'up_to_date' | 'update_available' | 'error';
|
||||
current_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 = {
|
||||
/** Unique identifier for the item */
|
||||
id?: string|null,
|
||||
id?: string | null;
|
||||
/** URL of the item to download */
|
||||
url: string,
|
||||
url: string;
|
||||
/** Preset to use for the download */
|
||||
preset?: string,
|
||||
preset?: string;
|
||||
/** Where to save the downloaded item */
|
||||
folder?: string,
|
||||
folder?: string;
|
||||
/** Output template for the downloaded item */
|
||||
template?: string,
|
||||
template?: string;
|
||||
/** Additional command line options for yt-dlp */
|
||||
cli?: string,
|
||||
cli?: string;
|
||||
/** Cookies file for the download */
|
||||
cookies?: string,
|
||||
cookies?: string;
|
||||
/** Auto start the download */
|
||||
auto_start?: boolean,
|
||||
auto_start?: boolean;
|
||||
/** Extras data for the item */
|
||||
extras?: Record<string, any>,
|
||||
}
|
||||
extras?: Record<string, any>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
type log_line = {
|
||||
id: number,
|
||||
line: string,
|
||||
datetime?: string,
|
||||
}
|
||||
|
||||
id: number;
|
||||
line: string;
|
||||
datetime?: string;
|
||||
};
|
||||
|
||||
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 PopoverTrigger = 'hover' | 'click' | 'focus'
|
||||
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
|
||||
export type PopoverTrigger = 'hover' | 'click' | 'focus';
|
||||
|
||||
export interface PopoverProps {
|
||||
title?: string
|
||||
description?: string
|
||||
placement?: PopoverPlacement
|
||||
trigger?: PopoverTrigger
|
||||
offset?: number
|
||||
disabled?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
showDelay?: number
|
||||
title?: string;
|
||||
description?: string;
|
||||
placement?: PopoverPlacement;
|
||||
trigger?: PopoverTrigger;
|
||||
offset?: number;
|
||||
disabled?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: 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 = {
|
||||
/** Unique identifier for the preset */
|
||||
id?: number
|
||||
id?: number;
|
||||
/** Preset name, e.g. "default" */
|
||||
name: string
|
||||
name: string;
|
||||
/** Optional description for the preset */
|
||||
description: string
|
||||
description: string;
|
||||
/** Folder where files will be saved, e.g. "/downloads" */
|
||||
folder: string
|
||||
folder: string;
|
||||
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */
|
||||
template: string
|
||||
template: string;
|
||||
/** Cookies for the preset, e.g. "cookies.txt" */
|
||||
cookies: string
|
||||
cookies: string;
|
||||
/** Additional command line options for yt-dlp */
|
||||
cli: string
|
||||
cli: string;
|
||||
/** Indicates if this is the default preset */
|
||||
default: boolean
|
||||
default: boolean;
|
||||
/** Priority for sorting. Higher priority presets appear first */
|
||||
priority: number
|
||||
}
|
||||
priority: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request payload for creating/updating preset
|
||||
*/
|
||||
type PresetRequest = {
|
||||
name: string
|
||||
description?: string
|
||||
folder?: string
|
||||
template?: string
|
||||
cookies?: string
|
||||
cli?: string
|
||||
priority?: number
|
||||
}
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
template?: string;
|
||||
cookies?: string;
|
||||
cli?: string;
|
||||
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 = {
|
||||
/** The error message */
|
||||
error: string,
|
||||
}
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type convert_args_response = {
|
||||
/** The converted CLI args */
|
||||
opts?: Record<string, any>,
|
||||
opts?: Record<string, any>;
|
||||
/** The output template if was provided */
|
||||
output_template?: string,
|
||||
output_template?: string;
|
||||
/** The download path if was provided */
|
||||
download_path?: string,
|
||||
download_path?: string;
|
||||
/** The download format if was provided */
|
||||
format?: string,
|
||||
format?: string;
|
||||
/** The removed options from the original CLI args if any. */
|
||||
removed_options?: Array<string>,
|
||||
}
|
||||
removed_options?: Array<string>;
|
||||
};
|
||||
|
||||
export type Pagination = {
|
||||
page: number
|
||||
per_page: number
|
||||
total: number
|
||||
total_pages: number
|
||||
has_next: boolean
|
||||
has_prev: boolean
|
||||
}
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
};
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: Array<T>
|
||||
pagination: Pagination
|
||||
}
|
||||
items: Array<T>;
|
||||
pagination: Pagination;
|
||||
};
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean
|
||||
error: string | null
|
||||
detail: unknown
|
||||
data?: T
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
detail: unknown;
|
||||
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 = {
|
||||
id: string
|
||||
created_at: string
|
||||
event: string
|
||||
title: string | null
|
||||
message: string | null
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
id: string;
|
||||
created_at: string;
|
||||
event: string;
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type EventPayload<T> = Event & { data: T }
|
||||
export type EventPayload<T> = Event & { data: T };
|
||||
|
||||
export type WebSocketEnvelope<T = unknown> = {
|
||||
event: string
|
||||
data: T
|
||||
}
|
||||
event: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type WSEP = {
|
||||
connect: null
|
||||
disconnect: null
|
||||
connect_error: { message?: string }
|
||||
connected: EventPayload<{ sid: string }>
|
||||
item_added: EventPayload<StoreItem>
|
||||
item_updated: EventPayload<StoreItem>
|
||||
item_cancelled: EventPayload<StoreItem>
|
||||
item_deleted: EventPayload<StoreItem>
|
||||
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>
|
||||
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>
|
||||
paused: EventPayload<{ paused?: boolean }>
|
||||
resumed: EventPayload<{ paused?: boolean }>
|
||||
log_info: EventPayload<Record<string, unknown>>
|
||||
log_success: EventPayload<Record<string, unknown>>
|
||||
log_warning: EventPayload<Record<string, unknown>>
|
||||
log_error: EventPayload<Record<string, unknown>>
|
||||
config_update: EventPayload<ConfigUpdatePayload>
|
||||
}
|
||||
connect: null;
|
||||
disconnect: null;
|
||||
connect_error: { message?: string };
|
||||
connected: EventPayload<{ sid: string }>;
|
||||
item_added: EventPayload<StoreItem>;
|
||||
item_updated: EventPayload<StoreItem>;
|
||||
item_cancelled: EventPayload<StoreItem>;
|
||||
item_deleted: EventPayload<StoreItem>;
|
||||
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>;
|
||||
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
|
||||
paused: EventPayload<{ paused?: boolean }>;
|
||||
resumed: EventPayload<{ paused?: boolean }>;
|
||||
log_info: EventPayload<Record<string, unknown>>;
|
||||
log_success: EventPayload<Record<string, unknown>>;
|
||||
log_warning: EventPayload<Record<string, unknown>>;
|
||||
log_error: EventPayload<Record<string, unknown>>;
|
||||
config_update: EventPayload<ConfigUpdatePayload>;
|
||||
};
|
||||
|
||||
export type WebSocketClientEmits = {
|
||||
add_url: Record<string, unknown>
|
||||
item_cancel: string
|
||||
item_delete: { id: string; remove_file?: boolean }
|
||||
item_start: string | string[]
|
||||
item_pause: string | string[]
|
||||
}
|
||||
add_url: Record<string, unknown>;
|
||||
item_cancel: string;
|
||||
item_delete: { id: string; remove_file?: boolean };
|
||||
item_start: 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> = {
|
||||
feature: ConfigFeature
|
||||
action: ConfigUpdateAction
|
||||
data: T | Array<T>
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
feature: ConfigFeature;
|
||||
action: ConfigUpdateAction;
|
||||
data: T | Array<T>;
|
||||
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 = {
|
||||
file: string
|
||||
}
|
||||
file: string;
|
||||
};
|
||||
|
||||
type sideCarSubtitle = SideCar & {
|
||||
lang: string
|
||||
name: string
|
||||
}
|
||||
lang: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type StoreItem = {
|
||||
/** Unique identifier for the item */
|
||||
_id: string
|
||||
_id: string;
|
||||
/** Error message if available */
|
||||
error: string | null
|
||||
error: string | null;
|
||||
/** Item source id */
|
||||
id: string
|
||||
id: string;
|
||||
/** Title of the item */
|
||||
title: string
|
||||
title: string;
|
||||
/** Description of the item */
|
||||
description: string
|
||||
description: string;
|
||||
/** URL of the item */
|
||||
url: string
|
||||
url: string;
|
||||
/** Preset used for the item */
|
||||
preset: string
|
||||
preset: string;
|
||||
/** Folder where the item is saved */
|
||||
folder: string
|
||||
folder: string;
|
||||
/** Download directory */
|
||||
download_dir: string
|
||||
download_dir: string;
|
||||
/** Temporary directory for the item */
|
||||
temp_dir: string
|
||||
temp_dir: string;
|
||||
/** Status of the item */
|
||||
status: ItemStatus
|
||||
status: ItemStatus;
|
||||
/** If the item has cookies */
|
||||
cookies: string
|
||||
cookies: string;
|
||||
/** If the item has custom output_template */
|
||||
template: string
|
||||
template: string;
|
||||
/** If the item has custom output_template for chapters */
|
||||
template_chapter: string
|
||||
template_chapter: string;
|
||||
/** When the item was created */
|
||||
timestamp: number
|
||||
timestamp: number;
|
||||
/** If the item is a live stream */
|
||||
is_live: boolean
|
||||
is_live: boolean;
|
||||
/** ISO 8601 formatted start time of the item */
|
||||
datetime: string
|
||||
datetime: string;
|
||||
/** Live stream start time if available */
|
||||
live_in: string | null
|
||||
live_in: string | null;
|
||||
/** File size of the item if available */
|
||||
file_size: number | null
|
||||
file_size: number | null;
|
||||
/** Custom yt-dlp command line arguments */
|
||||
cli: string
|
||||
cli: string;
|
||||
/** If the item is auto-started */
|
||||
auto_start: boolean
|
||||
auto_start: boolean;
|
||||
/** Options for the item */
|
||||
options: Record<string, unknown>
|
||||
options: Record<string, unknown>;
|
||||
/** Sidecar associated with the item. */
|
||||
sidecar: {
|
||||
Unknown?: Array<SideCar>
|
||||
subtitle?: Array<sideCarSubtitle>
|
||||
image?: Array<SideCar>
|
||||
},
|
||||
Unknown?: Array<SideCar>;
|
||||
subtitle?: Array<sideCarSubtitle>;
|
||||
image?: Array<SideCar>;
|
||||
};
|
||||
/** Extras for the item */
|
||||
extras: {
|
||||
/** Which channel the item belongs to */
|
||||
channel?: string
|
||||
channel?: string;
|
||||
/** The video duration if available */
|
||||
duration?: number | null
|
||||
duration?: number | null;
|
||||
/** The video release date if available */
|
||||
release_in?: string
|
||||
release_in?: string;
|
||||
/** The video thumbnail URL if available */
|
||||
thumbnail?: string
|
||||
thumbnail?: string;
|
||||
/** The uploader of the item if available */
|
||||
uploader?: string
|
||||
uploader?: string;
|
||||
/** Uploader name if available */
|
||||
is_audio?: boolean
|
||||
is_audio?: boolean;
|
||||
/** If the item has audio stream */
|
||||
is_video?: boolean
|
||||
is_video?: boolean;
|
||||
/** If the item has video stream */
|
||||
live_in?: string
|
||||
live_in?: string;
|
||||
/** Live stream start time if available */
|
||||
is_premiere?: boolean
|
||||
is_premiere?: boolean;
|
||||
/** If the item is a premiere */
|
||||
}
|
||||
};
|
||||
/** The item temporary filename */
|
||||
tmpfilename?: string | null
|
||||
tmpfilename?: string | null;
|
||||
/** The item filename */
|
||||
filename?: string | null
|
||||
filename?: string | null;
|
||||
/** Actual file size of the item if available */
|
||||
total_bytes?: number | null
|
||||
total_bytes?: number | null;
|
||||
/** 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?: number | null
|
||||
downloaded_bytes?: number | null;
|
||||
/** Message attached with the item if available */
|
||||
msg?: string | null
|
||||
msg?: string | null;
|
||||
/** Progress percentage of the item if available */
|
||||
percent?: number | null
|
||||
percent?: number | null;
|
||||
/** Speed of the item download if available */
|
||||
speed?: number | null
|
||||
speed?: number | null;
|
||||
/** Time remaining for the item download if available */
|
||||
eta?: number | null
|
||||
eta?: number | null;
|
||||
/** If the item can be archived */
|
||||
is_archivable?: boolean
|
||||
is_archivable?: boolean;
|
||||
/** If the item is archived */
|
||||
is_archived?: boolean
|
||||
is_archived?: boolean;
|
||||
/** Item archive ID */
|
||||
archive_id?: string | null
|
||||
archive_id?: string | null;
|
||||
/** 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 ---
|
||||
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 {
|
||||
url?: string
|
||||
browser?: 'chrome'
|
||||
arguments?: Array<string> | string
|
||||
wait_for?: WaitForSelector
|
||||
wait_timeout?: number
|
||||
page_load_timeout?: number
|
||||
[key: string]: unknown
|
||||
url?: string;
|
||||
browser?: 'chrome';
|
||||
arguments?: Array<string> | string;
|
||||
wait_for?: WaitForSelector;
|
||||
wait_timeout?: number;
|
||||
page_load_timeout?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EngineConfig {
|
||||
type?: EngineType
|
||||
options?: EngineOptions
|
||||
type?: EngineType;
|
||||
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 {
|
||||
method?: HttpMethod
|
||||
url?: string
|
||||
headers?: StringMap
|
||||
params?: StringMap
|
||||
data?: StringMap | string | null
|
||||
json_data?: object | Array<unknown> | string | number | boolean | null
|
||||
timeout?: number
|
||||
method?: HttpMethod;
|
||||
url?: string;
|
||||
headers?: StringMap;
|
||||
params?: StringMap;
|
||||
data?: StringMap | string | null;
|
||||
json_data?: object | Array<unknown> | string | number | boolean | null;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export type ResponseType = 'html' | 'json'
|
||||
export type ResponseType = 'html' | 'json';
|
||||
|
||||
export interface ResponseConfig {
|
||||
type?: ResponseType
|
||||
type?: ResponseType;
|
||||
}
|
||||
|
||||
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath'
|
||||
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath';
|
||||
|
||||
export interface PostFilter {
|
||||
filter: string
|
||||
value?: string
|
||||
filter: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ExtractionRule {
|
||||
type: ExtractionType
|
||||
expression: string
|
||||
attribute?: string
|
||||
post_filter?: PostFilter
|
||||
type: ExtractionType;
|
||||
expression: string;
|
||||
attribute?: string;
|
||||
post_filter?: PostFilter;
|
||||
}
|
||||
|
||||
export interface ContainerFields {
|
||||
link: ExtractionRule
|
||||
[field: string]: ExtractionRule
|
||||
link: ExtractionRule;
|
||||
[field: string]: ExtractionRule;
|
||||
}
|
||||
|
||||
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath'
|
||||
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath';
|
||||
|
||||
export interface Container {
|
||||
type?: ContainerSelectorType
|
||||
selector?: string
|
||||
expression?: string
|
||||
fields: ContainerFields
|
||||
type?: ContainerSelectorType;
|
||||
selector?: string;
|
||||
expression?: string;
|
||||
fields: ContainerFields;
|
||||
}
|
||||
|
||||
export interface WaitForSelector {
|
||||
type?: 'css' | 'xpath'
|
||||
expression?: string
|
||||
value?: string
|
||||
type?: 'css' | 'xpath';
|
||||
expression?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ParseConfig {
|
||||
items?: Container
|
||||
link?: ExtractionRule
|
||||
[field: string]: ExtractionRule | Container | undefined
|
||||
items?: Container;
|
||||
link?: ExtractionRule;
|
||||
[field: string]: ExtractionRule | Container | undefined;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionConfig {
|
||||
parse: ParseConfig
|
||||
engine?: EngineConfig
|
||||
request?: RequestConfig
|
||||
response?: ResponseConfig
|
||||
parse: ParseConfig;
|
||||
engine?: EngineConfig;
|
||||
request?: RequestConfig;
|
||||
response?: ResponseConfig;
|
||||
}
|
||||
|
||||
export interface TaskDefinitionDocument {
|
||||
name: string
|
||||
match_url: string[]
|
||||
priority?: number
|
||||
enabled?: boolean
|
||||
definition: TaskDefinitionConfig
|
||||
name: string;
|
||||
match_url: string[];
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
definition: TaskDefinitionConfig;
|
||||
}
|
||||
|
||||
export type TaskDefinitionSummary = {
|
||||
id: number
|
||||
name: string
|
||||
priority: number
|
||||
match_url: ReadonlyArray<string>
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
match_url: ReadonlyArray<string>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TaskDefinitionDetailed = TaskDefinitionSummary & {
|
||||
definition: TaskDefinitionConfig
|
||||
}
|
||||
definition: TaskDefinitionConfig;
|
||||
};
|
||||
|
||||
export type TaskDefinitionList = Paginated<TaskDefinitionSummary>
|
||||
export type TaskDefinitionList = Paginated<TaskDefinitionSummary>;
|
||||
|
||||
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
|
||||
|
||||
export interface TaskInspectRequest {
|
||||
url: string;
|
||||
preset?: string;
|
||||
handler?: string;
|
||||
url: string;
|
||||
preset?: string;
|
||||
handler?: string;
|
||||
}
|
||||
|
||||
export interface TaskInspectSuccess {
|
||||
// The structure depends on TaskResult, but at minimum:
|
||||
success?: boolean;
|
||||
[key: string]: unknown;
|
||||
// The structure depends on TaskResult, but at minimum:
|
||||
success?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskInspectError {
|
||||
error: string;
|
||||
message?: string;
|
||||
error: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export interface Task {
|
||||
id?: number
|
||||
name: string
|
||||
url: string
|
||||
folder?: string
|
||||
preset?: string
|
||||
timer?: string
|
||||
template?: string
|
||||
cli?: string
|
||||
auto_start?: boolean
|
||||
handler_enabled?: boolean
|
||||
enabled?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
id?: number;
|
||||
name: string;
|
||||
url: string;
|
||||
folder?: string;
|
||||
preset?: string;
|
||||
timer?: string;
|
||||
template?: string;
|
||||
cli?: string;
|
||||
auto_start?: boolean;
|
||||
handler_enabled?: boolean;
|
||||
enabled?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial Task interface for PATCH operations.
|
||||
*/
|
||||
export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & {
|
||||
name?: string
|
||||
url?: string
|
||||
}
|
||||
name?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginated list of tasks response.
|
||||
*/
|
||||
export type TaskList = Paginated<Task>
|
||||
export type TaskList = Paginated<Task>;
|
||||
|
||||
/**
|
||||
* Task handler inspect request.
|
||||
*/
|
||||
export interface TaskInspectRequest {
|
||||
url: string
|
||||
preset?: string
|
||||
handler?: string
|
||||
static_only?: boolean
|
||||
url: string;
|
||||
preset?: string;
|
||||
handler?: string;
|
||||
static_only?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task handler inspect response - success.
|
||||
*/
|
||||
export interface TaskInspectSuccess {
|
||||
matched: true
|
||||
handler: string
|
||||
message: string
|
||||
matched: true;
|
||||
handler: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task handler inspect response - failure.
|
||||
*/
|
||||
export interface TaskInspectFailure {
|
||||
matched: false
|
||||
message: string
|
||||
error: string
|
||||
matched: false;
|
||||
message: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task handler inspect response (union type).
|
||||
*/
|
||||
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure
|
||||
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure;
|
||||
|
||||
/**
|
||||
* Task metadata response.
|
||||
*/
|
||||
export interface TaskMetadataResponse {
|
||||
id: string
|
||||
id_type: string | null
|
||||
title: string | null
|
||||
description: string
|
||||
uploader: string
|
||||
tags: Array<string>
|
||||
year: number | null
|
||||
thumbnails: Record<string, string>
|
||||
json_file?: string
|
||||
nfo_file?: string
|
||||
id: string;
|
||||
id_type: string | null;
|
||||
title: string | null;
|
||||
description: string;
|
||||
uploader: string;
|
||||
tags: Array<string>;
|
||||
year: number | null;
|
||||
thumbnails: Record<string, string>;
|
||||
json_file?: string;
|
||||
nfo_file?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported task for import/export functionality.
|
||||
*/
|
||||
export interface ExportedTask extends Omit<Task, 'id' | 'created_at' | 'updated_at' | 'in_progress'> {
|
||||
_type: string
|
||||
_version: string
|
||||
export interface ExportedTask extends Omit<
|
||||
Task,
|
||||
'id' | 'created_at' | 'updated_at' | 'in_progress'
|
||||
> {
|
||||
_type: string;
|
||||
_version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error response.
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
detail?: unknown
|
||||
error: string;
|
||||
detail?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias for backward compatibility (deprecated).
|
||||
* @deprecated Use Task instead
|
||||
*/
|
||||
export type task_item = Task
|
||||
export type task_item = Task;
|
||||
|
||||
/**
|
||||
* Legacy alias for backward compatibility (deprecated).
|
||||
* @deprecated Use ExportedTask instead
|
||||
*/
|
||||
export type exported_task = ExportedTask
|
||||
export type exported_task = ExportedTask;
|
||||
|
||||
/**
|
||||
* Legacy alias for backward compatibility (deprecated).
|
||||
* @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