refactor: switch from pnpm to bun

This commit is contained in:
arabcoders 2026-02-20 22:08:47 +03:00
parent f2e9dc97f5
commit befc7df583
120 changed files with 37569 additions and 35175 deletions

View file

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

View file

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

View file

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

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

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&nbsp;(<u class="has-text-danger">{{ selectedElms.length }}</u>)
&nbsp;(<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 items <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 items
<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>

View file

@ -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>&nbsp;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>&nbsp;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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
&nbsp;
<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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
export { }
export {};
declare global {
interface Window {
ws?: WebSocket
ws?: WebSocket;
}
}

View file

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

View file

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

View file

@ -1,8 +1,7 @@
type log_line = {
id: number,
line: string,
datetime?: string,
}
id: number;
line: string;
datetime?: string;
};
export type { log_line };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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