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/**" - ".github/ISSUE_TEMPLATE/**"
env: env:
PNPM_VERSION: 10 BUN_VERSION: latest
NODE_VERSION: 20 NODE_VERSION: 20
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
@ -43,49 +43,56 @@ jobs:
- name: Run Python tests - name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short run: uv run pytest app/tests/ -v --tb=short
- name: Install pnpm - name: Cache bun installation
uses: pnpm/action-setup@v4 id: cache-bun
uses: actions/cache@v4
with: with:
version: ${{ env.PNPM_VERSION }} path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml" - name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ui working-directory: ui
env: env:
NODE_ENV: development NODE_ENV: development
run: pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Prepare frontend (nuxt prepare) - name: Prepare frontend (nuxt prepare)
working-directory: ui working-directory: ui
env: env:
NODE_ENV: development NODE_ENV: development
run: pnpm nuxt prepare run: bun nuxt prepare
- name: Run frontend linting - name: Run frontend linting
working-directory: ui working-directory: ui
run: pnpm run lint run: bun run lint
- name: Run frontend type checking - name: Run frontend type checking
working-directory: ui working-directory: ui
run: pnpm run typecheck run: bun run typecheck
- name: Run frontend tsc - name: Run frontend tsc
working-directory: ui working-directory: ui
run: pnpm run lint:tsc run: bun run lint:tsc
- name: Run frontend tests - name: Run frontend tests
working-directory: ui working-directory: ui
run: pnpm run test:ci run: bun run test:ci
- name: Build frontend - name: Build frontend
working-directory: ui working-directory: ui
run: pnpm run generate run: bun run generate
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View file

@ -28,7 +28,7 @@ env:
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }} BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
DOCKERHUB_SLUG: arabcoders/ytptube DOCKERHUB_SLUG: arabcoders/ytptube
GHCR_SLUG: ghcr.io/arabcoders/ytptube GHCR_SLUG: ghcr.io/arabcoders/ytptube
PNPM_VERSION: 10 BUN_VERSION: latest
NODE_VERSION: 20 NODE_VERSION: 20
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
@ -77,57 +77,64 @@ jobs:
- name: Run Python tests - name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short run: uv run pytest app/tests/ -v --tb=short
- name: Install pnpm - name: Cache bun installation
uses: pnpm/action-setup@v4 id: cache-bun
uses: actions/cache@v4
with: with:
version: ${{ env.PNPM_VERSION }} path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml" - name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ui working-directory: ui
env: env:
NODE_ENV: development NODE_ENV: development
run: pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Prepare frontend (nuxt prepare) - name: Prepare frontend (nuxt prepare)
working-directory: ui working-directory: ui
env: env:
NODE_ENV: development NODE_ENV: development
run: pnpm nuxt prepare run: bun nuxt prepare
- name: Run frontend linting - name: Run frontend linting
working-directory: ui working-directory: ui
run: pnpm run lint run: bun run lint
- name: Run frontend type checking - name: Run frontend type checking
working-directory: ui working-directory: ui
run: pnpm run typecheck run: bun run typecheck
- name: Run frontend tsc - name: Run frontend tsc
working-directory: ui working-directory: ui
run: pnpm run lint:tsc run: bun run lint:tsc
- name: Run frontend tests - name: Run frontend tests
working-directory: ui working-directory: ui
run: pnpm run test:ci run: bun run test:ci
- name: Remove dev dependencies - name: Remove dev dependencies
working-directory: ui working-directory: ui
env: env:
NODE_ENV: production NODE_ENV: production
run: pnpm install --frozen-lockfile --prod --ignore-scripts run: bun install --frozen-lockfile --production --ignore-scripts
- name: Build frontend - name: Build frontend
working-directory: ui working-directory: ui
env: env:
NODE_ENV: production NODE_ENV: production
run: pnpm run generate run: bun run generate
- name: Install GitPython - name: Install GitPython
run: pip install gitpython run: pip install gitpython

View file

@ -38,7 +38,7 @@ jobs:
env: env:
PYTHON_VERSION: 3.11 PYTHON_VERSION: 3.11
PNPM_VERSION: 10 BUN_VERSION: latest
NODE_VERSION: 20 NODE_VERSION: 20
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }} TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
@ -85,24 +85,31 @@ jobs:
uv venv --system-site-packages --relocatable uv venv --system-site-packages --relocatable
uv sync --link-mode=copy --active --extra installer uv sync --link-mode=copy --active --extra installer
- name: Install pnpm - name: Cache bun installation
uses: pnpm/action-setup@v4 id: cache-bun
uses: actions/cache@v4
with: with:
version: ${{ env.PNPM_VERSION }} path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ matrix.arch }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.arch }}-bun-
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml"
- name: Build frontend - name: Build frontend
working-directory: ui working-directory: ui
shell: bash shell: bash
run: | run: |
pnpm install --production --prefer-offline --frozen-lockfile bun install --production --prefer-offline --frozen-lockfile
pnpm run generate bun run generate
- name: Clean build outputs (Unix) - name: Clean build outputs (Unix)
if: runner.os != 'Windows' if: runner.os != 'Windows'

4
.vscode/launch.json vendored
View file

@ -10,7 +10,7 @@
"--port", "--port",
"8082" "8082"
], ],
"runtimeExecutable": "pnpm", "runtimeExecutable": "bun",
"type": "node", "type": "node",
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"env": { "env": {
@ -44,7 +44,7 @@
"run", "run",
"generate" "generate"
], ],
"runtimeExecutable": "pnpm", "runtimeExecutable": "bun",
"type": "node", "type": "node",
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"console": "internalConsole", "console": "internalConsole",

273
.vscode/settings.json vendored
View file

@ -2,218 +2,9 @@
"files.exclude": { "files.exclude": {
"**/__pycache__": true "**/__pycache__": true
}, },
"autopep8.args": [ "editor.rulers": [120],
"--max-line-length", "cSpell.enabled": false,
"120", "css.styleSheets": ["ui/app/assets/css/*.css"],
"--experimental"
],
"editor.rulers": [
120
],
"cSpell.words": [
"aconvert",
"addopts",
"ahas",
"ahash",
"aiocron",
"aiosqlite",
"alnum",
"anyio",
"Archiver",
"arrowdown",
"arrowleft",
"arrowless",
"arrowright",
"arrowup",
"asyncio",
"attl",
"autonumber",
"autouse",
"bgutil",
"bgutilhttp",
"bilibili",
"brainicism",
"brotlicffi",
"buildcache",
"cand",
"caplog",
"Cfmrc",
"choco",
"cifs",
"connectionpool",
"consoletitle",
"contenteditable",
"continuedl",
"cookiesfrombrowser",
"copyts",
"creationflags",
"cronsim",
"crosshairs",
"currsize",
"dailymotion",
"datas",
"dateparser",
"daterange",
"defusedxml",
"delenv",
"denoland",
"dlfields",
"dotdot",
"dotenv",
"dpkg",
"dunder",
"Edes",
"edgechromium",
"EISGPM",
"engineio",
"Errno",
"esac",
"euuo",
"eventbus",
"excepthook",
"exitcode",
"falsey",
"faststart",
"Fetc",
"fetchall",
"fetchone",
"filterwarnings",
"finaldir",
"flac",
"forcejson",
"forceprint",
"Fpasswd",
"fribidi",
"Funsafe",
"getpid",
"gibibytes",
"gitea",
"gitpython",
"googledrive",
"gpac",
"hiddenimports",
"hookspath",
"httpx",
"imagetools",
"isinstance",
"jmespath",
"jsonschema",
"kibibytes",
"Kodi",
"kwdefaults",
"lastgroup",
"levelno",
"libcurl",
"libjavascriptcoregtk",
"libmfx",
"libstdc",
"libwebkit",
"libx",
"LPAREN",
"matchtitle",
"matroska",
"mbed",
"mccabe",
"mebibytes",
"MEIPASS",
"merch",
"metaclass",
"Metaclasses",
"metadataparser",
"mgmt",
"Microformat",
"microformats",
"mkvtoolsnix",
"MMDD",
"movflags",
"mpegts",
"msvideo",
"mult",
"multidict",
"muxdelay",
"mweb",
"nfsvers",
"nodesc",
"noninteractive",
"noprogress",
"onefile",
"oneline",
"onupdate",
"parsel",
"pathex",
"pickleable",
"platformdirs",
"playlistend",
"playlistrandom",
"playlistreverse",
"plexmatch",
"postprocessor",
"preferredcodec",
"preferredquality",
"printtraffic",
"procps",
"pycryptodome",
"pycryptodomex",
"pyinstaller",
"pypi",
"pythonpath",
"quicktime",
"ratecontrol",
"rejecttitle",
"remux",
"reqs",
"restrictfilenames",
"RPAREN",
"rtime",
"rvfc",
"sessionmaker",
"SIGUSR",
"smhd",
"socketio",
"softprops",
"solverr",
"SQLA",
"sstr",
"startswith",
"SUPPRESSHELP",
"tablehead",
"tebibytes",
"testpaths",
"threadsafe",
"tiktok",
"timespec",
"tinyurl",
"tmpfilename",
"toplevel",
"trixie",
"ungroup",
"unmark",
"unnegated",
"unpickleable",
"Unraid",
"upgrader",
"urandom",
"urlparse",
"urlsafe",
"usegmt",
"usermod",
"ustr",
"vainfo",
"vcodec",
"vconvert",
"Vitest",
"writedescription",
"WSEP",
"xerror",
"youtu",
"youtubepot",
"zstd",
"русский",
"العربية"
],
"css.styleSheets": [
"ui/app/assets/css/*.css"
],
"search.exclude": { "search.exclude": {
"ui/.nuxt": true, "ui/.nuxt": true,
"ui/.output": true, "ui/.output": true,
@ -221,51 +12,23 @@
"ui/exported": true, "ui/exported": true,
"ui/node_modules": true, "ui/node_modules": true,
"ui/.out": true, "ui/.out": true,
"**/.venv": true, "**/.venv": true
}, },
"eslint.format.enable": true, "eslint.format.enable": false,
"eslint.ignoreUntitled": true, "eslint.ignoreUntitled": true,
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"json.schemas": [ "python.testing.pytestArgs": ["app"],
{ "oxc.path.oxfmt": "./ui/node_modules/.bin/oxfmt",
"fileMatch": [ "oxc.fmt.configPath": "./ui/.oxfmtrc.json",
"**/tasks/*.json" "[typescript]": {
], "editor.defaultFormatter": "oxc.oxc-vscode",
"url": "./app/schema/task_definition.json" "editor.formatOnSave": true,
}, "editor.formatOnSaveMode": "file"
{ },
"fileMatch": [ "[vue]": {
"**/config/conditions.json" "editor.defaultFormatter": "oxc.oxc-vscode",
], "editor.formatOnSave": true,
"url": "./app/schema/conditions.json" "editor.formatOnSaveMode": "file"
}, }
{
"fileMatch": [
"**/config/dl_fields.json"
],
"url": "./app/schema/dl_fields.json"
},
{
"fileMatch": [
"**/config/notifications.json"
],
"url": "./app/schema/notifications.json"
},
{
"fileMatch": [
"**/config/presets.json"
],
"url": "./app/schema/presets.json"
},
{
"fileMatch": [
"**/config/tasks.json"
],
"url": "./app/schema/tasks.json"
}
],
"python.testing.pytestArgs": [
"app"
]
} }

View file

@ -1,13 +1,12 @@
# syntax=docker/dockerfile:1.4
FROM node:lts-alpine AS node_builder FROM node:lts-alpine AS node_builder
WORKDIR /app WORKDIR /app
COPY ui ./ COPY ui ./
ENV NODE_ENV=production ENV NODE_ENV=production
RUN if [ ! -f "/app/exported/index.html" ]; then \ RUN if [ ! -f "/app/exported/index.html" ]; then \
npm install -g pnpm && \ npm install -g bun && \
NODE_ENV=production pnpm install --frozen-lockfile --prod --ignore-scripts && \ NODE_ENV=production bun install --frozen-lockfile --production && \
pnpm run generate; \ bun run generate; \
else echo "Skipping UI build, already built."; fi else echo "Skipping UI build, already built."; fi
FROM python:3.13-bookworm AS python_builder FROM python:3.13-bookworm AS python_builder

13
ui/.oxfmtrc.json Normal file
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; position: relative;
width: 100%; width: 100%;
height: 30px; height: 30px;
background-color: #F5F5F5; background-color: #f5f5f5;
} }
.progress, .progress,
@ -292,7 +292,7 @@ hr {
} }
.transparent-bg { .transparent-bg {
opacity: 0.90; opacity: 0.9;
} }
.bg-fanart { .bg-fanart {
@ -357,7 +357,7 @@ div.is-centered {
} }
.is-overflow-visible { .is-overflow-visible {
overflow: visible !important overflow: visible !important;
} }
.modal-content-max { .modal-content-max {

View file

@ -6,25 +6,29 @@
<div class="card-header"> <div class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
/></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</div> </div>
<div class="card-header-icon" v-if="reference"> <div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport"> <button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"
'fa-arrow-down': !showImport, ><i
'fa-arrow-up': showImport, class="fa-solid"
}" /></span> :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}"
/></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span> <span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button> </button>
</div> </div>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference"> <div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string"> <label class="label is-inline" for="import_string">
<span class="icon"><i class="fa-solid fa-file-import" /></span> <span class="icon"><i class="fa-solid fa-file-import" /></span>
@ -33,11 +37,22 @@
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off"> <input
type="text"
class="input"
id="import_string"
v-model="import_string"
autocomplete="off"
/>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem"> <button
class="button is-primary"
:disabled="!import_string"
type="button"
@click="importItem"
>
<span class="icon"><i class="fa-solid fa-add" /></span> <span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span> <span>Import</span>
</button> </button>
@ -56,8 +71,14 @@
Name Name
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" <input
placeholder="For the problematic channel or video name."> type="text"
class="input"
id="name"
v-model="form.name"
:disabled="addInProgress"
placeholder="For the problematic channel or video name."
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -73,8 +94,13 @@
Enabled Enabled
</label> </label>
<div class="control is-unselectable"> <div class="control is-unselectable">
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress" <input
class="switch is-success" /> id="enabled"
type="checkbox"
v-model="form.enabled"
:disabled="addInProgress"
class="switch is-success"
/>
<label for="enabled" class="is-unselectable"> <label for="enabled" class="is-unselectable">
{{ form.enabled ? 'Yes' : 'No' }} {{ form.enabled ? 'Yes' : 'No' }}
</label> </label>
@ -93,8 +119,15 @@
Priority Priority
</label> </label>
<div class="control"> <div class="control">
<input type="number" class="input" id="priority" v-model.number="form.priority" <input
:disabled="addInProgress" min="0" placeholder="0"> type="number"
class="input"
id="priority"
v-model.number="form.priority"
:disabled="addInProgress"
min="0"
placeholder="0"
/>
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -109,19 +142,28 @@
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
Condition Filter Condition Filter
<template v-if="!addInProgress || form.filter"> <template v-if="!addInProgress || form.filter">
- <NuxtLink @click="test_data.show = true" class="is-bold"> -
<NuxtLink @click="test_data.show = true" class="is-bold">
Test filter logic Test filter logic
</NuxtLink> </NuxtLink>
</template> </template>
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="addInProgress" <input
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"> type="text"
class="input"
id="filter"
v-model="form.filter"
:disabled="addInProgress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code> <span
support.</span> >yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
<code>||</code> support.</span
>
</span> </span>
</div> </div>
</div> </div>
@ -132,15 +174,22 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Command options for yt-dlp</span> <span>Command options for yt-dlp</span>
</label> </label>
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" <TextareaAutocomplete
:disabled="addInProgress" /> id="cli_options"
v-model="form.cli"
:options="ytDlpOpt"
:disabled="addInProgress"
/>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
supported <NuxtLink target="_blank" options are supported
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some <NuxtLink
are ignored</NuxtLink>. Use with caution. target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
>some are ignored</NuxtLink
>. Use with caution.
</span> </span>
</span> </span>
</div> </div>
@ -154,18 +203,38 @@
</label> </label>
<div class="content"> <div class="content">
<div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3"> <div v-if="form.extras && Object.keys(form.extras).length > 0" class="mb-3">
<div v-for="(value, key) in form.extras" :key="key" class="field is-grouped mb-2"> <div
v-for="(value, key) in form.extras"
:key="key"
class="field is-grouped mb-2"
>
<div class="control"> <div class="control">
<input type="text" class="input" :value="key" @input="updateExtraKey($event, key)" <input
:disabled="addInProgress" placeholder="key_name"> type="text"
class="input"
:value="key"
@input="updateExtraKey($event, key)"
:disabled="addInProgress"
placeholder="key_name"
/>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" :value="value" @input="updateExtraValue(key, $event)" <input
:disabled="addInProgress" placeholder="value"> type="text"
class="input"
:value="value"
@input="updateExtraValue(key, $event)"
:disabled="addInProgress"
placeholder="value"
/>
</div> </div>
<div class="control"> <div class="control">
<button type="button" class="button is-danger" @click="removeExtra(key)" <button
:disabled="addInProgress"> type="button"
class="button is-danger"
@click="removeExtra(key)"
:disabled="addInProgress"
>
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
</button> </button>
</div> </div>
@ -174,36 +243,60 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<input type="text" class="input" v-model="newExtraKey" :disabled="addInProgress" <input
placeholder="new_key" @keyup.enter="addExtra"> type="text"
class="input"
v-model="newExtraKey"
:disabled="addInProgress"
placeholder="new_key"
@keyup.enter="addExtra"
/>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" v-model="newExtraValue" :disabled="addInProgress" <input
placeholder="new_value" @keyup.enter="addExtra"> type="text"
class="input"
v-model="newExtraValue"
:disabled="addInProgress"
placeholder="new_value"
@keyup.enter="addExtra"
/>
</div> </div>
<div class="control"> <div class="control">
<button type="button" class="button is-primary" @click="addExtra" <button
:disabled="addInProgress || !newExtraKey || !newExtraValue"> type="button"
class="button is-primary"
@click="addExtra"
:disabled="addInProgress || !newExtraKey || !newExtraValue"
>
<span class="icon"><i class="fa-solid fa-plus" /></span> <span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span> <span>Add</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<span class="help "> <span class="help">
<div class="message is-info"> <div class="message is-info">
<div class="message-body content pl-0 is-small pt-1"> <div class="message-body content pl-0 is-small pt-1">
<ul> <ul>
<li>For advanced users only. This feature is meant to be expanded later.</li> <li>
<li>Keys must be lowercase with underscores (e.g., custom_field).</li> For advanced users only. This feature is meant to be expanded later.
<li class="has-text-danger is-bold">You must click on Add to actually add the option.</li>
<li>The key <code>ignore_download</code> with value of <code>true</code> will instruct
<b>YTPTube</b> to ignore the download and directly mark the item as archived. this is useful
to skip certain kind of downloads.
</li> </li>
<li>The key <code>set_preset</code> with the name of an existing preset will instruct <li>Keys must be lowercase with underscores (e.g., custom_field).</li>
<b>YTPTube</b> to switch the download to use the specified preset. This is useful <li class="has-text-danger is-bold">
to apply different download settings based on content type or source. You must click on Add to actually add the option.
</li>
<li>
The key <code>ignore_download</code> with value of
<code>true</code> will instruct <b>YTPTube</b> to ignore the download
and directly mark the item as archived. this is useful to skip certain
kind of downloads.
</li>
<li>
The key <code>set_preset</code> with the name of an existing preset will
instruct <b>YTPTube</b> to switch the download to use the specified
preset. This is useful to apply different download settings based on
content type or source.
</li> </li>
</ul> </ul>
</div> </div>
@ -219,8 +312,13 @@
Description Description
</label> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress" <textarea
placeholder="Describe what this condition does" /> class="textarea"
id="description"
v-model="form.description"
:disabled="addInProgress"
placeholder="Describe what this condition does"
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -228,22 +326,30 @@
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
<div class="card-footer-item"> <div class="card-footer-item">
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit" <button
:class="{ 'is-loading': addInProgress }" form="addForm"> class="button is-fullwidth is-primary"
:disabled="addInProgress"
type="submit"
:class="{ 'is-loading': addInProgress }"
form="addForm"
>
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress" <button
type="button"> class="button is-fullwidth is-danger"
@click="emitter('cancel')"
:disabled="addInProgress"
type="button"
>
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -267,12 +373,23 @@
<div class="field-body"> <div class="field-body">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="url" class="input " id="url" v-model="test_data.url" <input
:disabled="test_data.in_progress" placeholder="https://..." required> type="url"
class="input"
id="url"
v-model="test_data.url"
:disabled="test_data.in_progress"
placeholder="https://..."
required
/>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" type="submit" :disabled="test_data.in_progress" <button
:class="{ 'is-loading': test_data.in_progress }"> class="button is-primary"
type="submit"
:disabled="test_data.in_progress"
:class="{ 'is-loading': test_data.in_progress }"
>
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Test</span> <span>Test</span>
</button> </button>
@ -291,33 +408,49 @@
Condition Filter Condition Filter
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="filter" v-model="form.filter" :disabled="test_data.in_progress" <input
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'" required> type="text"
class="input"
id="filter"
v-model="form.filter"
:disabled="test_data.in_progress"
placeholder="availability = 'needs_auth' & channel_id = 'channel_id'"
required
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp <code>--match-filters</code> logic with <code>OR</code>, <code>||</code> <span
support.</span> >yt-dlp <code>--match-filters</code> logic with <code>OR</code>,
<code>||</code> support.</span
>
</span> </span>
</div> </div>
<div class="field"> <div class="field">
<span class="is-bold" :class="{ <span
'has-text-success': true === logic_test, class="is-bold"
'has-text-danger': false === logic_test, :class="{
}"> 'has-text-success': true === logic_test,
<span class="icon"><i class="fa-solid" :class="{ 'has-text-danger': false === logic_test,
'fa-check': true === logic_test, }"
'fa-xmark': false === logic_test, >
'fa-question': null === logic_test, <span class="icon"
}" /></span> ><i
class="fa-solid"
:class="{
'fa-check': true === logic_test,
'fa-xmark': false === logic_test,
'fa-question': null === logic_test,
}"
/></span>
Filter Status: Filter Status:
<template v-if="null === test_data?.data?.status">Not tested</template> <template v-if="null === test_data?.data?.status">Not tested</template>
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template> <template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
</span> </span>
</div> </div>
<div class="field"> <div class="field">
<pre style="height:60vh;"><code>{{ show_data() }}</code></pre> <pre style="height: 60vh"><code>{{ show_data() }}</code></pre>
</div> </div>
</div> </div>
</div> </div>
@ -331,310 +464,323 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue' import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
import type { AutoCompleteOptions } from '~/types/autocomplete'; import type { AutoCompleteOptions } from '~/types/autocomplete';
import type { Condition } from '~/types/conditions' import type { Condition } from '~/types/conditions';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import type { ImportedItem } from '~/types'; import type { ImportedItem } from '~/types';
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void;
(e: 'submit', payload: { reference: number | null | undefined, item: Condition }): void (e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
}>() }>();
const props = defineProps<{ const props = defineProps<{
reference?: number | null reference?: number | null;
item: Condition item: Condition;
addInProgress?: boolean addInProgress?: boolean;
}>() }>();
const toast = useNotification() const toast = useNotification();
const showImport = useStorage('showImport', false) const showImport = useStorage('showImport', false);
const box = useConfirm() const box = useConfirm();
const config = useConfigStore() const config = useConfigStore();
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item))) const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)));
const import_string = ref('') const import_string = ref('');
const newExtraKey = ref('') const newExtraKey = ref('');
const newExtraValue = ref('') const newExtraValue = ref('');
const test_data = ref<{ const test_data = ref<{
show: boolean, show: boolean;
url: string, url: string;
in_progress: boolean, in_progress: boolean;
changed: boolean, changed: boolean;
data: { status: boolean | null, data: Record<string, any> } data: { status: boolean | null; data: Record<string, any> };
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } }) }>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } });
const showOptions = ref<boolean>(false) const showOptions = ref<boolean>(false);
const ytDlpOpt = ref<AutoCompleteOptions>([]) const ytDlpOpt = ref<AutoCompleteOptions>([]);
if (!form.extras) { if (!form.extras) {
form.extras = {} form.extras = {};
} }
if (form.enabled === undefined) { if (form.enabled === undefined) {
form.enabled = true form.enabled = true;
} }
if (form.priority === undefined) { if (form.priority === undefined) {
form.priority = 0 form.priority = 0;
} }
if (form.description === undefined) { if (form.description === undefined) {
form.description = '' form.description = '';
} }
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions watch(
.filter(opt => !opt.ignored) () => config.ytdlp_options,
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--')) (newOptions) =>
.map(flag => ({ value: flag, description: opt.description || '' }))), (ytDlpOpt.value = newOptions
{ immediate: true } .filter((opt) => !opt.ignored)
) .flatMap((opt) =>
opt.flags
.filter((flag) => flag.startsWith('--'))
.map((flag) => ({ value: flag, description: opt.description || '' })),
)),
{ immediate: true },
);
watch(() => form.filter, () => test_data.value.changed = true) watch(
() => form.filter,
() => (test_data.value.changed = true),
);
const checkInfo = async (): Promise<void> => { const checkInfo = async (): Promise<void> => {
const required: (keyof Condition)[] = ['name', 'filter'] const required: (keyof Condition)[] = ['name', 'filter'];
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
toast.error(`The ${key} field is required.`) toast.error(`The ${key} field is required.`);
return return;
} }
} }
if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) { if ((!form.cli || '' === form.cli.trim()) && Object.keys(form.extras).length < 1) {
toast.error('Command options for yt-dlp or at least one extra option is required.') toast.error('Command options for yt-dlp or at least one extra option is required.');
return return;
} }
if (form.cli && '' !== form.cli.trim()) { if (form.cli && '' !== form.cli.trim()) {
const options = await convertOptions(form.cli) const options = await convertOptions(form.cli);
if (options === null) { if (options === null) {
return return;
} }
form.cli = form.cli.trim() form.cli = form.cli.trim();
} }
const copy: Condition = JSON.parse(JSON.stringify(form)) const copy: Condition = JSON.parse(JSON.stringify(form));
for (const key in copy) { for (const key in copy) {
if (typeof (copy as any)[key] !== 'string') { if (typeof (copy as any)[key] !== 'string') {
continue continue;
} }
(copy as any)[key] = (copy as any)[key].trim() (copy as any)[key] = (copy as any)[key].trim();
} }
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }) emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
} };
const convertOptions = async (args: string): Promise<any | null> => { const convertOptions = async (args: string): Promise<any | null> => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args);
return response.opts return response.opts;
} catch (e: any) { } catch (e: any) {
toast.error(e.message) toast.error(e.message);
} }
return null return null;
} };
const run_test = async (): Promise<void> => { const run_test = async (): Promise<void> => {
if (!test_data.value.url) { if (!test_data.value.url) {
toast.error('The URL is required for testing.', { force: true }) toast.error('The URL is required for testing.', { force: true });
return return;
} }
try { try {
new URL(test_data.value.url) new URL(test_data.value.url);
} catch { } catch {
toast.error('The URL is invalid.', { force: true }) toast.error('The URL is invalid.', { force: true });
return return;
} }
test_data.value.in_progress = true test_data.value.in_progress = true;
test_data.value.data.status = false test_data.value.data.status = false;
try { try {
const response = await request('/api/conditions/test', { const response = await request('/api/conditions/test', {
method: 'POST', method: 'POST',
body: JSON.stringify({ url: test_data.value.url, condition: form.filter }) body: JSON.stringify({ url: test_data.value.url, condition: form.filter }),
}) });
const json = await response.json() const json = await response.json();
if (!response.ok) { if (!response.ok) {
toast.error(json.message || json.error || 'Unknown error', { force: true }) toast.error(json.message || json.error || 'Unknown error', { force: true });
return return;
} }
test_data.value.data = json test_data.value.data = json;
test_data.value.changed = false test_data.value.changed = false;
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to test condition. ${error.message}`) toast.error(`Failed to test condition. ${error.message}`);
} finally { } finally {
test_data.value.in_progress = false test_data.value.in_progress = false;
} }
} };
const importItem = async (): Promise<void> => { const importItem = async (): Promise<void> => {
const val = import_string.value.trim() const val = import_string.value.trim();
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.');
return return;
} }
try { try {
const item = decode(val) as Condition & ImportedItem const item = decode(val) as Condition & ImportedItem;
if (!item._type || item._type !== 'condition') { if (!item._type || item._type !== 'condition') {
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`) toast.error(
return `Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`,
);
return;
} }
if ((form.filter || form.cli || Object.keys(form.extras).length > 0) && !(await box.confirm('Overwrite the current form fields?'))) { if (
return (form.filter || form.cli || Object.keys(form.extras).length > 0) &&
!(await box.confirm('Overwrite the current form fields?'))
) {
return;
} }
if (item.name) { if (item.name) {
form.name = item.name form.name = item.name;
} }
if (item.filter) { if (item.filter) {
form.filter = item.filter form.filter = item.filter;
} }
if (item.cli) { if (item.cli) {
form.cli = item.cli form.cli = item.cli;
} }
if (item.extras) { if (item.extras) {
form.extras = { ...item.extras } form.extras = { ...item.extras };
} }
if (item.enabled !== undefined) { if (item.enabled !== undefined) {
form.enabled = item.enabled form.enabled = item.enabled;
} }
if (item.priority !== undefined) { if (item.priority !== undefined) {
form.priority = item.priority form.priority = item.priority;
} }
if (item.description !== undefined) { if (item.description !== undefined) {
form.description = item.description form.description = item.description;
} }
import_string.value = '' import_string.value = '';
showImport.value = false showImport.value = false;
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Failed to parse import string. ${e.message}`) toast.error(`Failed to parse import string. ${e.message}`);
} }
} };
const show_data = (): string => { const show_data = (): string => {
if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) { if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) {
return 'No data to show.' return 'No data to show.';
} }
return JSON.stringify(test_data.value.data.data, null, 2) return JSON.stringify(test_data.value.data.data, null, 2);
} };
const logic_test = computed(() => { const logic_test = computed(() => {
if (Object.keys(test_data.value.data?.data ?? {}).length < 1) { if (Object.keys(test_data.value.data?.data ?? {}).length < 1) {
return null return null;
} }
if (!test_data.value.changed) { if (!test_data.value.changed) {
return test_data.value.data.status return test_data.value.data.status;
} }
try { try {
const st = match_str(form.filter, test_data.value.data.data) const st = match_str(form.filter, test_data.value.data.data);
console.log('Logic test:', st, form.filter, test_data.value.data.data) console.log('Logic test:', st, form.filter, test_data.value.data.data);
return st return st;
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
return false return false;
} }
}) });
const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key) const validateKey = (key: string): boolean => /^[a-z][a-z0-9_]*$/.test(key);
const parseValue = (value: string): string | number | boolean => { const parseValue = (value: string): string | number | boolean => {
if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) { if (!isNaN(Number(value)) && !isNaN(parseFloat(value))) {
return Number(value) return Number(value);
} }
if ('true' === value.toLowerCase()) { if ('true' === value.toLowerCase()) {
return true return true;
} }
if ('false' === value.toLowerCase()) { if ('false' === value.toLowerCase()) {
return false return false;
} }
return value return value;
} };
const addExtra = (): void => { const addExtra = (): void => {
const key = newExtraKey.value.trim() const key = newExtraKey.value.trim();
const value = newExtraValue.value.trim() const value = newExtraValue.value.trim();
if (!key || !value) { if (!key || !value) {
toast.error('Both key and value are required.') toast.error('Both key and value are required.');
return return;
} }
if (!validateKey(key)) { if (!validateKey(key)) {
toast.error('Key must be lower_case.') toast.error('Key must be lower_case.');
return return;
} }
if (form.extras[key]) { if (form.extras[key]) {
toast.error(`Key '${key}' already exists.`) toast.error(`Key '${key}' already exists.`);
return return;
} }
form.extras[key] = parseValue(value) form.extras[key] = parseValue(value);
newExtraKey.value = '' newExtraKey.value = '';
newExtraValue.value = '' newExtraValue.value = '';
} };
const removeExtra = (key: string): void => { const removeExtra = (key: string): void => {
const { [key]: _, ...rest } = form.extras const { [key]: _, ...rest } = form.extras;
form.extras = rest form.extras = rest;
} };
const updateExtraKey = (event: Event, oldKey: string): void => { const updateExtraKey = (event: Event, oldKey: string): void => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement;
const newKey = target.value.trim() const newKey = target.value.trim();
if (!newKey) { if (!newKey) {
return return;
} }
if (!validateKey(newKey)) { if (!validateKey(newKey)) {
toast.error('Key must be lowercase and contain only letters, numbers, and underscores.') toast.error('Key must be lowercase and contain only letters, numbers, and underscores.');
target.value = oldKey target.value = oldKey;
return return;
} }
if (newKey !== oldKey) { if (newKey !== oldKey) {
if (form.extras[newKey]) { if (form.extras[newKey]) {
toast.error(`Key '${newKey}' already exists.`) toast.error(`Key '${newKey}' already exists.`);
target.value = oldKey target.value = oldKey;
return return;
} }
const value = form.extras[oldKey] const value = form.extras[oldKey];
const { [oldKey]: _, ...rest } = form.extras const { [oldKey]: _, ...rest } = form.extras;
form.extras = { ...rest, [newKey]: value } form.extras = { ...rest, [newKey]: value };
} }
} };
const updateExtraValue = (key: string, event: Event): void => { const updateExtraValue = (key: string, event: Event): void => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement;
const value = target.value.trim() const value = target.value.trim();
form.extras[key] = value ? parseValue(value) : '' form.extras[key] = value ? parseValue(value) : '';
} };
</script> </script>

View file

@ -9,11 +9,20 @@
<section class="modal-card-body"> <section class="modal-card-body">
<p class="mb-3 title is-5" v-if="!html_message">{{ message }}</p> <p class="mb-3 title is-5" v-if="!html_message">{{ message }}</p>
<div class="content" v-if="html_message" v-html="html_message" style="max-height: 40vh; overflow: auto;" /> <div
class="content"
v-if="html_message"
v-html="html_message"
style="max-height: 40vh; overflow: auto"
/>
<div v-if="options?.length"> <div v-if="options?.length">
<hr class=""> <hr class="" />
<label v-for="opt in options" :key="opt.key" class="checkbox is-block mb-2 is-unselectable"> <label
v-for="opt in options"
:key="opt.key"
class="checkbox is-block mb-2 is-unselectable"
>
<input type="checkbox" v-model="selected[opt.key]" class="mr-2" /> <input type="checkbox" v-model="selected[opt.key]" class="mr-2" />
{{ opt.label }} {{ opt.label }}
</label> </label>
@ -21,14 +30,23 @@
</section> </section>
<footer class="modal-card-foot p-5"> <footer class="modal-card-foot p-5">
<div class="field is-grouped" style="width:100%"> <div class="field is-grouped" style="width: 100%">
<div class="control is-expanded"> <div class="control is-expanded">
<button ref="confirmBtn" class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm"> <button
ref="confirmBtn"
class="button is-fullwidth"
:class="confirm_button_color"
@click="handleConfirm"
>
{{ confirm_button_label }} {{ confirm_button_label }}
</button> </button>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-success is-fullwidth" :class="cancel_button_color" @click="cancel"> <button
class="button is-success is-fullwidth"
:class="cancel_button_color"
@click="cancel"
>
{{ cancel_button_label }} {{ cancel_button_label }}
</button> </button>
</div> </div>
@ -39,74 +57,77 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
const props = defineProps({ const props = defineProps({
visible: { visible: {
type: Boolean, type: Boolean,
required: true required: true,
}, },
title: { title: {
type: String, type: String,
default: 'Confirm' default: 'Confirm',
}, },
message: { message: {
type: String, type: String,
default: 'Are you sure?' default: 'Are you sure?',
}, },
html_message: { html_message: {
type: String, type: String,
default: '' default: '',
}, },
options: { options: {
type: Array as () => Array<{ key: string; label: string, checked?: boolean }>, type: Array as () => Array<{ key: string; label: string; checked?: boolean }>,
default: () => [] default: () => [],
}, },
confirm_button_label: { confirm_button_label: {
type: String, type: String,
default: 'Confirm' default: 'Confirm',
}, },
cancel_button_label: { cancel_button_label: {
type: String, type: String,
default: 'Cancel' default: 'Cancel',
}, },
confirm_button_color: { confirm_button_color: {
type: String, type: String,
default: 'is-danger' default: 'is-danger',
}, },
cancel_button_color: { cancel_button_color: {
type: String, type: String,
default: 'is-success' default: 'is-success',
} },
}) });
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'confirm', options: Record<string, boolean>): void (e: 'confirm', options: Record<string, boolean>): void;
(e: 'cancel'): void (e: 'cancel'): void;
}>() }>();
const confirmBtn = ref<HTMLButtonElement | null>(null) const confirmBtn = ref<HTMLButtonElement | null>(null);
const selected = reactive<Record<string, boolean>>({}) const selected = reactive<Record<string, boolean>>({});
watch(() => props.visible, async visible => { watch(
if (!visible) { () => props.visible,
return async (visible) => {
} if (!visible) {
return;
if (props.options) {
for (const opt of props.options) {
selected[opt.key] ??= false
} }
}
await nextTick() if (props.options) {
confirmBtn.value?.focus() for (const opt of props.options) {
}, { immediate: true }) selected[opt.key] ??= false;
}
}
await nextTick();
confirmBtn.value?.focus();
},
{ immediate: true },
);
const handleConfirm = () => emit('confirm', { ...selected }) const handleConfirm = () => emit('confirm', { ...selected });
const cancel = () => emit('cancel') const cancel = () => emit('cancel');
onMounted(() => disableOpacity()) onMounted(() => disableOpacity());
onBeforeUnmount(() => enableOpacity()) onBeforeUnmount(() => enableOpacity());
</script> </script>

View file

@ -1,21 +1,26 @@
<template> <template>
<div class="message is-warning" v-if="status !== 'connected'"> <div class="message is-warning" v-if="status !== 'connected'">
<div class="message-body"> <div class="message-body">
<span class="icon"><i class="fas" <span class="icon"
:class="{ 'fa-info-circle': status === 'disconnected', 'fa-spinner fa-pulse': status === 'connecting' }" /></span> ><i
class="fas"
:class="{
'fa-info-circle': status === 'disconnected',
'fa-spinner fa-pulse': status === 'connecting',
}"
/></span>
<span v-if="status === 'disconnected'"> <span v-if="status === 'disconnected'">
Websocket connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here Websocket connection lost,
</NuxtLink> to reconnect. <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here </NuxtLink>
</span> to reconnect.
<span v-else-if="status === 'connecting'">
Connecting to websocket server...
</span> </span>
<span v-else-if="status === 'connecting'"> Connecting to websocket server... </span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { connectionStatus } from '~/stores/SocketStore' import type { connectionStatus } from '~/stores/SocketStore';
defineProps<{ 'status': connectionStatus }>() defineProps<{ status: connectionStatus }>();
defineEmits<{ (e: "reconnect"): void }>() defineEmits<{ (e: 'reconnect'): void }>();
</script> </script>

View file

@ -6,16 +6,22 @@
<div class="card-header"> <div class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
/></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</div> </div>
<div class="card-header-icon" v-if="reference"> <div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport"> <button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"
'fa-arrow-down': !showImport, ><i
'fa-arrow-up': showImport, class="fa-solid"
}" /></span> :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}"
/></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span> <span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button> </button>
</div> </div>
@ -31,11 +37,22 @@
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off"> <input
type="text"
class="input"
id="import_string"
v-model="import_string"
autocomplete="off"
/>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem"> <button
class="button is-primary"
:disabled="!import_string"
type="button"
@click="importItem"
>
<span class="icon"><i class="fa-solid fa-add" /></span> <span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span> <span>Import</span>
</button> </button>
@ -65,7 +82,12 @@
<span class="icon"><i class="fas fa-info-circle" /></span> <span class="icon"><i class="fas fa-info-circle" /></span>
<span>Field Description</span> <span>Field Description</span>
</label> </label>
<input type="text" v-model="form.description" class="input" :disabled="addInProgress" /> <input
type="text"
v-model="form.description"
class="input"
:disabled="addInProgress"
/>
<span class="help is-bold"> <span class="help is-bold">
A short description of the field, it will be shown in the UI. A short description of the field, it will be shown in the UI.
</span> </span>
@ -80,11 +102,17 @@
</label> </label>
<div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }"> <div class="select is-fullwidth" :class="{ 'is-loading': addInProgress }">
<select v-model="form.kind" :disabled="addInProgress" class="is-capitalized"> <select v-model="form.kind" :disabled="addInProgress" class="is-capitalized">
<option v-for="kind in fieldTypes" :key="`kind-${kind}`" :value="kind" v-text="kind" /> <option
v-for="kind in fieldTypes"
:key="`kind-${kind}`"
:value="kind"
v-text="kind"
/>
</select> </select>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox. Field Type. String is a single line input, Text is a multi-line input, Bool is a
checkbox.
</span> </span>
</div> </div>
</div> </div>
@ -95,24 +123,36 @@
<span class="icon"><i class="fas fa-terminal" /></span> <span class="icon"><i class="fas fa-terminal" /></span>
<span>Associated yt-dlp option</span> <span>Associated yt-dlp option</span>
</label> </label>
<InputAutocomplete v-model="form.field" :options="ytDlpOptions" :disabled="addInProgress" <InputAutocomplete
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" /> v-model="form.field"
:options="ytDlpOptions"
:disabled="addInProgress"
placeholder="Type or select a yt-dlp option"
:multiple="false"
:openOnFocus="true"
/>
<span class="help is-bold"> <span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>. The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
<code>-w</code>.
</span> </span>
</div> </div>
</div> </div>
<div class="column is-6 is-12-mobile"> <div class="column is-6 is-12-mobile">
<div class="field"> <div class="field">
<label class="label"> <label class="label">
<span class="icon"><i class="fas fa-sort-numeric-up" /></span> <span class="icon"><i class="fas fa-sort-numeric-up" /></span>
<span>Field Order</span> <span>Field Order</span>
</label> </label>
<input type="number" v-model.number="form.order" class="input" :disabled="addInProgress" /> <input
type="number"
v-model.number="form.order"
class="input"
:disabled="addInProgress"
/>
<span class="help is-bold"> <span class="help is-bold">
The order of the field, used to sort the fields in the UI. Lower numbers will appear first. The order of the field, used to sort the fields in the UI. Lower numbers will
appear first.
</span> </span>
</div> </div>
</div> </div>
@ -125,9 +165,11 @@
</label> </label>
<input type="text" v-model="form.icon" class="input" :disabled="addInProgress" /> <input type="text" v-model="form.icon" class="input" :disabled="addInProgress" />
<span class="help is-bold"> <span class="help is-bold">
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r" The icon of the field, must be from
target="_blank"> <NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon. font-awesome</NuxtLink
>
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
</span> </span>
</div> </div>
</div> </div>
@ -137,15 +179,24 @@
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
<div class="card-footer-item"> <div class="card-footer-item">
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit" <button
:class="{ 'is-loading': addInProgress }" form="dlFieldForm"> class="button is-fullwidth is-primary"
:disabled="addInProgress"
type="submit"
:class="{ 'is-loading': addInProgress }"
form="dlFieldForm"
>
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress" <button
type="button"> class="button is-fullwidth is-danger"
@click="emitter('cancel')"
:disabled="addInProgress"
type="button"
>
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -159,161 +210,172 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue';
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete';
import type { DLField } from '~/types/dl_fields' import type { DLField } from '~/types/dl_fields';
import type { ImportedItem } from '~/types' import type { ImportedItem } from '~/types';
import { decode } from '~/utils' import { decode } from '~/utils';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void;
(e: 'submit', payload: { reference: number | null | undefined, item: DLField }): void (e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
}>() }>();
const props = defineProps<{ const props = defineProps<{
reference?: number | null reference?: number | null;
item: DLField item: DLField;
addInProgress?: boolean addInProgress?: boolean;
}>() }>();
const toast = useNotification() const toast = useNotification();
const box = useConfirm() const box = useConfirm();
const config = useConfigStore() const config = useConfigStore();
const fieldTypes = ['string', 'text', 'bool'] as const const fieldTypes = ['string', 'text', 'bool'] as const;
const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item))) const form = reactive<DLField>(JSON.parse(JSON.stringify(props.item)));
const ytDlpOptions = ref<AutoCompleteOptions>([]) const ytDlpOptions = ref<AutoCompleteOptions>([]);
const showImport = useStorage('showDlFieldsImport', false) const showImport = useStorage('showDlFieldsImport', false);
const import_string = ref('') const import_string = ref('');
if (!form.extras) { if (!form.extras) {
form.extras = {} form.extras = {};
} }
if (!form.kind) { if (!form.kind) {
form.kind = 'string' form.kind = 'string';
} }
if (!form.description) { if (!form.description) {
form.description = '' form.description = '';
} }
if (!form.value) { if (!form.value) {
form.value = '' form.value = '';
} }
if (!form.icon) { if (!form.icon) {
form.icon = '' form.icon = '';
} }
if (!form.order) { if (!form.order) {
form.order = 1 form.order = 1;
} }
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions watch(
.filter(opt => !opt.ignored) () => config.ytdlp_options,
.flatMap(opt => opt.flags.filter(flag => flag.startsWith('--')) (newOptions) =>
.map(flag => ({ value: flag, description: opt.description || '' }))), (ytDlpOptions.value = newOptions
{ immediate: true } .filter((opt) => !opt.ignored)
) .flatMap((opt) =>
opt.flags
.filter((flag) => flag.startsWith('--'))
.map((flag) => ({ value: flag, description: opt.description || '' })),
)),
{ immediate: true },
);
const importItem = async (): Promise<void> => { const importItem = async (): Promise<void> => {
const val = import_string.value.trim() const val = import_string.value.trim();
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.');
return return;
} }
try { try {
const item = decode(val) as DLField & ImportedItem const item = decode(val) as DLField & ImportedItem;
if (!item._type || item._type !== 'dl_field') { if (!item._type || item._type !== 'dl_field') {
toast.error(`Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`) toast.error(
return `Invalid import string. Expected type 'dl_field', got '${item._type ?? 'unknown'}'.`,
);
return;
} }
if ((form.name || form.field || form.description) && !(await box.confirm('Overwrite the current form fields?'))) { if (
return (form.name || form.field || form.description) &&
!(await box.confirm('Overwrite the current form fields?'))
) {
return;
} }
if (item.name) { if (item.name) {
form.name = item.name form.name = item.name;
} }
if (item.field) { if (item.field) {
form.field = item.field form.field = item.field;
} }
if (item.description !== undefined) { if (item.description !== undefined) {
form.description = item.description form.description = item.description;
} }
if (item.kind) { if (item.kind) {
form.kind = item.kind form.kind = item.kind;
} }
if (item.icon !== undefined) { if (item.icon !== undefined) {
form.icon = item.icon form.icon = item.icon;
} }
if (item.order !== undefined) { if (item.order !== undefined) {
form.order = item.order form.order = item.order;
} }
if (item.value !== undefined) { if (item.value !== undefined) {
form.value = item.value form.value = item.value;
} }
if (item.extras) { if (item.extras) {
form.extras = { ...item.extras } form.extras = { ...item.extras };
} }
import_string.value = '' import_string.value = '';
showImport.value = false showImport.value = false;
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Failed to parse import string. ${e.message}`) toast.error(`Failed to parse import string. ${e.message}`);
} }
} };
const checkInfo = (): void => { const checkInfo = (): void => {
const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description'] const required: (keyof DLField)[] = ['name', 'field', 'kind', 'description'];
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
toast.error(`The ${key} field is required.`) toast.error(`The ${key} field is required.`);
return return;
} }
} }
if (!form.order || form.order < 1) { if (!form.order || form.order < 1) {
toast.error('Order must be a positive number.') toast.error('Order must be a positive number.');
return return;
} }
if (!fieldTypes.includes(form.kind)) { if (!fieldTypes.includes(form.kind)) {
toast.error(`Invalid field type: ${form.kind}`) toast.error(`Invalid field type: ${form.kind}`);
return return;
} }
if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) { if (!/^--[a-zA-Z0-9-]+$/.test(form.field)) {
toast.error('Invalid field format, it must start with "--" and contain no spaces.') toast.error('Invalid field format, it must start with "--" and contain no spaces.');
return return;
} }
const copy: DLField = JSON.parse(JSON.stringify(form)) const copy: DLField = JSON.parse(JSON.stringify(form));
const entries = copy as unknown as Record<string, unknown> const entries = copy as unknown as Record<string, unknown>;
for (const key in entries) { for (const key in entries) {
if ('string' !== typeof entries[key]) { if ('string' !== typeof entries[key]) {
continue continue;
} }
entries[key] = entries[key].trim() entries[key] = entries[key].trim();
} }
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }) emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
} };
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<form class="modal is-active" @submit.prevent="updateItems"> <form class="modal is-active" @submit.prevent="updateItems">
<div class="modal-background" @click="cancel" /> <div class="modal-background" @click="cancel" />
<div class="modal-card" style="min-width:calc(100vw - 30vw);"> <div class="modal-card" style="min-width: calc(100vw - 30vw)">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Manage Custom Fields</p> <p class="modal-card-title">Manage Custom Fields</p>
<button type="button" class="delete" aria-label="close" @click="cancel" /> <button type="button" class="delete" aria-label="close" @click="cancel" />
@ -18,21 +18,33 @@
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button type="button" class="button is-primary" @click="addNewField" :disabled="isLoading"> <button
type="button"
class="button is-primary"
@click="addNewField"
:disabled="isLoading"
>
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button type="button" class="button is-info" @click="loadContent()" <button
:class="{ 'is-loading': isLoading }" :disabled="isLoading"> type="button"
class="button is-info"
@click="loadContent()"
:class="{ 'is-loading': isLoading }"
:disabled="isLoading"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
</p> </p>
</div> </div>
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">The idea behind the custom fields is to allow you to add new fields to the download <span class="subtitle"
form to automate some of the yt-dlp options.</span> >The idea behind the custom fields is to allow you to add new fields to the download
form to automate some of the yt-dlp options.</span
>
</div> </div>
</div> </div>
@ -40,7 +52,11 @@
<div class="box"> <div class="box">
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-12 is-12-mobile"> <div class="column is-12 is-12-mobile">
<NuxtLink @click="deleteField(index)" :disabled="isLoading" class="has-text-danger"> <NuxtLink
@click="deleteField(index)"
:disabled="isLoading"
class="has-text-danger"
>
<span class="icon"><i class="fas fa-trash" /></span> <span class="icon"><i class="fas fa-trash" /></span>
<span>Delete Field</span> <span>Delete Field</span>
</NuxtLink> </NuxtLink>
@ -54,12 +70,17 @@
</label> </label>
<div class="select is-fullwidth" :class="{ 'is-loading': isLoading }"> <div class="select is-fullwidth" :class="{ 'is-loading': isLoading }">
<select v-model="item.kind" :disabled="isLoading" class="is-capitalized"> <select v-model="item.kind" :disabled="isLoading" class="is-capitalized">
<option v-for="kind in Object.values(FieldTypes)" :key="`kind-${kind}`" :value="kind" <option
v-text="kind" /> v-for="kind in Object.values(FieldTypes)"
:key="`kind-${kind}`"
:value="kind"
v-text="kind"
/>
</select> </select>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
Field Type. String is a single line input, Text is a multi-line input, Bool is a checkbox. Field Type. String is a single line input, Text is a multi-line input, Bool is
a checkbox.
</span> </span>
</div> </div>
</div> </div>
@ -81,10 +102,17 @@
<span class="icon"><i class="fas fa-terminal" /></span> <span class="icon"><i class="fas fa-terminal" /></span>
<span>Associated yt-dlp option</span> <span>Associated yt-dlp option</span>
</label> </label>
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading" <InputAutocomplete
placeholder="Type or select a yt-dlp option" :multiple="false" :openOnFocus="true" /> v-model="item.field"
:options="ytDlpOptions"
:disabled="isLoading"
placeholder="Type or select a yt-dlp option"
:multiple="false"
:openOnFocus="true"
/>
<span class="help is-bold"> <span class="help is-bold">
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>. The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not
<code>-w</code>.
</span> </span>
</div> </div>
</div> </div>
@ -94,7 +122,12 @@
<span class="icon"><i class="fas fa-info-circle" /></span> <span class="icon"><i class="fas fa-info-circle" /></span>
<span>Field Description</span> <span>Field Description</span>
</label> </label>
<input type="text" v-model="item.description" class="input" :disabled="isLoading" /> <input
type="text"
v-model="item.description"
class="input"
:disabled="isLoading"
/>
<span class="help is-bold"> <span class="help is-bold">
A short description of the field, it will be shown in the UI. A short description of the field, it will be shown in the UI.
</span> </span>
@ -109,7 +142,8 @@
</label> </label>
<input type="number" v-model="item.order" class="input" :disabled="isLoading" /> <input type="number" v-model="item.order" class="input" :disabled="isLoading" />
<span class="help is-bold"> <span class="help is-bold">
The order of the field, used to sort the fields in the UI. Lower numbers will appear first. The order of the field, used to sort the fields in the UI. Lower numbers will
appear first.
</span> </span>
</div> </div>
</div> </div>
@ -122,9 +156,11 @@
</label> </label>
<input type="text" v-model="item.icon" class="input" :disabled="isLoading" /> <input type="text" v-model="item.icon" class="input" :disabled="isLoading" />
<span class="help is-bold"> <span class="help is-bold">
The icon of the field, must be from <NuxtLink href="https://fontawesome.com/search?ic=free&o=r" The icon of the field, must be from
target="_blank"> <NuxtLink href="https://fontawesome.com/search?ic=free&o=r" target="_blank">
font-awesome</NuxtLink> icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon. font-awesome</NuxtLink
>
icon. e.g. <code>fa-solid fa-image</code>. Leave empty for no icon.
</span> </span>
</div> </div>
</div> </div>
@ -140,7 +176,7 @@
</div> </div>
</section> </section>
<footer class="modal-card-foot p-5"> <footer class="modal-card-foot p-5">
<div class="field is-grouped" style="width:100%"> <div class="field is-grouped" style="width: 100%">
<div class="control is-expanded"> <div class="control is-expanded">
<button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading"> <button type="submit" class="button is-fullwidth is-primary" :disabled="isLoading">
<span class="icon"><i class="fas fa-save" /></span> <span class="icon"><i class="fas fa-save" /></span>
@ -148,7 +184,12 @@
</button> </button>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<button type="button" class="button is-fullwidth is-danger" @click="cancel" :disabled="isLoading"> <button
type="button"
class="button is-fullwidth is-danger"
@click="cancel"
:disabled="isLoading"
>
<span class="icon"><i class="fas fa-times" /></span> <span class="icon"><i class="fas fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -160,132 +201,139 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue';
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue';
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
import type { DLField } from '~/types/dl_fields' import type { DLField } from '~/types/dl_fields';
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete';
const emitter = defineEmits<{ (e: 'cancel'): void }>() const emitter = defineEmits<{ (e: 'cancel'): void }>();
const toast = useNotification() const toast = useNotification();
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
const items = ref<DLField[]>([]) const items = ref<DLField[]>([]);
const config = useConfigStore() const config = useConfigStore();
const ytDlpOptions = ref<AutoCompleteOptions>([]) const ytDlpOptions = ref<AutoCompleteOptions>([]);
const FieldTypes = { const FieldTypes = {
STRING: 'string', STRING: 'string',
TEXT: 'text', TEXT: 'text',
BOOL: 'bool' BOOL: 'bool',
} };
const cancel = () => emitter('cancel') const cancel = () => emitter('cancel');
const loadContent = async () => { const loadContent = async () => {
try { try {
isLoading.value = true isLoading.value = true;
const response = await request('/api/dl_fields') const response = await request('/api/dl_fields');
const data = await response.json() const data = await response.json();
if (0 === data.length) { if (0 === data.length) {
return return;
} }
items.value = data items.value = data;
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error('Failed to fetch page content.') toast.error('Failed to fetch page content.');
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
} };
const updateItems = async () => { const updateItems = async () => {
for (const item of items.value) { for (const item of items.value) {
if (validateItem(item, items.value.indexOf(item) + 1)) { if (validateItem(item, items.value.indexOf(item) + 1)) {
continue continue;
} }
return return;
} }
try { try {
isLoading.value = true isLoading.value = true;
const resp = await request('/api/dl_fields', { const resp = await request('/api/dl_fields', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(items.value) body: JSON.stringify(items.value),
}) });
if (!resp.ok) { if (!resp.ok) {
const error = await resp.json() const error = await resp.json();
toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`) toast.error(`Failed to update fields: ${error.error || 'Unknown error'}`);
return return;
} }
toast.success('Fields updated successfully.') toast.success('Fields updated successfully.');
emitter('cancel') emitter('cancel');
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
} };
const addNewField = () => items.value.push({ const addNewField = () =>
name: '', items.value.push({
description: '', name: '',
kind: 'string', description: '',
field: '', kind: 'string',
value: '', field: '',
icon: '', value: '',
order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1, icon: '',
extras: {} order: items.value.reduce((max, item) => Math.max(max, item.order || 1), 0) + 1,
}) extras: {},
});
const deleteField = (index: number) => items.value.splice(index, 1) const deleteField = (index: number) => items.value.splice(index, 1);
const validateItem = (item: DLField, index: number): boolean => { const validateItem = (item: DLField, index: number): boolean => {
const requiredFields = ['name', 'field', 'kind', 'description'] const requiredFields = ['name', 'field', 'kind', 'description'];
for (const field of requiredFields) { for (const field of requiredFields) {
if (!item[field as keyof DLField]) { if (!item[field as keyof DLField]) {
toast.error(`${item.name || index}: Field ${field} is required.`) toast.error(`${item.name || index}: Field ${field} is required.`);
return false return false;
} }
} }
if (!item.order || item.order < 1) { if (!item.order || item.order < 1) {
toast.error(`${item.name || index}: Order must be a positive number.`) toast.error(`${item.name || index}: Order must be a positive number.`);
return false return false;
} }
if (!Object.values(FieldTypes).includes(item.kind)) { if (!Object.values(FieldTypes).includes(item.kind)) {
toast.error(`${item.name || index}: Invalid field type: ${item.kind}`) toast.error(`${item.name || index}: Invalid field type: ${item.kind}`);
return false return false;
} }
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) { if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`) toast.error(
return false `${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`,
);
return false;
} }
return true return true;
} };
// eslint-disable-next-line vue/no-side-effects-in-computed-properties // eslint-disable-next-line vue/no-side-effects-in-computed-properties
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0))) const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)));
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions watch(
.filter(opt => !opt.ignored) () => config.ytdlp_options,
.flatMap( (newOptions) =>
opt => opt.flags.filter(flag => flag.startsWith('--') (ytDlpOptions.value = newOptions
).map(flag => ({ value: flag, description: opt.description || '' }))), .filter((opt) => !opt.ignored)
{ immediate: true } .flatMap((opt) =>
) opt.flags
.filter((flag) => flag.startsWith('--'))
.map((flag) => ({ value: flag, description: opt.description || '' })),
)),
{ immediate: true },
);
onMounted(async () => { onMounted(async () => {
disableOpacity() disableOpacity();
await loadContent() await loadContent();
}) });
onBeforeUnmount(() => enableOpacity()) onBeforeUnmount(() => enableOpacity());
</script> </script>

View file

@ -6,19 +6,40 @@
</template> </template>
<template v-else> <template v-else>
<span v-if="icon" class="icon"><i :class="icon" /></span> <span v-if="icon" class="icon"><i :class="icon" /></span>
<span v-tooltip="field ? `yt-dlp option: ${field}` : null" :class="{ 'has-tooltip': field }">{{ label }}</span> <span
v-tooltip="field ? `yt-dlp option: ${field}` : null"
:class="{ 'has-tooltip': field }"
>{{ label }}</span
>
</template> </template>
</label> </label>
<div class="control is-expanded" v-if="'string' === type"> <div class="control is-expanded" v-if="'string' === type">
<input :id="`dlf-${id}`" :type="type" class="input" v-model="model" :placeholder="placeholder" <input
:disabled="disabled" /> :id="`dlf-${id}`"
:type="type"
class="input"
v-model="model"
:placeholder="placeholder"
:disabled="disabled"
/>
</div> </div>
<div class="control is-expanded" v-if="'text' === type"> <div class="control is-expanded" v-if="'text' === type">
<textarea class="textarea is-pre" :id="`dlf-${id}`" v-model="model" :placeholder="placeholder" <textarea
:disabled="disabled"></textarea> class="textarea is-pre"
:id="`dlf-${id}`"
v-model="model"
:placeholder="placeholder"
:disabled="disabled"
></textarea>
</div> </div>
<div class="control is-expanded" v-if="'bool' === type"> <div class="control is-expanded" v-if="'bool' === type">
<input type="checkbox" :id="`dlf-${id}`" v-model="model" class="switch is-success" :disabled="disabled" /> <input
type="checkbox"
:id="`dlf-${id}`"
v-model="model"
class="switch is-success"
:disabled="disabled"
/>
<label :for="`dlf-${id}`" class="label is-unselectable"> <label :for="`dlf-${id}`" class="label is-unselectable">
{{ model ? 'Yes' : 'No' }} {{ model ? 'Yes' : 'No' }}
</label> </label>
@ -39,14 +60,14 @@
import type { ModelRef } from 'vue'; import type { ModelRef } from 'vue';
import type { DLFieldType } from '~/types/dl_fields'; import type { DLFieldType } from '~/types/dl_fields';
defineProps<{ defineProps<{
id: number|string, id: number | string;
label: string, label: string;
field?: string, field?: string;
type: DLFieldType, type: DLFieldType;
description?: string description?: string;
icon?: string icon?: string;
placeholder?: string placeholder?: string;
disabled?: boolean disabled?: boolean;
}>() }>();
const model = defineModel() as ModelRef<string> const model = defineModel() as ModelRef<string>;
</script> </script>

View file

@ -2,7 +2,7 @@
/* container fades */ /* container fades */
.dialog-enter-active, .dialog-enter-active,
.dialog-leave-active { .dialog-leave-active {
transition: opacity .18s ease; transition: opacity 0.18s ease;
} }
.dialog-enter-from, .dialog-enter-from,
@ -13,24 +13,31 @@
/* animate the card itself */ /* animate the card itself */
.dialog-enter-active .modal-card, .dialog-enter-active .modal-card,
.dialog-leave-active .modal-card { .dialog-leave-active .modal-card {
transition: transform .18s ease, opacity .18s ease; transition:
transform 0.18s ease,
opacity 0.18s ease;
} }
.dialog-enter-from .modal-card { .dialog-enter-from .modal-card {
transform: translateY(-8px); transform: translateY(-8px);
opacity: .98; opacity: 0.98;
} }
.dialog-leave-to .modal-card { .dialog-leave-to .modal-card {
transform: translateY(-8px); transform: translateY(-8px);
opacity: .98; opacity: 0.98;
} }
</style> </style>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<transition name="dialog" @after-enter="focusInput"> <transition name="dialog" @after-enter="focusInput">
<div id="app-dialog-host" v-if="state.current" class="modal is-active" @keydown.esc="onCancel"> <div
id="app-dialog-host"
v-if="state.current"
class="modal is-active"
@keydown.esc="onCancel"
>
<div class="modal-background" @click="onCancel" /> <div class="modal-background" @click="onCancel" />
<div class="modal-card" @keydown.enter.stop.prevent="onEnter"> <div class="modal-card" @keydown.enter.stop.prevent="onEnter">
<header class="modal-card-head p-4"> <header class="modal-card-head p-4">
@ -46,8 +53,14 @@
<!-- prompt input --> <!-- prompt input -->
<div v-if="'prompt' === state.current?.type" class="field"> <div v-if="'prompt' === state.current?.type" class="field">
<div class="control"> <div class="control">
<input ref="inputEl" class="input" type="text" v-model="localInput" <input
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop /> ref="inputEl"
class="input"
type="text"
v-model="localInput"
:placeholder="(state.current?.opts as any)?.placeholder ?? ''"
@keyup.stop
/>
</div> </div>
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable"> <p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
<span class="icon-text"> <span class="icon-text">
@ -56,8 +69,14 @@
</span> </span>
</p> </p>
</div> </div>
<div v-else-if="'confirm' === state.current?.type && (state.current?.opts as ConfirmOptions)?.rawHTML" <div
class="content" v-html="(state.current?.opts as ConfirmOptions)?.rawHTML" /> v-else-if="
'confirm' === state.current?.type &&
(state.current?.opts as ConfirmOptions)?.rawHTML
"
class="content"
v-html="(state.current?.opts as ConfirmOptions)?.rawHTML"
/>
</section> </section>
<footer class="modal-card-foot p-4 is-justify-content-flex-end"> <footer class="modal-card-foot p-4 is-justify-content-flex-end">
@ -70,12 +89,18 @@
</button> </button>
</template> </template>
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type"> <template
v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type"
>
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button id="primaryButton" class="button" @click="onEnter" <button
id="primaryButton"
class="button"
@click="onEnter"
:class="state.current?.opts.confirmColor ?? 'is-primary'" :class="state.current?.opts.confirmColor ?? 'is-primary'"
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial"> :disabled="localInput === (state.current?.opts as PromptOptions)?.initial"
>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span> <span class="icon"><i class="fas fa-check" /></span>
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span> <span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
@ -100,58 +125,61 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue' import { ref, watch, nextTick, computed } from 'vue';
import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog' import { useDialog, type ConfirmOptions, type PromptOptions } from '~/composables/useDialog';
const { state, confirm, cancel } = useDialog() const { state, confirm, cancel } = useDialog();
const localInput = ref('') const localInput = ref('');
watch(() => state.current, (cur) => { watch(
if (state.current) { () => state.current,
disableOpacity() (cur) => {
} if (state.current) {
else { disableOpacity();
enableOpacity() } else {
} enableOpacity();
}
localInput.value = 'prompt' === cur?.type ? (cur.opts as any).initial ?? '' : '' localInput.value = 'prompt' === cur?.type ? ((cur.opts as any).initial ?? '') : '';
}, { immediate: true }) },
{ immediate: true },
);
const inputEl = ref<HTMLInputElement>() const inputEl = ref<HTMLInputElement>();
const focusPrimary = () => { const focusPrimary = () => {
const root = document.getElementById('app-dialog-host') const root = document.getElementById('app-dialog-host');
if (!root) { if (!root) {
return return;
} }
const btn = root.querySelector<HTMLButtonElement>('#primaryButton') const btn = root.querySelector<HTMLButtonElement>('#primaryButton');
btn?.focus() btn?.focus();
} };
const focusInput = async () => { const focusInput = async () => {
await nextTick() await nextTick();
if ('prompt' === state.current?.type) { if ('prompt' === state.current?.type) {
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true })) requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }));
return return;
} }
requestAnimationFrame(focusPrimary) requestAnimationFrame(focusPrimary);
} };
const onCancel = () => cancel() const onCancel = () => cancel();
const onEnter = () => confirm(localInput.value) const onEnter = () => confirm(localInput.value);
const defaultTitle = computed(() => { const defaultTitle = computed(() => {
if (!state.current) { if (!state.current) {
return '' return '';
} }
switch (state.current.type) { switch (state.current.type) {
case 'alert': case 'alert':
return 'Alert' return 'Alert';
case 'confirm': case 'confirm':
return 'Confirm' return 'Confirm';
case 'prompt': case 'prompt':
return 'Input required' return 'Input required';
default: default:
return 'Dialog' return 'Dialog';
} }
}) });
</script> </script>

View file

@ -1,8 +1,14 @@
<template> <template>
<div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown"> <div class="dropdown" :class="{ 'is-active': isOpen, 'drop-up': dropUp }" ref="dropdown">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<button class="button is-fullwidth is-justify-content-space-between" aria-haspopup="true" type="button" <button
aria-controls="dropdown-menu" @click="toggle" :class="button_classes"> class="button is-fullwidth is-justify-content-space-between"
aria-haspopup="true"
type="button"
aria-controls="dropdown-menu"
@click="toggle"
:class="button_classes"
>
<span class="icon" v-if="icons"><i :class="icons" /></span> <span class="icon" v-if="icons"><i :class="icons" /></span>
<span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span> <span :class="{ 'is-sr-only': hideLabel }">{{ label }}</span>
<div class="is-pulled-right"> <div class="is-pulled-right">
@ -33,131 +39,131 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue' import { ref, onMounted, onBeforeUnmount, nextTick, watchEffect, useTemplateRef } from 'vue';
const emitter = defineEmits(['open_state']) const emitter = defineEmits(['open_state']);
const props = defineProps({ const props = defineProps({
label: { label: {
type: String, type: String,
default: 'Select' default: 'Select',
}, },
icons: { icons: {
type: String, type: String,
default: null default: null,
}, },
button_classes: { button_classes: {
type: String, type: String,
default: '' default: '',
}, },
hide_label_on_mobile: { hide_label_on_mobile: {
type: Boolean, type: Boolean,
default: false default: false,
} },
}) });
const isOpen = ref(false) const isOpen = ref(false);
const dropUp = ref(false) const dropUp = ref(false);
const dropdown = useTemplateRef<HTMLDivElement>('dropdown') const dropdown = useTemplateRef<HTMLDivElement>('dropdown');
const menu = useTemplateRef<HTMLDivElement>('menu') const menu = useTemplateRef<HTMLDivElement>('menu');
const menuStyle = ref<Record<string, string>>({}) const menuStyle = ref<Record<string, string>>({});
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile) const hideLabel = computed(() => isMobile.value && props.hide_label_on_mobile);
const updatePosition = () => { const updatePosition = () => {
if (!dropdown.value || !isOpen.value) { if (!dropdown.value || !isOpen.value) {
return return;
} }
const triggerRect = dropdown.value.getBoundingClientRect() const triggerRect = dropdown.value.getBoundingClientRect();
const menuHeight = menu.value?.offsetHeight || 300 const menuHeight = menu.value?.offsetHeight || 300;
const spaceBelow = window.innerHeight - triggerRect.bottom const spaceBelow = window.innerHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top const spaceAbove = triggerRect.top;
// Determine if dropdown should appear above or below // Determine if dropdown should appear above or below
const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow const shouldDropUp = spaceBelow < menuHeight + 24 && spaceAbove > spaceBelow;
dropUp.value = shouldDropUp dropUp.value = shouldDropUp;
// Calculate position // Calculate position
const left = triggerRect.left const left = triggerRect.left;
const width = triggerRect.width const width = triggerRect.width;
if (shouldDropUp) { if (shouldDropUp) {
// Position above the trigger // Position above the trigger
const bottom = window.innerHeight - triggerRect.top const bottom = window.innerHeight - triggerRect.top;
menuStyle.value = { menuStyle.value = {
position: 'fixed', position: 'fixed',
left: `${left}px`, left: `${left}px`,
bottom: `${bottom}px`, bottom: `${bottom}px`,
width: `${width}px`, width: `${width}px`,
top: 'auto' top: 'auto',
} };
} else { } else {
// Position below the trigger // Position below the trigger
const top = triggerRect.bottom const top = triggerRect.bottom;
menuStyle.value = { menuStyle.value = {
position: 'fixed', position: 'fixed',
left: `${left}px`, left: `${left}px`,
top: `${top}px`, top: `${top}px`,
width: `${width}px`, width: `${width}px`,
bottom: 'auto' bottom: 'auto',
} };
} }
} };
const toggle = async () => { const toggle = async () => {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value;
if (isOpen.value) { if (isOpen.value) {
await nextTick() await nextTick();
updatePosition() updatePosition();
} }
} };
const handle_slot_click = (event: MouseEvent) => { const handle_slot_click = (event: MouseEvent) => {
const target = event.target as HTMLElement const target = event.target as HTMLElement;
if (target.closest('.dropdown-item')) { if (target.closest('.dropdown-item')) {
isOpen.value = false isOpen.value = false;
} }
} };
const handle_event = (event: MouseEvent) => { const handle_event = (event: MouseEvent) => {
if (!dropdown.value) { if (!dropdown.value) {
return return;
} }
const target = event.target as HTMLElement const target = event.target as HTMLElement;
if (!dropdown.value.contains(target) && !menu.value?.contains(target)) { if (!dropdown.value.contains(target) && !menu.value?.contains(target)) {
isOpen.value = false isOpen.value = false;
} }
} };
const handleScroll = () => { const handleScroll = () => {
if (isOpen.value) { if (isOpen.value) {
updatePosition() updatePosition();
} }
} };
const handleResize = () => { const handleResize = () => {
if (isOpen.value) { if (isOpen.value) {
updatePosition() updatePosition();
} }
} };
watchEffect(() => emitter('open_state', isOpen.value)) watchEffect(() => emitter('open_state', isOpen.value));
onMounted(() => { onMounted(() => {
document.addEventListener('click', handle_event) document.addEventListener('click', handle_event);
window.addEventListener('scroll', handleScroll, true) // Use capture to catch all scroll events window.addEventListener('scroll', handleScroll, true); // Use capture to catch all scroll events
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handle_event) document.removeEventListener('click', handle_event);
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize);
}) });
</script> </script>
<style scoped> <style scoped>
@ -192,7 +198,14 @@ onBeforeUnmount(() => {
.dropdown.dropdown-portal .dropdown-content { .dropdown.dropdown-portal .dropdown-content {
background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff)); background-color: var(--bulma-dropdown-content-background-color, var(--bulma-scheme-main, #fff));
border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px)); border-radius: var(--bulma-dropdown-content-radius, var(--bulma-radius, 4px));
box-shadow: var(--bulma-dropdown-content-shadow, var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02))); box-shadow: var(
--bulma-dropdown-content-shadow,
var(
--bulma-shadow,
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
0 0px 0 1px rgba(10, 10, 10, 0.02)
)
);
padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem); padding-top: var(--bulma-dropdown-content-padding-top, 0.5rem);
padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem); padding-bottom: var(--bulma-dropdown-content-padding-bottom, 0.5rem);
} }

View file

@ -16,31 +16,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
defineProps({ defineProps({
url: { url: {
type: String, type: String,
required: true, required: true,
} },
}) });
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel']);
const handle_event = (e: KeyboardEvent) => { const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') { if (e.key !== 'Escape') {
return return;
} }
emitter('closeModel') emitter('closeModel');
} };
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event);
disableOpacity() disableOpacity();
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
enableOpacity() enableOpacity();
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event);
}) });
</script> </script>

View file

@ -1,53 +1,58 @@
<template> <template>
<ModalText :isLoading="isLoading" :data="data" :externalModel="externalModel" <ModalText
@closeModel="() => emitter('closeModel')" :code_classes="code_classes" /> :isLoading="isLoading"
:data="data"
:externalModel="externalModel"
@closeModel="() => emitter('closeModel')"
:code_classes="code_classes"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const toast = useNotification() const toast = useNotification();
const emitter = defineEmits<{ (e: 'closeModel'): void }>() const emitter = defineEmits<{ (e: 'closeModel'): void }>();
const props = defineProps<{ const props = defineProps<{
link?: string link?: string;
preset?: string preset?: string;
cli?: string cli?: string;
useUrl?: boolean useUrl?: boolean;
externalModel?: boolean externalModel?: boolean;
code_classes?: string code_classes?: string;
}>() }>();
const isLoading = ref<boolean>(true) const isLoading = ref<boolean>(true);
const data = ref<any>({}) const data = ref<any>({});
onMounted(async (): Promise<void> => { onMounted(async (): Promise<void> => {
let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info' let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info';
if (!props.useUrl) { if (!props.useUrl) {
const params = new URLSearchParams() const params = new URLSearchParams();
if (props.preset) { if (props.preset) {
params.append('preset', props.preset) params.append('preset', props.preset);
} }
if (props.cli) { if (props.cli) {
params.append('args', props.cli) params.append('args', props.cli);
} }
params.append('url', props.link || '') params.append('url', props.link || '');
url += '?' + params.toString() url += '?' + params.toString();
} }
try { try {
const response = await request(url) const response = await request(url);
const body = await response.text() const body = await response.text();
try { try {
data.value = JSON.parse(body) data.value = JSON.parse(body);
} catch { } catch {
data.value = body data.value = body;
} }
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`);
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
}) });
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -9,66 +9,68 @@ img {
<template> <template>
<div> <div>
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i> <i class="fas fa-circle-notch fa-spin"></i>
</div> </div>
<div v-else> <div v-else>
<img :src="image"> <img :src="image" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
const toast = useNotification() const toast = useNotification();
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel']);
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
const image = ref<string>('') const image = ref<string>('');
const props = defineProps({ const props = defineProps({
link: { link: {
type: String, type: String,
required: true, required: true,
}, },
}) });
const handle_event = (e: KeyboardEvent) => { const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') { if (e.key !== 'Escape') {
return return;
} }
emitter('closeModel') emitter('closeModel');
} };
onMounted(async () => { onMounted(async () => {
disableOpacity() disableOpacity();
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event);
const url = props.link.startsWith('/') ? props.link : '/api/thumbnail?url=' + encodePath(props.link) const url = props.link.startsWith('/')
? props.link
: '/api/thumbnail?url=' + encodePath(props.link);
try { try {
isLoading.value = true isLoading.value = true;
const imgRequest = await request(url) const imgRequest = await request(url);
if (200 !== imgRequest.status) { if (200 !== imgRequest.status) {
return return;
} }
image.value = URL.createObjectURL(await imgRequest.blob()) image.value = URL.createObjectURL(await imgRequest.blob());
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`);
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event);
if (image.value) { if (image.value) {
URL.revokeObjectURL(image.value) URL.revokeObjectURL(image.value);
} }
enableOpacity() enableOpacity();
}) });
</script> </script>

View file

@ -1,17 +1,40 @@
<template> <template>
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;"> <div
<div class="control" style="width:100%;"> class="dropdown"
<input v-model="model" @focus="onFocus" @blur="hideList" @keydown="handleKeydown" @input="onInput" class="input" :class="{ 'is-active': showList && filteredOptions.length }"
:placeholder="placeholder" autocomplete="new-password" style="width:100%; position:relative; z-index:2;" style="width: 100%"
:disabled="disabled" :id="id" /> >
<div class="control" style="width: 100%">
<input
v-model="model"
@focus="onFocus"
@blur="hideList"
@keydown="handleKeydown"
@input="onInput"
class="input"
:placeholder="placeholder"
autocomplete="new-password"
style="width: 100%; position: relative; z-index: 2"
:disabled="disabled"
:id="id"
/>
</div> </div>
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;"> <div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
<div class="dropdown-content" style="width:100%; max-height:10em; overflow-y:auto;"> <div class="dropdown-content" style="width: 100%; max-height: 10em; overflow-y: auto">
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option.value)" <a
v-for="(option, idx) in filteredOptions"
:key="option.value"
@mousedown.prevent="selectOption(option.value)"
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]" :class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)"> style="display: flex; justify-content: space-between"
:ref="(el) => setDropdownItemRef(el, idx)"
>
<span class="has-text-weight-bold">{{ option.value }}</span> <span class="has-text-weight-bold">{{ option.value }}</span>
<abbr class="has-text-grey-light is-text-overflow" :title="option.description" style="margin-left:1em;"> <abbr
class="has-text-grey-light is-text-overflow"
:title="option.description"
style="margin-left: 1em"
>
{{ option.description }} {{ option.description }}
</abbr> </abbr>
</a> </a>
@ -21,36 +44,39 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue' import { ref, watch, computed, nextTick } from 'vue';
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete';
const props = withDefaults(defineProps<{ const props = withDefaults(
options: AutoCompleteOptions defineProps<{
placeholder?: string options: AutoCompleteOptions;
disabled?: boolean placeholder?: string;
id?: string disabled?: boolean;
multiple?: boolean id?: string;
openOnFocus?: boolean multiple?: boolean;
allowShortFlags?: boolean openOnFocus?: boolean;
}>(), { allowShortFlags?: boolean;
placeholder: '', }>(),
disabled: false, {
id: '', placeholder: '',
multiple: true, disabled: false,
openOnFocus: false, id: '',
allowShortFlags: false multiple: true,
}) openOnFocus: false,
allowShortFlags: false,
},
);
const model = defineModel<string>() const model = defineModel<string>();
const onInput = () => { const onInput = () => {
showList.value = isFlagTrigger.value && filteredOptions.value.length > 0 showList.value = isFlagTrigger.value && filteredOptions.value.length > 0;
highlightedIndex.value = showList.value ? 0 : -1 highlightedIndex.value = showList.value ? 0 : -1;
} };
const showList = ref(false) const showList = ref(false);
const highlightedIndex = ref(-1) const highlightedIndex = ref(-1);
const dropdownItemRefs = ref<(HTMLElement | null)[]>([]) const dropdownItemRefs = ref<(HTMLElement | null)[]>([]);
// Extract the last non-space token and its bounds // Extract the last non-space token and its bounds
const getLastToken = (value: string) => { const getLastToken = (value: string) => {
@ -59,211 +85,216 @@ const getLastToken = (value: string) => {
return { return {
token: value, token: value,
start: 0, start: 0,
end: value.length end: value.length,
} };
} }
// Multiple enabled: extract last token for multi-flag support // Multiple enabled: extract last token for multi-flag support
const m = (value || '').match(/(\S+)$/) const m = (value || '').match(/(\S+)$/);
const token: string = m?.[1] ?? '' const token: string = m?.[1] ?? '';
const start = m ? (m.index as number) : value.length const start = m ? (m.index as number) : value.length;
const end = m ? start + token.length : value.length const end = m ? start + token.length : value.length;
return { token, start, end } return { token, start, end };
} };
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const value = model.value || '' const value = model.value || '';
if (!value) { if (!value) {
return props.options return props.options;
} }
const { token } = getLastToken(value) const { token } = getLastToken(value);
// If openOnFocus is enabled and token is empty/just whitespace, show all options // If openOnFocus is enabled and token is empty/just whitespace, show all options
if (props.openOnFocus && !token) { if (props.openOnFocus && !token) {
return props.options return props.options;
} }
// Hide suggestions when token has '=' // Hide suggestions when token has '='
if (!token || token.includes('=')) { if (!token || token.includes('=')) {
return [] return [];
} }
// Check if token is a valid flag format // Check if token is a valid flag format
const isLongFlag = token.startsWith('--') const isLongFlag = token.startsWith('--');
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--') const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
if (!isLongFlag && !isShortFlag) { if (!isLongFlag && !isShortFlag) {
return [] return [];
} }
// Check for exact match first - if found, only show that // Check for exact match first - if found, only show that
const exactMatch = props.options.find(opt => opt.value === token) const exactMatch = props.options.find((opt) => opt.value === token);
if (exactMatch) { if (exactMatch) {
return [exactMatch] return [exactMatch];
} }
const startsWithFlag = [] const startsWithFlag = [];
const includesFlag = [] const includesFlag = [];
const includesDesc = [] const includesDesc = [];
for (const opt of props.options) { for (const opt of props.options) {
const flag = opt.value const flag = opt.value;
const desc = opt.description.toLowerCase() const desc = opt.description.toLowerCase();
if (isShortFlag) { if (isShortFlag) {
// Short flags: case-sensitive matching for flag, case-insensitive for description // Short flags: case-sensitive matching for flag, case-insensitive for description
if (flag === token) { if (flag === token) {
startsWithFlag.push(opt) startsWithFlag.push(opt);
} else if (flag.includes(token)) { } else if (flag.includes(token)) {
includesFlag.push(opt) includesFlag.push(opt);
} else if (desc.includes(token.toLowerCase())) { } else if (desc.includes(token.toLowerCase())) {
includesDesc.push(opt) includesDesc.push(opt);
} }
} else { } else {
// Long flags: case-insensitive matching // Long flags: case-insensitive matching
const val = token.toLowerCase() const val = token.toLowerCase();
const flagLower = flag.toLowerCase() const flagLower = flag.toLowerCase();
if (flagLower.startsWith(val)) { if (flagLower.startsWith(val)) {
startsWithFlag.push(opt) startsWithFlag.push(opt);
} else if (flagLower.includes(val)) { } else if (flagLower.includes(val)) {
includesFlag.push(opt) includesFlag.push(opt);
} else if (desc.includes(val)) { } else if (desc.includes(val)) {
includesDesc.push(opt) includesDesc.push(opt);
} }
} }
} }
return [...startsWithFlag, ...includesFlag, ...includesDesc] return [...startsWithFlag, ...includesFlag, ...includesDesc];
}) });
const selectOption = (val: string) => { const selectOption = (val: string) => {
const value = model.value || '' const value = model.value || '';
const { token, start, end } = getLastToken(value) const { token, start, end } = getLastToken(value);
// If multiple is disabled, replace entire value // If multiple is disabled, replace entire value
if (!props.multiple) { if (!props.multiple) {
// Preserve any '=value' suffix already typed // Preserve any '=value' suffix already typed
const eqPos = token.indexOf('=') const eqPos = token.indexOf('=');
const after = eqPos !== -1 ? token.slice(eqPos) : '' const after = eqPos !== -1 ? token.slice(eqPos) : '';
model.value = val + after model.value = val + after;
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
return return;
} }
// Multiple enabled: replace only the last token // Multiple enabled: replace only the last token
if (token) { if (token) {
// Preserve any '=value' suffix already typed for this token // Preserve any '=value' suffix already typed for this token
const eqPos = token.indexOf('=') const eqPos = token.indexOf('=');
const after = eqPos !== -1 ? token.slice(eqPos) : '' const after = eqPos !== -1 ? token.slice(eqPos) : '';
model.value = value.slice(0, start) + val + after + value.slice(end) model.value = value.slice(0, start) + val + after + value.slice(end);
} else { } else {
model.value = val model.value = val;
} }
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
} };
const hideList = () => { const hideList = () => {
setTimeout(() => { setTimeout(() => {
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
dropdownItemRefs.value = [] dropdownItemRefs.value = [];
}, 100) }, 100);
} };
const onFocus = () => { const onFocus = () => {
if (!props.openOnFocus) { if (!props.openOnFocus) {
return return;
} }
// When openOnFocus is enabled, show dropdown if there are options // When openOnFocus is enabled, show dropdown if there are options
const hasOptions = filteredOptions.value.length > 0 const hasOptions = filteredOptions.value.length > 0;
showList.value = hasOptions showList.value = hasOptions;
highlightedIndex.value = hasOptions ? 0 : -1 highlightedIndex.value = hasOptions ? 0 : -1;
} };
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => { const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null;
} };
watch(filteredOptions, () => { watch(filteredOptions, () => {
highlightedIndex.value = filteredOptions.value.length ? 0 : -1 highlightedIndex.value = filteredOptions.value.length ? 0 : -1;
dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null) dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null);
nextTick(() => { nextTick(() => {
const dropdown = document.querySelector('.dropdown-content') const dropdown = document.querySelector('.dropdown-content');
if (dropdown) { if (dropdown) {
dropdown.scrollTop = 0 dropdown.scrollTop = 0;
} }
}) });
}) });
const isFlagTrigger = computed(() => { const isFlagTrigger = computed(() => {
const { token } = getLastToken(model.value || '') const { token } = getLastToken(model.value || '');
// If openOnFocus is enabled and input is empty, allow trigger // If openOnFocus is enabled and input is empty, allow trigger
if (props.openOnFocus && !token) { if (props.openOnFocus && !token) {
return true return true;
} }
if (!token || token.includes('=')) return false if (!token || token.includes('=')) return false;
// Check if token is a valid flag format // Check if token is a valid flag format
const isLongFlag = token.startsWith('--') const isLongFlag = token.startsWith('--');
const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--') const isShortFlag = props.allowShortFlags && token.startsWith('-') && !token.startsWith('--');
if (!isLongFlag && !isShortFlag) { if (!isLongFlag && !isShortFlag) {
return false return false;
} }
// Allow trigger even for exact matches so users can see descriptions // Allow trigger even for exact matches so users can see descriptions
return true return true;
}) });
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
// Escape closes dropdown and lets arrows navigate the input // Escape closes dropdown and lets arrows navigate the input
if (e.key === 'Escape') { if (e.key === 'Escape') {
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
return return;
} }
if (!showList.value || !filteredOptions.value.length) { if (!showList.value || !filteredOptions.value.length) {
return return;
} }
const pageSize = 5 const pageSize = 5;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1) highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
nextTick(() => scrollHighlightedIntoView()) nextTick(() => scrollHighlightedIntoView());
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0) highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
nextTick(() => scrollHighlightedIntoView()) nextTick(() => scrollHighlightedIntoView());
} else if (e.key === 'PageDown') { } else if (e.key === 'PageDown') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1) highlightedIndex.value = Math.min(
nextTick(() => scrollHighlightedIntoView()) highlightedIndex.value + pageSize,
filteredOptions.value.length - 1,
);
nextTick(() => scrollHighlightedIntoView());
} else if (e.key === 'PageUp') { } else if (e.key === 'PageUp') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0) highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
nextTick(() => scrollHighlightedIntoView()) nextTick(() => scrollHighlightedIntoView());
} else if (e.key === 'Enter' || e.key === 'Tab') { } else if (e.key === 'Enter' || e.key === 'Tab') {
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ? const selected =
filteredOptions.value[highlightedIndex.value] : undefined highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
? filteredOptions.value[highlightedIndex.value]
: undefined;
if (selected && isFlagTrigger.value) { if (selected && isFlagTrigger.value) {
e.preventDefault() e.preventDefault();
selectOption(selected.value) selectOption(selected.value);
} }
} }
} };
function scrollHighlightedIntoView() { function scrollHighlightedIntoView() {
const el = dropdownItemRefs.value[highlightedIndex.value] const el = dropdownItemRefs.value[highlightedIndex.value];
if (!el) { if (!el) {
return return;
} }
el.scrollIntoView({ block: 'nearest' }) el.scrollIntoView({ block: 'nearest' });
} }
</script> </script>

View file

@ -5,73 +5,80 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core' import { useIntersectionObserver } from '@vueuse/core';
const props = defineProps<{ const props = defineProps<{
renderOnIdle?: boolean renderOnIdle?: boolean;
unrender?: boolean unrender?: boolean;
minHeight?: number minHeight?: number;
unrenderDelay?: number unrenderDelay?: number;
}>() }>();
const shouldRender = ref(false) const shouldRender = ref(false);
const targetEl = ref<HTMLElement | null>(null) const targetEl = ref<HTMLElement | null>(null);
const fixedMinHeight = ref(0) const fixedMinHeight = ref(0);
let unrenderTimer: ReturnType<typeof setTimeout> | null = null let unrenderTimer: ReturnType<typeof setTimeout> | null = null;
let renderTimer: ReturnType<typeof setTimeout> | null = null let renderTimer: ReturnType<typeof setTimeout> | null = null;
function onIdle(cb: () => void): void { function onIdle(cb: () => void): void {
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb) (window as any).requestIdleCallback(cb);
} else { } else {
setTimeout(() => nextTick(cb), 300) setTimeout(() => nextTick(cb), 300);
} }
} }
const { stop } = useIntersectionObserver(targetEl, (entries) => { const { stop } = useIntersectionObserver(
const entry = entries[0] targetEl,
if (entry?.isIntersecting) { (entries) => {
if (unrenderTimer) clearTimeout(unrenderTimer) const entry = entries[0];
if (entry?.isIntersecting) {
if (unrenderTimer) clearTimeout(unrenderTimer);
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0) renderTimer = setTimeout(
() => {
shouldRender.value = true;
},
props.unrender ? 200 : 0,
);
shouldRender.value = true shouldRender.value = true;
if (!props.unrender) { if (!props.unrender) {
stop() stop();
}
}
else if (props.unrender) {
if (renderTimer) {
clearTimeout(renderTimer)
}
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight
} }
shouldRender.value = false } else if (props.unrender) {
}, props.unrenderDelay ?? 6000) if (renderTimer) {
} clearTimeout(renderTimer);
}, { rootMargin: '600px', }) }
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight;
}
shouldRender.value = false;
}, props.unrenderDelay ?? 6000);
}
},
{ rootMargin: '600px' },
);
if (props.renderOnIdle) { if (props.renderOnIdle) {
onIdle(() => { onIdle(() => {
shouldRender.value = true shouldRender.value = true;
if (!props.unrender) { if (!props.unrender) {
stop() stop();
} }
}) });
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (renderTimer) { if (renderTimer) {
clearTimeout(renderTimer) clearTimeout(renderTimer);
} }
if (unrenderTimer) { if (unrenderTimer) {
clearTimeout(unrenderTimer) clearTimeout(unrenderTimer);
} }
}) });
</script> </script>

View file

@ -34,39 +34,38 @@
border-left-color: #e5534b; border-left-color: #e5534b;
} }
.markdown-alert-note>.markdown-alert-title { .markdown-alert-note > .markdown-alert-title {
color: #539bf5; color: #539bf5;
} }
.markdown-alert-tip>.markdown-alert-title { .markdown-alert-tip > .markdown-alert-title {
color: #57ab5a; color: #57ab5a;
} }
.markdown-alert-important>.markdown-alert-title { .markdown-alert-important > .markdown-alert-title {
color: #986ee2; color: #986ee2;
} }
.markdown-alert-warning>.markdown-alert-title { .markdown-alert-warning > .markdown-alert-title {
color: #c69026; color: #c69026;
} }
.markdown-alert-caution>.markdown-alert-title { .markdown-alert-caution > .markdown-alert-title {
color: #e5534b; color: #e5534b;
} }
code { code {
word-break: break-word !important word-break: break-word !important;
} }
</style> </style>
<template> <template>
<div> <div>
<div class="modal is-active"> <div class="modal is-active">
<div class="modal-background" @click="emitter('closeModel')"></div> <div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content modal-content-max"> <div class="modal-content modal-content-max">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" /> <i class="fas fa-circle-notch fa-spin" />
</div> </div>
@ -80,120 +79,121 @@ code {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue' import { ref, onMounted, onBeforeUnmount, onUpdated } from 'vue';
import { marked } from 'marked' import { marked } from 'marked';
import { baseUrl } from 'marked-base-url' import { baseUrl } from 'marked-base-url';
import markedAlert from 'marked-alert' import markedAlert from 'marked-alert';
import { gfmHeadingId } from 'marked-gfm-heading-id' import { gfmHeadingId } from 'marked-gfm-heading-id';
import Message from '~/components/Message.vue' import Message from '~/components/Message.vue';
const props = defineProps<{ file: string }>() const props = defineProps<{ file: string }>();
const emitter = defineEmits<{ (e: 'closeModel'): void }>() const emitter = defineEmits<{ (e: 'closeModel'): void }>();
const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg'] const urls = ['FAQ.md', 'README.md', 'API.md', 'sc_short.jpg', 'sc_simple.jpg'];
const content = ref<string>('') const content = ref<string>('');
const error = ref<string>('') const error = ref<string>('');
const isLoading = ref<boolean>(true) const isLoading = ref<boolean>(true);
const handleClick = async (e: MouseEvent) => { const handleClick = async (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null const target = (e.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
if (!target) { if (!target) {
return return;
} }
const href = target.getAttribute('data-url') const href = target.getAttribute('data-url');
if (!href) { if (!href) {
return return;
} }
e.preventDefault() e.preventDefault();
await loader(href) await loader(href);
} };
const addListeners = (): void => { const addListeners = (): void => {
removeListeners() removeListeners();
document.querySelectorAll('.content a').forEach((l: Element): void => { document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url') const href = l.getAttribute('data-url');
if (!href) { if (!href) {
return return;
} }
(l as HTMLElement).addEventListener('click', handleClick) (l as HTMLElement).addEventListener('click', handleClick);
}) });
} };
const removeListeners = (): void => { const removeListeners = (): void => {
document.querySelectorAll('.content a').forEach((l: Element): void => { document.querySelectorAll('.content a').forEach((l: Element): void => {
const href = l.getAttribute('data-url') const href = l.getAttribute('data-url');
if (!href) { if (!href) {
return return;
} }
(l as HTMLElement).removeEventListener('click', handleClick) (l as HTMLElement).removeEventListener('click', handleClick);
}) });
} };
const loader = async (file: string) => { const loader = async (file: string) => {
try { try {
isLoading.value = true isLoading.value = true;
const response = await fetch(`${file}?_=${Date.now()}`) const response = await fetch(`${file}?_=${Date.now()}`);
if (true !== response.ok) { if (true !== response.ok) {
const err = await response.json() const err = await response.json();
error.value = err.error.message error.value = err.error.message;
return return;
} }
const text = await response.text() const text = await response.text();
marked.use(gfmHeadingId()) marked.use(gfmHeadingId());
marked.use(baseUrl(window.origin)) marked.use(baseUrl(window.origin));
marked.use(markedAlert()) marked.use(markedAlert());
marked.use({ marked.use({
gfm: true, gfm: true,
hooks: { hooks: {
postprocess: (text: string) => text.replace( postprocess: (text: string) =>
/<!--\s*?i:([\w.-]+)\s*?-->/gi, text.replace(
(_, list) => `<span class="icon"><i class="fas ${list.split('.').map((n: string) => n.trim()).join(' ')}"></i></span>` /<!--\s*?i:([\w.-]+)\s*?-->/gi,
) (_, list) =>
`<span class="icon"><i class="fas ${list
.split('.')
.map((n: string) => n.trim())
.join(' ')}"></i></span>`,
),
}, },
walkTokens: (token: any) => { walkTokens: (token: any) => {
if (token.type !== 'link') { if (token.type !== 'link') {
return return;
} }
if (token.href.startsWith('#')) { if (token.href.startsWith('#')) {
return return;
} }
if (urls.some(l => token.href.includes(l))) { if (urls.some((l) => token.href.includes(l))) {
const name = urls.find(l => token.href.includes(l)) || '' const name = urls.find((l) => token.href.includes(l)) || '';
token._external = false token._external = false;
token.href = `/${name}` token.href = `/${name}`;
} else { } else {
token._external = true token._external = true;
} }
}, },
renderer: { renderer: {
link(token: any) { link(token: any) {
const text = this.parser.parseInline(token.tokens) const text = this.parser.parseInline(token.tokens);
const title = token.title ? ` title="${token.title}"` : '' const title = token.title ? ` title="${token.title}"` : '';
const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : '' const attrs = token._external ? ' target="_blank" rel="noopener noreferrer"' : '';
let local = '' let local = '';
const name = urls.find(l => token.href.includes(l)) || '' const name = urls.find((l) => token.href.includes(l)) || '';
if (name) { if (name) {
local = ` data-url="/api/docs/${name}"` local = ` data-url="/api/docs/${name}"`;
} }
return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>` return `<a href="${token.href}"${local}${title}${attrs}>${text}</a>`;
}, },
table(token: any) { table(token: any) {
// `token.header` and `token.rows` are available // `token.header` and `token.rows` are available
@ -201,39 +201,42 @@ const loader = async (file: string) => {
// Then wrap with classed <table> // Then wrap with classed <table>
// We need to generate the inner HTML parts first // We need to generate the inner HTML parts first
const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>` const headerHtml = `<thead><tr>${token.header.map((cell: any) => `<th>${this.parser.parseInline(cell.tokens)}</th>`).join('')}</tr></thead>`;
const bodyHtml = (token.rows || []).map((row: any[]) => { const bodyHtml = (token.rows || [])
const tr = row.map((cell: any) => { .map((row: any[]) => {
return `<td>${this.parser.parseInline(cell.tokens)}</td>` const tr = row
}).join('') .map((cell: any) => {
return `<tr>${tr}</tr>` return `<td>${this.parser.parseInline(cell.tokens)}</td>`;
}).join('') })
.join('');
return `<tr>${tr}</tr>`;
})
.join('');
return `<div class="table-container"><table class="table is-striped is-hoverable is-fullwidth is-bordered">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>` return `<div class="table-container"><table class="table is-striped is-hoverable is-fullwidth is-bordered">\n${headerHtml}\n<tbody>\n${bodyHtml}\n</tbody>\n</table></div>`;
}, },
image(token: any) { image(token: any) {
const alt = token.text ? ` alt="${token.text}"` : ' alt=""' const alt = token.text ? ` alt="${token.text}"` : ' alt=""';
const title = token.title ? ` title="${token.title}"` : '' const title = token.title ? ` title="${token.title}"` : '';
const refPolicy = ' referrerpolicy="no-referrer"' const refPolicy = ' referrerpolicy="no-referrer"';
const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '' const crossorigin = token._isExternalImage ? ' crossorigin="anonymous"' : '';
const loading = ' loading="lazy"' const loading = ' loading="lazy"';
return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />` return `<img src="${token.href}"${alt}${title}${refPolicy}${crossorigin}${loading} />`;
}, },
}, },
}) });
content.value = String(marked.parse(text))
content.value = String(marked.parse(text));
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
error.value = e.message error.value = e.message;
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
} };
onMounted(async () => loader(props.file)) onMounted(async () => loader(props.file));
onUpdated(() => addListeners()) onUpdated(() => addListeners());
onBeforeUnmount(() => removeListeners()) onBeforeUnmount(() => removeListeners());
</script> </script>

View file

@ -1,13 +1,21 @@
<template> <template>
<div class="message"> <div class="message">
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle"> <div
@click="$emit('toggle')"
class="is-clickable is-pulled-right is-unselectable"
v-if="useToggle"
>
<span class="icon"> <span class="icon">
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i> <i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i>
</span> </span>
<span>{{ toggle ? 'Close' : 'Open' }}</span> <span>{{ toggle ? 'Close' : 'Open' }}</span>
</div> </div>
<div class="is-unselectable message-header" :class="{ 'is-clickable': useToggle, }" v-if="title || icon" <div
@click="true === useToggle ? $emit('toggle', toggle) : null"> class="is-unselectable message-header"
:class="{ 'is-clickable': useToggle }"
v-if="title || icon"
@click="true === useToggle ? $emit('toggle', toggle) : null"
>
<template v-if="icon"> <template v-if="icon">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i :class="icon"></i></span> <span class="icon"><i :class="icon"></i></span>
@ -17,7 +25,11 @@
<template v-else>{{ title }}</template> <template v-else>{{ title }}</template>
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" /> <button class="delete" @click="$emit('close')" v-if="!useToggle && useClose" />
</div> </div>
<div class="content message-body is-text-break" v-if="false === useToggle || toggle" :class="body_class"> <div
class="content message-body is-text-break"
v-if="false === useToggle || toggle"
:class="body_class"
>
<template v-if="message">{{ message }}</template> <template v-if="message">{{ message }}</template>
<slot /> <slot />
</div> </div>
@ -25,34 +37,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
withDefaults(defineProps<{ withDefaults(
/** Title text for the notification */ defineProps<{
title?: string | null /** Title text for the notification */
/** Icon class for the notification */ title?: string | null;
icon?: string | null /** Icon class for the notification */
/** Main message content */ icon?: string | null;
message?: string | null /** Main message content */
/** If true, show toggle button */ message?: string | null;
useToggle?: boolean /** If true, show toggle button */
/** Current toggle state */ useToggle?: boolean;
toggle?: boolean /** Current toggle state */
/** If true, show close button */ toggle?: boolean;
useClose?: boolean, /** If true, show close button */
body_class?: string | null, useClose?: boolean;
}>(), { body_class?: string | null;
title: null, }>(),
icon: null, {
message: null, title: null,
useToggle: false, icon: null,
toggle: false, message: null,
useClose: false, useToggle: false,
body_class: null, toggle: false,
}) useClose: false,
body_class: null,
},
);
defineEmits<{ defineEmits<{
/** Emitted when the toggle button is clicked */ /** Emitted when the toggle button is clicked */
(e: 'toggle', value?: boolean): void (e: 'toggle', value?: boolean): void;
/** Emitted when the close button is clicked */ /** Emitted when the close button is clicked */
(e: 'close'): void (e: 'close'): void;
}>() }>();
</script> </script>

View file

@ -17,9 +17,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
const emitter = defineEmits(['close']) const emitter = defineEmits(['close']);
defineProps({ defineProps({
title: { title: {
@ -32,22 +32,22 @@ defineProps({
default: '', default: '',
required: false, required: false,
}, },
}) });
const handle_event = (e: KeyboardEvent) => { const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') { if (e.key !== 'Escape') {
return return;
} }
emitter('close') emitter('close');
} };
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event);
disableOpacity() disableOpacity();
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event);
enableOpacity() enableOpacity();
}) });
</script> </script>

View file

@ -1,6 +1,6 @@
<style scoped> <style scoped>
code { code {
color: var(--bulma-code) !important color: var(--bulma-code) !important;
} }
</style> </style>
@ -9,7 +9,7 @@ code {
<div class="modal is-active" v-if="false === externalModel"> <div class="modal is-active" v-if="false === externalModel">
<div class="modal-background" @click="emitter('closeModel')"></div> <div class="modal-background" @click="emitter('closeModel')"></div>
<div class="modal-content modal-content-max"> <div class="modal-content modal-content-max">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" /> <i class="fas fa-circle-notch fa-spin" />
</div> </div>
<div v-else> <div v-else>
@ -27,20 +27,32 @@ code {
</div> </div>
</div> </div>
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button> <button
class="modal-close is-large"
aria-label="close"
@click="emitter('closeModel')"
></button>
</div> </div>
<div class="modal-content-max" style="height: 80vh;" v-else> <div class="modal-content-max" style="height: 80vh" v-else>
<div class="content p-0 m-0" style="position: relative"> <div class="content p-0 m-0" style="position: relative">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading"> <div style="font-size: 30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin" /> <i class="fas fa-circle-notch fa-spin" />
</div> </div>
<div v-else> <div v-else>
<pre :class="[code_classes, custom_classes]"><code class="p-4 is-block" v-text="data" /></pre> <pre
<div class="m-4 is-flex" style="position: absolute; top:0; right:0;"> :class="[code_classes, custom_classes]"
<button class="button is-small is-purple mr-3" @click="() => toggleClass('is-pre-wrap-force')"> ><code class="p-4 is-block" v-text="data" /></pre>
<div class="m-4 is-flex" style="position: absolute; top: 0; right: 0">
<button
class="button is-small is-purple mr-3"
@click="() => toggleClass('is-pre-wrap-force')"
>
<span class="icon"><i class="fas fa-text-width" /></span> <span class="icon"><i class="fas fa-text-width" /></span>
</button> </button>
<button class="button is-info is-small" @click="() => copyText(JSON.stringify(data, null, 2))"> <button
class="button is-info is-small"
@click="() => copyText(JSON.stringify(data, null, 2))"
>
<span class="icon"><i class="fas fa-copy" /></span> <span class="icon"><i class="fas fa-copy" /></span>
</button> </button>
</div> </div>
@ -51,40 +63,43 @@ code {
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { disableOpacity, enableOpacity } from '~/utils'; import { disableOpacity, enableOpacity } from '~/utils';
const emitter = defineEmits<{ (e: 'closeModel'): void }>() const emitter = defineEmits<{ (e: 'closeModel'): void }>();
withDefaults(defineProps<{ externalModel?: boolean, data: any, code_classes?: string, isLoading?: boolean }>(), { withDefaults(
code_classes: '', defineProps<{ externalModel?: boolean; data: any; code_classes?: string; isLoading?: boolean }>(),
isLoading: false, {
externalModel: false, code_classes: '',
}) isLoading: false,
externalModel: false,
},
);
const custom_classes = useStorage<string>('modal_text_classes', '') const custom_classes = useStorage<string>('modal_text_classes', '');
const handle_event = (e: KeyboardEvent): void => { const handle_event = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
emitter('closeModel') emitter('closeModel');
} }
} };
onMounted(async (): Promise<void> => { onMounted(async (): Promise<void> => {
disableOpacity() disableOpacity();
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
enableOpacity() enableOpacity();
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event);
}) });
const toggleClass = (className: string) => { const toggleClass = (className: string) => {
if (custom_classes.value.includes(className)) { if (custom_classes.value.includes(className)) {
custom_classes.value = custom_classes.value.replace(className, '').trim() custom_classes.value = custom_classes.value.replace(className, '').trim();
} else { } else {
custom_classes.value += ` ${className}` custom_classes.value += ` ${className}`;
} }
} };
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Message from "~/components/Message.vue"; import Message from '~/components/Message.vue';
const reloadPage = () => window.location.reload(); const reloadPage = () => window.location.reload();
</script> </script>

View file

@ -3,21 +3,26 @@
<div class="column is-12"> <div class="column is-12">
<form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()"> <form autocomplete="off" id="taskForm" @submit.prevent="checkInfo()">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
/></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</div> </div>
<div class="card-header-icon" v-if="reference"> <div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport"> <button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"
'fa-arrow-down': !showImport, ><i
'fa-arrow-up': showImport, class="fa-solid"
}" /></span> :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}"
/></span>
<span> <span>
<span v-if="showImport">Hide</span> <span v-if="showImport">Hide</span>
<span v-else>Show</span> <span v-else>Show</span>
@ -29,20 +34,28 @@
<div class="card-content"> <div class="card-content">
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-12" v-if="showImport || !reference"> <div class="column is-12" v-if="showImport || !reference">
<label class="label is-inline" for="import_string"> <label class="label is-inline" for="import_string"> Import string </label>
Import string
</label>
<div class="field has-addons"> <div class="field has-addons">
<div class="control has-icons-left is-expanded"> <div class="control has-icons-left is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" autocomplete="off"> <input
type="text"
class="input"
id="import_string"
v-model="import_string"
autocomplete="off"
/>
<span class="icon is-small is-left"><i class="fa-solid fa-t" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-t" /></span>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" @click="importItem"> <button
class="button is-primary"
:disabled="!import_string"
type="button"
@click="importItem"
>
<span class="icon"><i class="fa-solid fa-add" /></span> <span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span> <span>Import</span>
</button> </button>
@ -56,37 +69,52 @@
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="name"> <label class="label is-inline" for="name"> Target name </label>
Target name
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required> <input
type="text"
class="input"
id="name"
v-model="form.name"
:disabled="addInProgress"
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>The notification target name, this is used to identify the target in the logs and <span
notifications.</span> >The notification target name, this is used to identify the target in the logs
and notifications.</span
>
</span> </span>
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="url"> <label class="label is-inline" for="url"> Target URL </label>
Target URL
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress" <input
required> type="url"
class="input"
id="url"
v-model="form.request.url"
:disabled="addInProgress"
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
The URL to send the notification to. It can be regular http/https endpoint. or <NuxtLink The URL to send the notification to. It can be regular http/https endpoint. or
target="blank" href="https://github.com/caronc/apprise?tab=readme-ov-file#readme">Apprise <NuxtLink
</NuxtLink> URL. target="blank"
href="https://github.com/caronc/apprise?tab=readme-ov-file#readme"
>Apprise
</NuxtLink>
URL.
</span> </span>
</span> </span>
</div> </div>
@ -94,13 +122,20 @@
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget"> <div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
<div class="field"> <div class="field">
<label class="label is-inline" for="method"> <label class="label is-inline" for="method"> Request method </label>
Request method
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress"> <select
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod"> id="method"
class="is-fullwidth"
v-model="form.request.method"
:disabled="addInProgress"
>
<option
v-for="(rMethod, index) in requestMethods"
:key="`${index}-${rMethod}`"
:value="rMethod"
>
{{ rMethod }} {{ rMethod }}
</option> </option>
</select> </select>
@ -110,8 +145,8 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
The request method to use when sending the notification. This can be any of the standard HTTP The request method to use when sending the notification. This can be any of
methods. the standard HTTP methods.
</span> </span>
</span> </span>
</div> </div>
@ -119,13 +154,20 @@
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget"> <div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
<div class="field"> <div class="field">
<label class="label is-inline" for="type"> <label class="label is-inline" for="type"> Request Type </label>
Request Type
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress"> <select
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType"> id="type"
class="is-fullwidth"
v-model="form.request.type"
:disabled="addInProgress"
>
<option
v-for="(rType, index) in requestType"
:key="`${index}-${rType}`"
:value="rType"
>
{{ ucFirst(rType) }} {{ ucFirst(rType) }}
</option> </option>
</select> </select>
@ -135,8 +177,8 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
The request type to use when sending the notification. This can be <code>JSON</code> or The request type to use when sending the notification. This can be
<code>FORM</code> request. <code>JSON</code> or <code>FORM</code> request.
</span> </span>
</span> </span>
</div> </div>
@ -152,8 +194,18 @@
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-multiple is-fullwidth"> <div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple> <select
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent"> id="on"
class="is-fullwidth"
v-model="form.on"
:disabled="addInProgress"
multiple
>
<option
v-for="(aEvent, index) in allowedEvents"
:key="`${index}-${aEvent}`"
:value="aEvent"
>
{{ aEvent }} {{ aEvent }}
</option> </option>
</select> </select>
@ -163,9 +215,9 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
Subscribe to the events you want to listen for. When the event is triggered, the notification will Subscribe to the events you want to listen for. When the event is triggered,
be sent to the target URL. If no events are selected, the notification will be sent for all the notification will be sent to the target URL. If no events are selected,
events. the notification will be sent for all events.
</span> </span>
</span> </span>
</div> </div>
@ -181,14 +233,31 @@
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-multiple is-fullwidth"> <div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple> <select
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0"> id="on"
<option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name"> class="is-fullwidth"
v-model="form.presets"
:disabled="addInProgress"
multiple
>
<optgroup
label="Custom presets"
v-if="config?.presets.filter((p) => !p?.default).length > 0"
>
<option
v-for="cPreset in filter_presets(false)"
:key="cPreset.id"
:value="cPreset.name"
>
{{ cPreset.name }} {{ cPreset.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="Default presets"> <optgroup label="Default presets">
<option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name"> <option
v-for="dPreset in filter_presets(true)"
:key="dPreset.id"
:value="dPreset.name"
>
{{ dPreset.name }} {{ dPreset.name }}
</option> </option>
</optgroup> </optgroup>
@ -199,9 +268,9 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
Select the presets you want to listen for. If you select presets, only events that reference those Select the presets you want to listen for. If you select presets, only events
presets will trigger the notification. If no presets are selected, the notification will be sent that reference those presets will trigger the notification. If no presets are
for all presets. selected, the notification will be sent for all presets.
</span> </span>
</span> </span>
</div> </div>
@ -214,8 +283,13 @@
Enabled Enabled
</label> </label>
<div class="control is-unselectable"> <div class="control is-unselectable">
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress" <input
class="switch is-success" /> id="enabled"
type="checkbox"
v-model="form.enabled"
:disabled="addInProgress"
class="switch is-success"
/>
<label for="enabled" class="is-unselectable"> <label for="enabled" class="is-unselectable">
{{ form.enabled ? 'Yes' : 'No' }} {{ form.enabled ? 'Yes' : 'No' }}
</label> </label>
@ -229,19 +303,23 @@
<div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget"> <div class="column is-6-tablet is-12-mobile" v-if="!isAppriseTarget">
<div class="field"> <div class="field">
<label class="label is-inline" for="data_key"> <label class="label is-inline" for="data_key"> Data field </label>
Data field
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" id="data_key" v-model="form.request.data_key" <input
:disabled="addInProgress" required> type="text"
class="input"
id="data_key"
v-model="form.request.data_key"
:disabled="addInProgress"
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
The field name to use when sending the notification. This is used to identify the data in the The field name to use when sending the notification. This is used to identify
request. The default is <code>data</code>. the data in the request. The default is <code>data</code>.
</span> </span>
</span> </span>
</div> </div>
@ -250,17 +328,27 @@
<div class="column is-12" v-if="!isAppriseTarget"> <div class="column is-12" v-if="!isAppriseTarget">
<div class="field"> <div class="field">
<label class="label is-inline is-unselectable"> <label class="label is-inline is-unselectable">
Optional Headers - <button type="button" class="has-text-link" Optional Headers -
@click="form.request.headers.push({ key: '', value: '' });">Add Header <button
type="button"
class="has-text-link"
@click="form.request.headers.push({ key: '', value: '' })"
>
Add Header
</button> </button>
</label> </label>
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<template v-for="_, key in form.request.headers" :key="key"> <template v-for="(_, key) in form.request.headers" :key="key">
<div class="column is-5" v-if="form.request.headers[key]"> <div class="column is-5" v-if="form.request.headers[key]">
<div class="field"> <div class="field">
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].key" <input
:disabled="addInProgress" required> type="text"
class="input"
v-model="form.request.headers[key].key"
:disabled="addInProgress"
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
</div> </div>
</div> </div>
@ -272,8 +360,13 @@
<div class="column is-6" v-if="form.request.headers[key]"> <div class="column is-6" v-if="form.request.headers[key]">
<div class="field"> <div class="field">
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].value" <input
:disabled="addInProgress" required> type="text"
class="input"
v-model="form.request.headers[key].value"
:disabled="addInProgress"
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
</div> </div>
</div> </div>
@ -284,8 +377,12 @@
</div> </div>
<div class="column is-1"> <div class="column is-1">
<div class="control"> <div class="control">
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)" <button
:disabled="addInProgress"> type="button"
class="button is-danger"
@click="form.request.headers.splice(key, 1)"
:disabled="addInProgress"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
</button> </button>
</div> </div>
@ -304,15 +401,24 @@
<div class="card-footer"> <div class="card-footer">
<p class="card-footer-item"> <p class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit" <button
:class="{ 'is-loading': addInProgress }" form="taskForm"> class="button is-fullwidth is-primary"
:disabled="addInProgress"
type="submit"
:class="{ 'is-loading': addInProgress }"
form="taskForm"
>
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</p> </p>
<p class="card-footer-item"> <p class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress" <button
type="button"> class="button is-fullwidth is-danger"
@click="emitter('cancel')"
:disabled="addInProgress"
type="button"
>
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -326,17 +432,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { notification } from '~/types/notification' import type { notification } from '~/types/notification';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import type { ImportedItem } from '~/types' import type { ImportedItem } from '~/types';
const emitter = defineEmits(['cancel', 'submit']) const emitter = defineEmits(['cancel', 'submit']);
const toast = useNotification() const toast = useNotification();
const box = useConfirm() const box = useConfirm();
const config = useConfigStore() const config = useConfigStore();
const { isApprise } = useNotifications() const { isApprise } = useNotifications();
const props = defineProps({ const props = defineProps({
reference: { reference: {
@ -357,135 +463,136 @@ const props = defineProps({
required: false, required: false,
default: false, default: false,
}, },
}) });
const form = reactive<notification>({ ...props.item }) const form = reactive<notification>({ ...props.item });
const requestMethods = ['POST', 'PUT'] const requestMethods = ['POST', 'PUT'];
const requestType = ['json', 'form'] const requestType = ['json', 'form'];
const showImport = useStorage('showImport', false) const showImport = useStorage('showImport', false);
const import_string = ref('') const import_string = ref('');
onMounted(() => { onMounted(() => {
if (!form.request.data_key) { if (!form.request.data_key) {
form.request.data_key = 'data' form.request.data_key = 'data';
} }
if (form.enabled === undefined) { if (form.enabled === undefined) {
form.enabled = true form.enabled = true;
} }
}) });
const checkInfo = async () => { const checkInfo = async () => {
let required: string[] let required: string[];
if (!isAppriseTarget.value) { if (!isAppriseTarget.value) {
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key'] required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key'];
} else { } else {
required = ['name', 'request.url'] required = ['name', 'request.url'];
} }
for (const key of required) { for (const key of required) {
if (key.includes('.')) { if (key.includes('.')) {
const [parent, child] = key.split('.') as [keyof typeof form, string] const [parent, child] = key.split('.') as [keyof typeof form, string];
const parentObj = form[parent] as Record<string, any> | undefined const parentObj = form[parent] as Record<string, any> | undefined;
if (!parentObj || !parentObj[child]) { if (!parentObj || !parentObj[child]) {
toast.error(`The field ${parent}.${child} is required.`) toast.error(`The field ${parent}.${child} is required.`);
return return;
} }
} else { } else {
const value = (form as Record<string, any>)[key] const value = (form as Record<string, any>)[key];
if (!value) { if (!value) {
toast.error(`The field ${key} is required.`) toast.error(`The field ${key} is required.`);
return return;
} }
} }
} }
if (!isAppriseTarget.value) { if (!isAppriseTarget.value) {
try { try {
new URL(form.request.url) new URL(form.request.url);
} catch { } catch {
toast.error('Invalid URL') toast.error('Invalid URL');
return return;
} }
} }
const headers = [] const headers = [];
for (const header of form.request.headers) { for (const header of form.request.headers) {
if (!header.key || !header.value) { if (!header.key || !header.value) {
continue continue;
} }
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() }) headers.push({ key: String(header.key).trim(), value: String(header.value).trim() });
} }
form.request.headers = headers form.request.headers = headers;
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) }) emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
} };
const importItem = async () => { const importItem = async () => {
const val = import_string.value.trim() const val = import_string.value.trim();
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.');
return return;
} }
try { try {
const item = decode(val) as notification & ImportedItem const item = decode(val) as notification & ImportedItem;
if ('notification' !== item._type) { if ('notification' !== item._type) {
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`) toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`);
import_string.value = '' import_string.value = '';
return return;
} }
if (form.name || form.request?.url) { if (form.name || form.request?.url) {
if (false === (await box.confirm('Overwrite the current form fields?'))) { if (false === (await box.confirm('Overwrite the current form fields?'))) {
return return;
} }
} }
if (item.name) { if (item.name) {
form.name = item.name form.name = item.name;
} }
if (!form.request) { if (!form.request) {
form.request = {} as any form.request = {} as any;
} }
if (item.request) { if (item.request) {
form.request = item.request form.request = item.request;
} }
if (item.request?.data_key) { if (item.request?.data_key) {
form.request.data_key = item.request.data_key form.request.data_key = item.request.data_key;
} }
if (item.on) { if (item.on) {
form.on = item.on form.on = item.on;
} }
if (item.presets) { if (item.presets) {
item.presets.forEach(p => { item.presets.forEach((p) => {
if (!config.presets.find(cp => cp.name === p)) { if (!config.presets.find((cp) => cp.name === p)) {
return return;
} }
if (!form.presets.includes(p)) { if (!form.presets.includes(p)) {
form.presets.push(p) form.presets.push(p);
} }
}) });
} }
if (item.enabled !== undefined) { if (item.enabled !== undefined) {
form.enabled = item.enabled form.enabled = item.enabled;
} }
import_string.value = '' import_string.value = '';
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Failed to import task. ${e.message}`) toast.error(`Failed to import task. ${e.message}`);
} }
} };
const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url)) const isAppriseTarget = computed(() => form.request.url && isApprise(form.request.url));
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) =>
config.presets.filter((item) => item.default === flag);
</script> </script>

View file

@ -52,7 +52,7 @@
</span> </span>
<span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span> <span class="icon ml-2" v-if="store.severityIcon"><i :class="store.severityIcon" /></span>
</a> </a>
<div class="navbar-dropdown is-right" style="width: 400px;"> <div class="navbar-dropdown is-right" style="width: 400px">
<template v-if="store.notifications.length > 0"> <template v-if="store.notifications.length > 0">
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center"> <div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
<span class="has-text-grey"></span> <span class="has-text-grey"></span>
@ -73,20 +73,31 @@
</div> </div>
</div> </div>
</div> </div>
<hr class="navbar-divider"> <hr class="navbar-divider" />
</template> </template>
<div class="notification-list"> <div class="notification-list">
<div v-for="n in store.notifications" :key="n.id" class="pr-1 pl-1 navbar-item is-flex is-align-items-start" <div
:class="['notification-item', 'notification-' + n.level]"> v-for="n in store.notifications"
:key="n.id"
class="pr-1 pl-1 navbar-item is-flex is-align-items-start"
:class="['notification-item', 'notification-' + n.level]"
>
<div class="is-flex-grow-1"> <div class="is-flex-grow-1">
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }" <p
@click="toggleExpand(n.id)"> class="is-size-7 mb-1 notification-message"
:class="{ expanded: expandedId === n.id }"
@click="toggleExpand(n.id)"
>
{{ n.message }} {{ n.message }}
</p> </p>
<p class="is-size-7 has-text-grey"> <p class="is-size-7 has-text-grey">
<span :date-datetime="n.created" v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')" <span
v-rtime="n.created" /> :date-datetime="n.created"
- <NuxtLink @click="copy_text(n.id, n.message)"> v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
v-rtime="n.created"
/>
-
<NuxtLink @click="copy_text(n.id, n.message)">
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span> <span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
<span v-else>Copy</span> <span v-else>Copy</span>
</NuxtLink> </NuxtLink>
@ -118,20 +129,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment';
const store = useNotificationStore() const store = useNotificationStore();
const copiedId = ref<string | null>(null) const copiedId = ref<string | null>(null);
const expandedId = ref<string | null>(null) const expandedId = ref<string | null>(null);
const toggleExpand = (id: string) => expandedId.value = expandedId.value === id ? null : id const toggleExpand = (id: string) => (expandedId.value = expandedId.value === id ? null : id);
const copy_text = (id: string, text: string): void => { const copy_text = (id: string, text: string): void => {
copiedId.value = id copiedId.value = id;
copyText(text, false, false) copyText(text, false, false);
setTimeout(() => { setTimeout(() => {
if (copiedId.value === id) copiedId.value = null if (copiedId.value === id) copiedId.value = null;
}, 2000) }, 2000);
} };
</script> </script>

View file

@ -1,36 +1,69 @@
<template> <template>
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading" <button
:class="{'is-loading':isLoading}"> rel="first"
class="button"
v-if="page !== 1"
@click="changePage(1)"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon"><i class="fas fa-angle-double-left"></i></span> <span class="icon"><i class="fas fa-angle-double-left"></i></span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)" <button
:disabled="isLoading" :class="{'is-loading':isLoading}"> rel="prev"
class="button"
v-if="page > 1 && page - 1 !== 1"
@click="changePage(page - 1)"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon"><i class="fas fa-angle-left"></i></span> <span class="icon"><i class="fas fa-angle-left"></i></span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading"> <select
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`" id="pager_list"
:value="item.page" :disabled="0 === item.page"> v-model="currentPage"
@change="changePage(currentPage)"
:disabled="isLoading"
>
<option
v-for="(item, index) in makePagination(page, last_page)"
:key="`pager-${index}`"
:value="item.page"
:disabled="0 === item.page"
>
{{ item.text }} {{ item.text }}
</option> </option>
</select> </select>
</div> </div>
</div> </div>
<div class="control"> <div class="control">
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page" <button
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }"> rel="next"
class="button"
v-if="page !== last_page && page + 1 !== last_page"
@click="changePage(page + 1)"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon"><i class="fas fa-angle-right"></i></span> <span class="icon"><i class="fas fa-angle-right"></i></span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)" <button
:disabled="isLoading" :class="{ 'is-loading': isLoading }"> rel="last"
class="button"
v-if="page !== last_page"
@click="changePage(last_page)"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon"><i class="fas fa-angle-double-right"></i></span> <span class="icon"><i class="fas fa-angle-double-right"></i></span>
</button> </button>
</div> </div>
@ -38,33 +71,33 @@
</template> </template>
<script setup> <script setup>
import {makePagination} from '~/utils/index' import { makePagination } from '~/utils/index';
const emitter = defineEmits(['navigate']) const emitter = defineEmits(['navigate']);
const props = defineProps({ const props = defineProps({
page: { page: {
type: Number, type: Number,
required: true required: true,
}, },
last_page: { last_page: {
type: Number, type: Number,
required: true required: true,
}, },
isLoading: { isLoading: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false,
}, },
}) });
const changePage = p => { const changePage = (p) => {
if (p < 1 || p > props.last_page) { if (p < 1 || p > props.last_page) {
return return;
} }
emitter('navigate', p) emitter('navigate', p);
currentPage.value = p currentPage.value = p;
} };
const currentPage = ref(props.page) const currentPage = ref(props.page);
</script> </script>

View file

@ -1,6 +1,14 @@
<template> <template>
<div class="popover-wrapper" ref="triggerElm" :tabindex="triggerTabIndex" @mouseenter="handleHoverEnter" <div
@mouseleave="handleHoverLeave" @focusin="handleFocusIn" @focusout="handleFocusOut" @click="handleClick"> class="popover-wrapper"
ref="triggerElm"
:tabindex="triggerTabIndex"
@mouseenter="handleHoverEnter"
@mouseleave="handleHoverLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
@click="handleClick"
>
<div class="popover-trigger"> <div class="popover-trigger">
<slot name="trigger"> <slot name="trigger">
<button type="button" class="button is-small is-rounded" aria-label="More information"> <button type="button" class="button is-small is-rounded" aria-label="More information">
@ -13,9 +21,20 @@
<Teleport to="body"> <Teleport to="body">
<div v-if="isOpen" class="popover-portal" ref="portal"> <div v-if="isOpen" class="popover-portal" ref="portal">
<div ref="popover" class="popover-card box" :class="placementClass" role="tooltip" :style="portalStyle" <div
@mouseenter="handleHoverEnter" @mouseleave="handleHoverLeave"> ref="popover"
<button type="button" class="button is-bold is-fullwidth is-hidden-tablet" @click="closePopover"> class="popover-card box"
:class="placementClass"
role="tooltip"
:style="portalStyle"
@mouseenter="handleHoverEnter"
@mouseleave="handleHoverLeave"
>
<button
type="button"
class="button is-bold is-fullwidth is-hidden-tablet"
@click="closePopover"
>
<span class="icon"><i class="fas fa-times" /></span> <span class="icon"><i class="fas fa-times" /></span>
<span>Close</span> <span>Close</span>
</button> </button>
@ -38,10 +57,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PopoverProps } from '~/types/popover' import type { PopoverProps } from '~/types/popover';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch, useTemplateRef } from 'vue' import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useSlots,
watch,
useTemplateRef,
} from 'vue';
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>() const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>();
const props = withDefaults(defineProps<PopoverProps>(), { const props = withDefaults(defineProps<PopoverProps>(), {
placement: 'top', placement: 'top',
@ -54,211 +82,211 @@ const props = withDefaults(defineProps<PopoverProps>(), {
minWidth: 220, minWidth: 220,
maxWidth: 340, maxWidth: 340,
maxHeight: 360, maxHeight: 360,
showDelay: 0 showDelay: 0,
}) });
const slots = useSlots() const slots = useSlots();
const isOpen = ref(false) const isOpen = ref(false);
const triggerRef = useTemplateRef<HTMLElement>('triggerElm') const triggerRef = useTemplateRef<HTMLElement>('triggerElm');
const popover = useTemplateRef<HTMLDivElement>('popover') const popover = useTemplateRef<HTMLDivElement>('popover');
const portalStyle = ref<Record<string, string>>({}) const portalStyle = ref<Record<string, string>>({});
const hoverTimeout = ref<number | null>(null) const hoverTimeout = ref<number | null>(null);
const openTimeout = ref<number | null>(null) const openTimeout = ref<number | null>(null);
const placementClass = computed(() => `is-${props.placement}`) const placementClass = computed(() => `is-${props.placement}`);
const triggerTabIndex = computed(() => props.trigger === 'hover' ? -1 : 0) const triggerTabIndex = computed(() => (props.trigger === 'hover' ? -1 : 0));
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title)) const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title));
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default)) const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default));
const clearHoverTimeout = () => { const clearHoverTimeout = () => {
if (null !== hoverTimeout.value) { if (null !== hoverTimeout.value) {
window.clearTimeout(hoverTimeout.value) window.clearTimeout(hoverTimeout.value);
hoverTimeout.value = null hoverTimeout.value = null;
} }
} };
const clearOpenTimeout = () => { const clearOpenTimeout = () => {
if (null !== openTimeout.value) { if (null !== openTimeout.value) {
window.clearTimeout(openTimeout.value) window.clearTimeout(openTimeout.value);
openTimeout.value = null openTimeout.value = null;
} }
} };
const scheduleClose = () => { const scheduleClose = () => {
clearHoverTimeout() clearHoverTimeout();
clearOpenTimeout() clearOpenTimeout();
hoverTimeout.value = window.setTimeout(() => { hoverTimeout.value = window.setTimeout(() => {
isOpen.value = false isOpen.value = false;
}, 120) }, 120);
} };
const openPopoverImmediate = async () => { const openPopoverImmediate = async () => {
if (props.disabled || isOpen.value) { if (props.disabled || isOpen.value) {
return return;
} }
isOpen.value = true isOpen.value = true;
await nextTick() await nextTick();
updatePosition() updatePosition();
} };
const openPopover = () => { const openPopover = () => {
clearOpenTimeout() clearOpenTimeout();
if (props.showDelay && 0 < props.showDelay) { if (props.showDelay && 0 < props.showDelay) {
openTimeout.value = window.setTimeout(() => { openTimeout.value = window.setTimeout(() => {
openTimeout.value = null openTimeout.value = null;
void openPopoverImmediate() void openPopoverImmediate();
}, props.showDelay) }, props.showDelay);
return return;
} }
void openPopoverImmediate() void openPopoverImmediate();
} };
const closePopover = () => { const closePopover = () => {
if (!isOpen.value) { if (!isOpen.value) {
return return;
} }
isOpen.value = false isOpen.value = false;
} };
const togglePopover = async () => { const togglePopover = async () => {
if (isOpen.value) { if (isOpen.value) {
closePopover() closePopover();
return return;
} }
openPopover() openPopover();
} };
const handleHoverEnter = () => { const handleHoverEnter = () => {
if ('hover' !== props.trigger || props.disabled) { if ('hover' !== props.trigger || props.disabled) {
return return;
} }
clearHoverTimeout() clearHoverTimeout();
openPopover() openPopover();
} };
const handleHoverLeave = () => { const handleHoverLeave = () => {
if ('hover' !== props.trigger) { if ('hover' !== props.trigger) {
return return;
} }
clearOpenTimeout() clearOpenTimeout();
scheduleClose() scheduleClose();
} };
const handleFocusIn = () => { const handleFocusIn = () => {
if ('focus' !== props.trigger || props.disabled) { if ('focus' !== props.trigger || props.disabled) {
return return;
} }
openPopover() openPopover();
} };
const handleFocusOut = () => { const handleFocusOut = () => {
if ('focus' !== props.trigger) { if ('focus' !== props.trigger) {
return return;
} }
clearOpenTimeout() clearOpenTimeout();
scheduleClose() scheduleClose();
} };
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if ('click' !== props.trigger || props.disabled) { if ('click' !== props.trigger || props.disabled) {
return return;
} }
event.stopPropagation() event.stopPropagation();
void togglePopover() void togglePopover();
} };
const handleDocumentClick = (event: MouseEvent) => { const handleDocumentClick = (event: MouseEvent) => {
if (!props.closeOnClickOutside || 'click' !== props.trigger) { if (!props.closeOnClickOutside || 'click' !== props.trigger) {
return return;
} }
const target = event.target as Node | null const target = event.target as Node | null;
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target)) const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target));
const isInsidePopover = Boolean(popover.value && popover.value.contains(target)) const isInsidePopover = Boolean(popover.value && popover.value.contains(target));
if (!isInsideTrigger && !isInsidePopover) { if (!isInsideTrigger && !isInsidePopover) {
clearOpenTimeout() clearOpenTimeout();
closePopover() closePopover();
} }
} };
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: KeyboardEvent) => {
if ('Escape' === event.key && isOpen.value) { if ('Escape' === event.key && isOpen.value) {
closePopover() closePopover();
} }
} };
const updatePosition = () => { const updatePosition = () => {
if (!triggerRef.value || !popover.value) { if (!triggerRef.value || !popover.value) {
return return;
} }
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight;
const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96) const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96);
popover.value.style.minWidth = `${props.minWidth}px` popover.value.style.minWidth = `${props.minWidth}px`;
popover.value.style.maxWidth = `${constrainedMaxWidth}px` popover.value.style.maxWidth = `${constrainedMaxWidth}px`;
popover.value.style.maxHeight = `${props.maxHeight}px` popover.value.style.maxHeight = `${props.maxHeight}px`;
popover.value.style.overflowY = 'auto' popover.value.style.overflowY = 'auto';
const triggerRect = triggerRef.value.getBoundingClientRect() const triggerRect = triggerRef.value.getBoundingClientRect();
const popoverRect = popover.value.getBoundingClientRect() const popoverRect = popover.value.getBoundingClientRect();
const offset = props.offset const offset = props.offset;
let effectivePlacement = props.placement let effectivePlacement = props.placement;
if ('top' === props.placement) { if ('top' === props.placement) {
const spaceAbove = triggerRect.top - offset const spaceAbove = triggerRect.top - offset;
if (spaceAbove < popoverRect.height) { if (spaceAbove < popoverRect.height) {
effectivePlacement = 'bottom' effectivePlacement = 'bottom';
} }
} else if ('bottom' === props.placement) { } else if ('bottom' === props.placement) {
const spaceBelow = viewportHeight - triggerRect.bottom - offset const spaceBelow = viewportHeight - triggerRect.bottom - offset;
if (spaceBelow < popoverRect.height) { if (spaceBelow < popoverRect.height) {
effectivePlacement = 'top' effectivePlacement = 'top';
} }
} else if ('left' === props.placement) { } else if ('left' === props.placement) {
const spaceLeft = triggerRect.left - offset const spaceLeft = triggerRect.left - offset;
if (spaceLeft < popoverRect.width) { if (spaceLeft < popoverRect.width) {
effectivePlacement = 'right' effectivePlacement = 'right';
} }
} else if ('right' === props.placement) { } else if ('right' === props.placement) {
const spaceRight = viewportWidth - triggerRect.right - offset const spaceRight = viewportWidth - triggerRect.right - offset;
if (spaceRight < popoverRect.width) { if (spaceRight < popoverRect.width) {
effectivePlacement = 'left' effectivePlacement = 'left';
} }
} }
let top = triggerRect.bottom + offset let top = triggerRect.bottom + offset;
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2;
if ('top' === effectivePlacement) { if ('top' === effectivePlacement) {
top = triggerRect.top - popoverRect.height - offset top = triggerRect.top - popoverRect.height - offset;
} else if ('left' === effectivePlacement) { } else if ('left' === effectivePlacement) {
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
left = triggerRect.left - popoverRect.width - offset left = triggerRect.left - popoverRect.width - offset;
} else if ('right' === effectivePlacement) { } else if ('right' === effectivePlacement) {
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2;
left = triggerRect.right + offset left = triggerRect.right + offset;
} }
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const maxLeft = viewportWidth - popoverRect.width - 8 const maxLeft = viewportWidth - popoverRect.width - 8;
const maxTop = viewportHeight - popoverRect.height - 8 const maxTop = viewportHeight - popoverRect.height - 8;
const clampedTop = clamp(Math.round(top), 8, maxTop) const clampedTop = clamp(Math.round(top), 8, maxTop);
const clampedLeft = clamp(Math.round(left), 8, maxLeft) const clampedLeft = clamp(Math.round(left), 8, maxLeft);
portalStyle.value = { portalStyle.value = {
position: 'fixed', position: 'fixed',
@ -267,37 +295,37 @@ const updatePosition = () => {
minWidth: `${props.minWidth}px`, minWidth: `${props.minWidth}px`,
maxWidth: `${constrainedMaxWidth}px`, maxWidth: `${constrainedMaxWidth}px`,
maxHeight: `${props.maxHeight}px`, maxHeight: `${props.maxHeight}px`,
overflowY: 'auto' overflowY: 'auto',
} };
} };
watch(isOpen, async (value) => { watch(isOpen, async (value) => {
if (value) { if (value) {
await nextTick() await nextTick();
updatePosition() updatePosition();
await nextTick() await nextTick();
emit('shown') emit('shown');
return return;
} }
emit('hidden') emit('hidden');
}) });
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleDocumentClick, true) document.addEventListener('click', handleDocumentClick, true);
document.addEventListener('keydown', handleEscape) document.addEventListener('keydown', handleEscape);
window.addEventListener('resize', updatePosition) window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true) window.addEventListener('scroll', updatePosition, true);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick, true) document.removeEventListener('click', handleDocumentClick, true);
document.removeEventListener('keydown', handleEscape) document.removeEventListener('keydown', handleEscape);
window.removeEventListener('resize', updatePosition) window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('scroll', updatePosition, true);
clearHoverTimeout() clearHoverTimeout();
clearOpenTimeout() clearOpenTimeout();
}) });
</script> </script>
<style> <style>
@ -315,7 +343,7 @@ onBeforeUnmount(() => {
min-width: 0; min-width: 0;
} }
.popover-trigger>* { .popover-trigger > * {
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
} }
@ -332,7 +360,11 @@ onBeforeUnmount(() => {
background-color: var(--bulma-scheme-main, #fff); background-color: var(--bulma-scheme-main, #fff);
color: var(--bulma-text, #363636); color: var(--bulma-text, #363636);
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08)); border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
box-shadow: var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02)); box-shadow: var(
--bulma-shadow,
0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1),
0 0px 0 1px rgba(10, 10, 10, 0.02)
);
border-radius: var(--bulma-radius-large, 0.5rem); border-radius: var(--bulma-radius-large, 0.5rem);
pointer-events: auto; pointer-events: auto;
padding: 0.9rem 1rem; padding: 0.9rem 1rem;

View file

@ -6,16 +6,22 @@
<div class="card-header"> <div class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span class="icon"
><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'"
/></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
</span> </span>
</div> </div>
<div class="card-header-icon" v-if="reference"> <div class="card-header-icon" v-if="reference">
<button type="button" @click="showImport = !showImport"> <button type="button" @click="showImport = !showImport">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"
'fa-arrow-down': !showImport, ><i
'fa-arrow-up': showImport, class="fa-solid"
}" /></span> :class="{
'fa-arrow-down': !showImport,
'fa-arrow-up': showImport,
}"
/></span>
<span>{{ showImport ? 'Hide' : 'Show' }} import</span> <span>{{ showImport ? 'Hide' : 'Show' }} import</span>
</button> </button>
</div> </div>
@ -32,15 +38,30 @@
</label> </label>
<div class="control is-expanded"> <div class="control is-expanded">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select class="is-fullwidth" v-model="selected_preset" @change="import_existing_preset"> <select
class="is-fullwidth"
v-model="selected_preset"
@change="import_existing_preset"
>
<option value="" disabled>Select a preset</option> <option value="" disabled>Select a preset</option>
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0"> <optgroup
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name"> label="Custom presets"
v-if="config?.presets.filter((p) => !p?.default).length > 0"
>
<option
v-for="item in filter_presets(false)"
:key="item.name"
:value="item.name"
>
{{ prettyName(item.name) }} {{ prettyName(item.name) }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="Default presets"> <optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name"> <option
v-for="item in filter_presets(true)"
:key="item.name"
:value="item.name"
>
{{ prettyName(item.name) }} {{ prettyName(item.name) }}
</option> </option>
</optgroup> </optgroup>
@ -49,8 +70,12 @@
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select a preset to import its data. <span class="has-text-danger">Warning: This will <span
overwrite the current form data.</span></span> >Select a preset to import its data.
<span class="has-text-danger"
>Warning: This will overwrite the current form data.</span
></span
>
</span> </span>
</div> </div>
</div> </div>
@ -64,12 +89,21 @@
<div class="field-body"> <div class="field-body">
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="import_string" v-model="import_string" <input
autocomplete="off"> type="text"
class="input"
id="import_string"
v-model="import_string"
autocomplete="off"
/>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary" :disabled="!import_string" type="button" <button
@click="importItem"> class="button is-primary"
:disabled="!import_string"
type="button"
@click="importItem"
>
<span class="icon"><i class="fa-solid fa-add" /></span> <span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span> <span>Import</span>
</button> </button>
@ -78,12 +112,15 @@
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Paste shared preset string here to import it. <span class="has-text-danger">Warning: <span
This will overwrite the current form data.</span></span> >Paste shared preset string here to import it.
<span class="has-text-danger"
>Warning: This will overwrite the current form data.</span
></span
>
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
@ -93,7 +130,13 @@
Name Name
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"> <input
type="text"
class="input"
id="name"
v-model="form.name"
:disabled="addInProgress"
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -109,8 +152,15 @@
Priority Priority
</label> </label>
<div class="control"> <div class="control">
<input type="number" class="input" id="priority" v-model.number="form.priority" <input
:disabled="addInProgress" min="0" placeholder="0"> type="number"
class="input"
id="priority"
v-model.number="form.priority"
:disabled="addInProgress"
min="0"
placeholder="0"
/>
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -131,8 +181,15 @@
</span> </span>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path" <input
v-model="form.folder" :disabled="addInProgress" list="folders"> type="text"
class="input"
id="folder"
placeholder="Leave empty to use default download path"
v-model="form.folder"
:disabled="addInProgress"
list="folders"
/>
</div> </div>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
@ -148,8 +205,14 @@
Output template Output template
</label> </label>
<div class="control"> <div class="control">
<input type="text" class="input" id="output_template" :disabled="addInProgress" <input
placeholder="Leave empty to use default template." v-model="form.template"> type="text"
class="input"
id="output_template"
:disabled="addInProgress"
placeholder="Leave empty to use default template."
v-model="form.template"
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -164,15 +227,22 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Command options for yt-dlp</span> <span>Command options for yt-dlp</span>
</label> </label>
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" <TextareaAutocomplete
:disabled="addInProgress" /> id="cli_options"
v-model="form.cli"
:options="ytDlpOpt"
:disabled="addInProgress"
/>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all
supported <NuxtLink target="_blank" options are supported
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some <NuxtLink
are ignored</NuxtLink>. Use with caution. target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26"
>some are ignored</NuxtLink
>. Use with caution.
</span> </span>
</span> </span>
</div> </div>
@ -180,21 +250,39 @@
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'"> <label
class="label is-inline"
for="cookies"
v-tooltip="'Netscape HTTP Cookie format.'"
>
<span class="icon"><i class="fa-solid fa-cookie" /></span> <span class="icon"><i class="fa-solid fa-cookie" /></span>
Cookies - <NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()">Upload file</NuxtLink> Cookies -
<NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()"
>Upload file</NuxtLink
>
</label> </label>
<div class="control"> <div class="control">
<TextDropzone ref="cookiesDropzoneRef" id="cookies" v-model="form.cookies" :disabled="addInProgress" <TextDropzone
ref="cookiesDropzoneRef"
id="cookies"
v-model="form.cookies"
:disabled="addInProgress"
@error="(msg: string) => toast.error(msg)" @error="(msg: string) => toast.error(msg)"
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here." /> placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here."
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this cookies if non are given with the URL. Use the <NuxtLink target="_blank" <span
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"> >Use this cookies if non are given with the URL. Use the
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP <NuxtLink
Cookie format. target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp"
>
Recommended addon</NuxtLink
>
by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP Cookie
format.
</span> </span>
</span> </span>
</div> </div>
@ -207,8 +295,13 @@
Description Description
</label> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="description" v-model="form.description" :disabled="addInProgress" <textarea
placeholder="Extras instructions for users to follow" /> class="textarea"
id="description"
v-model="form.description"
:disabled="addInProgress"
placeholder="Extras instructions for users to follow"
/>
</div> </div>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -221,15 +314,24 @@
<div class="card-footer"> <div class="card-footer">
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit" <button
:class="{ 'is-loading': addInProgress }" form="presetForm"> class="button is-fullwidth is-primary"
:disabled="addInProgress"
type="submit"
:class="{ 'is-loading': addInProgress }"
form="presetForm"
>
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span> <span>Save</span>
</button> </button>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress" <button
type="button"> class="button is-fullwidth is-danger"
@click="emitter('cancel')"
:disabled="addInProgress"
type="button"
>
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -250,191 +352,199 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue' import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
import TextDropzone from '~/components/TextDropzone.vue' import TextDropzone from '~/components/TextDropzone.vue';
import type { ImportedItem } from '~/types'; import type { ImportedItem } from '~/types';
import type { AutoCompleteOptions } from '~/types/autocomplete'; import type { AutoCompleteOptions } from '~/types/autocomplete';
import type { Preset } from '~/types/presets' import type { Preset } from '~/types/presets';
import { normalizePresetName, prettyName } from '~/utils' import { normalizePresetName, prettyName } from '~/utils';
const emitter = defineEmits<{ const emitter = defineEmits<{
(event: 'cancel'): void (event: 'cancel'): void;
(event: 'submit', payload: { reference: number | null, preset: Preset }): void (event: 'submit', payload: { reference: number | null; preset: Preset }): void;
}>() }>();
const props = defineProps<{ const props = defineProps<{
reference?: number | null reference?: number | null;
preset: Partial<Preset> preset: Partial<Preset>;
addInProgress?: boolean addInProgress?: boolean;
presets?: Preset[] presets?: Preset[];
}>() }>();
const config = useConfigStore() const config = useConfigStore();
const toast = useNotification() const toast = useNotification();
const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset))) const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)));
const import_string = ref<string>('') const import_string = ref<string>('');
const showImport = useStorage<boolean>('showImport', false) const showImport = useStorage<boolean>('showImport', false);
const selected_preset = ref<string>('') const selected_preset = ref<string>('');
const showOptions = ref<boolean>(false) const showOptions = ref<boolean>(false);
const ytDlpOpt = ref<AutoCompleteOptions>([]) const ytDlpOpt = ref<AutoCompleteOptions>([]);
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null) const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null);
if (form.priority === undefined) { if (form.priority === undefined) {
form.priority = 0 form.priority = 0;
} }
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions watch(
.filter(opt => !opt.ignored) () => config.ytdlp_options,
.flatMap(opt => opt.flags (newOptions) =>
.filter(flag => flag.startsWith('--')) (ytDlpOpt.value = newOptions
.map(flag => ({ value: flag, description: opt.description || '' }))), .filter((opt) => !opt.ignored)
{ immediate: true } .flatMap((opt) =>
) opt.flags
.filter((flag) => flag.startsWith('--'))
.map((flag) => ({ value: flag, description: opt.description || '' })),
)),
{ immediate: true },
);
const checkInfo = async (): Promise<void> => { const checkInfo = async (): Promise<void> => {
for (const key of ['name']) { for (const key of ['name']) {
if (!form[key as keyof Preset]) { if (!form[key as keyof Preset]) {
toast.error(`The ${key} field is required.`) toast.error(`The ${key} field is required.`);
return return;
} }
} }
const normalizedName = normalizePresetName(String(form.name)) const normalizedName = normalizePresetName(String(form.name));
if (!normalizedName) { if (!normalizedName) {
toast.error('The name field is required.') toast.error('The name field is required.');
return return;
} }
form.name = normalizedName form.name = normalizedName;
if (form.folder) { if (form.folder) {
form.folder = form.folder.trim() form.folder = form.folder.trim();
await nextTick() await nextTick();
} }
if (form.cli && '' !== form.cli) { if (form.cli && '' !== form.cli) {
const options = await convertOptions(form.cli) const options = await convertOptions(form.cli);
if (null === options) { if (null === options) {
return return;
} }
form.cli = form.cli.trim() form.cli = form.cli.trim();
} }
const copy: Preset = JSON.parse(JSON.stringify(form)) const copy: Preset = JSON.parse(JSON.stringify(form));
let usedName = false let usedName = false;
const name = normalizedName const name = normalizedName;
props.presets?.forEach(p => { props.presets?.forEach((p) => {
if (p.id === props.reference) { if (p.id === props.reference) {
return return;
} }
if (p.name === name) { if (p.name === name) {
usedName = true usedName = true;
} }
}) });
if (usedName) { if (usedName) {
toast.error('The preset name is already in use.') toast.error('The preset name is already in use.');
return return;
} }
for (const key in copy) { for (const key in copy) {
const val = copy[key as keyof Preset] const val = copy[key as keyof Preset];
if ('string' === typeof val) { if ('string' === typeof val) {
(copy as any)[key] = val.trim() (copy as any)[key] = val.trim();
} }
} }
emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) }) emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) });
} };
const convertOptions = async (args: string): Promise<Record<string, any> | null> => { const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args);
if (response.output_template) { if (response.output_template) {
form.template = response.output_template form.template = response.output_template;
} }
if (response.download_path) { if (response.download_path) {
form.folder = response.download_path form.folder = response.download_path;
} }
return response.opts as Record<string, any> return response.opts as Record<string, any>;
} catch (e: any) { } catch (e: any) {
toast.error(e.message) toast.error(e.message);
return null return null;
} }
} };
const importItem = async (): Promise<void> => { const importItem = async (): Promise<void> => {
const val = import_string.value.trim() const val = import_string.value.trim();
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.');
return return;
} }
try { try {
const item = decode(val) as Preset & ImportedItem const item = decode(val) as Preset & ImportedItem;
if (!item?._type || 'preset' !== item._type) { if (!item?._type || 'preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`) toast.error(
return `Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`,
);
return;
} }
if (item.name) { if (item.name) {
form.name = item.name form.name = item.name;
} }
if (item.cli) { if (item.cli) {
form.cli = item.cli form.cli = item.cli;
} }
if (item.template) { if (item.template) {
form.template = item.template form.template = item.template;
} }
if (item.folder) { if (item.folder) {
form.folder = item.folder form.folder = item.folder;
} }
if (item.description) { if (item.description) {
form.description = item.description form.description = item.description;
} }
if (item.priority !== undefined) { if (item.priority !== undefined) {
form.priority = item.priority form.priority = item.priority;
} }
import_string.value = '' import_string.value = '';
showImport.value = false showImport.value = false;
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Failed to parse. ${e.message}`) toast.error(`Failed to parse. ${e.message}`);
} }
} };
const filter_presets = (flag = true): Preset[] => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true): Preset[] =>
config.presets.filter((item) => item.default === flag);
const import_existing_preset = async (): Promise<void> => { const import_existing_preset = async (): Promise<void> => {
if (!selected_preset.value) { if (!selected_preset.value) {
return return;
} }
const preset = config.presets.find(p => p.name === selected_preset.value) const preset = config.presets.find((p) => p.name === selected_preset.value);
if (!preset) { if (!preset) {
toast.error('Preset not found.') toast.error('Preset not found.');
return return;
} }
form.cli = preset.cli || '' form.cli = preset.cli || '';
form.folder = preset.folder || '' form.folder = preset.folder || '';
form.template = preset.template || '' form.template = preset.template || '';
form.cookies = preset.cookies || '' form.cookies = preset.cookies || '';
form.description = preset.description || '' form.description = preset.description || '';
form.priority = preset.priority ?? 0 form.priority = preset.priority ?? 0;
await nextTick() await nextTick();
selected_preset.value = '' selected_preset.value = '';
} };
</script> </script>

View file

@ -1,10 +1,18 @@
<template> <template>
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end" v-if="!socket.isConnected"> <div
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
v-if="!socket.isConnected"
>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button type="button" class="button is-info" @click="refreshQueue" :class="{ 'is-loading': isRefreshing }" <button
v-tooltip.bottom="'Refresh queue data'"> type="button"
class="button is-info"
@click="refreshQueue"
:class="{ 'is-loading': isRefreshing }"
v-tooltip.bottom="'Refresh queue data'"
>
<span class="icon"> <span class="icon">
<i class="fa-solid fa-refresh" /> <i class="fa-solid fa-refresh" />
</span> </span>
@ -15,35 +23,62 @@
</div> </div>
</div> </div>
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end" <div
v-if="filteredItems.length > 0"> class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
v-if="filteredItems.length > 0"
>
<div class="column is-narrow"> <div class="column is-narrow">
<button type="button" class="button" @click="masterSelectAll = !masterSelectAll" <button
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"> type="button"
class="button"
@click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
>
<span class="icon"> <span class="icon">
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" /> <i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span> </span>
<span v-if="!masterSelectAll">Select</span> <span v-if="!masterSelectAll">Select</span>
<span v-else>Unselect</span> <span v-else>Unselect</span>
<span v-if="selectedElms.length > 0"> <span v-if="selectedElms.length > 0">
&nbsp;(<u class="has-text-danger">{{ selectedElms.length }}</u>) &nbsp;(<u class="has-text-danger">{{ selectedElms.length }}</u
>)
</span> </span>
</button> </button>
</div> </div>
<div class="column is-2-tablet is-5-mobile"> <div class="column is-2-tablet is-5-mobile">
<Dropdown label="Actions" icons="fa-solid fa-list"> <Dropdown label="Actions" icons="fa-solid fa-list">
<a v-if="hasManualStart" class="dropdown-item has-text-success" @click="hasSelected ? startItems() : null" <a
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }"> v-if="hasManualStart"
class="dropdown-item has-text-success"
@click="hasSelected ? startItems() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-circle-play" /></span> <span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span> <span>Start</span>
</a> </a>
<a v-if="hasPausable" class="dropdown-item has-text-warning" @click="hasSelected ? pauseSelected() : null" <a
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }"> v-if="hasPausable"
class="dropdown-item has-text-warning"
@click="hasSelected ? pauseSelected() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-pause" /></span> <span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span> <span>Pause</span>
</a> </a>
<a class="dropdown-item has-text-warning" @click="hasSelected ? cancelSelected() : null" <a
:style="{ opacity: !hasSelected ? 0.5 : 1, cursor: !hasSelected ? 'not-allowed' : 'pointer' }"> class="dropdown-item has-text-warning"
@click="hasSelected ? cancelSelected() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-eject" /></span> <span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span> <span>Cancel</span>
</a> </a>
@ -54,16 +89,23 @@
<div class="columns is-multiline" v-if="'list' === display_style"> <div class="columns is-multiline" v-if="'list' === display_style">
<div class="column is-12" v-if="filteredItems.length > 0"> <div class="column is-12" v-if="filteredItems.length > 0">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 1300px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'"> <th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll"> <a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"> <span class="icon">
<i class="fa-regular" <i
:class="{ 'fa-square-check': !masterSelectAll, 'fa-square': masterSelectAll }" /> class="fa-regular"
:class="{
'fa-square-check': !masterSelectAll,
'fa-square': masterSelectAll,
}"
/>
</span> </span>
</span> </span>
</a> </a>
@ -79,13 +121,20 @@
<tr v-for="item in filteredItems" :key="item._id"> <tr v-for="item in filteredItems" :key="item._id">
<td class="has-text-centered is-vcentered"> <td class="has-text-centered is-vcentered">
<label class="checkbox is-block"> <label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id" <input
:value="item._id"> class="completed-checkbox"
type="checkbox"
v-model="selectedElms"
:id="'checkbox-' + item._id"
:value="item._id"
/>
</label> </label>
</td> </td>
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || show_popover"> <div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || show_popover">
<span class="tag" v-if="item.downloaded_bytes">{{ formatBytes(item.downloaded_bytes) }}</span> <span class="tag" v-if="item.downloaded_bytes">{{
formatBytes(item.downloaded_bytes)
}}</span>
<Popover :showDelay="400" :maxWidth="450" v-if="show_popover"> <Popover :showDelay="400" :maxWidth="450" v-if="show_popover">
<template #trigger> <template #trigger>
<span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span> <span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span>
@ -103,9 +152,16 @@
<b>Path:</b> {{ getPath(config.app.download_path, item) }} <b>Path:</b> {{ getPath(config.app.download_path, item) }}
</p> </p>
<hr <hr
v-if="(showThumbnails && getImage(config.app.download_path, item, false)) || item.description" /> v-if="
<img v-if="showThumbnails && getImage(config.app.download_path, item, false)" (showThumbnails && getImage(config.app.download_path, item, false)) ||
:src="getImage(config.app.download_path, item, false)" class="card-image mt-2 mb-2" /> item.description
"
/>
<img
v-if="showThumbnails && getImage(config.app.download_path, item, false)"
:src="getImage(config.app.download_path, item, false)"
class="card-image mt-2 mb-2"
/>
<p v-if="item.description">{{ item.description }}</p> <p v-if="item.description">{{ item.description }}</p>
</Popover> </Popover>
</div> </div>
@ -122,25 +178,33 @@
<td> <td>
<div class="progress-bar is-unselectable"> <div class="progress-bar is-unselectable">
<div class="progress-percentage" v-html="updateProgress(item)" /> <div class="progress-percentage" v-html="updateProgress(item)" />
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div> <div
class="progress"
:style="{ width: percentPipe(item.percent as number) + '%' }"
></div>
</div> </div>
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <td class="has-text-centered is-text-overflow is-unselectable">
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime" <span
v-rtime="item.datetime" /> v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
:data-datetime="item.datetime"
v-rtime="item.datetime"
/>
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions"> <Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" <NuxtLink
@click="embed_url = getEmbedable(item.url) as string"> class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string"
>
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</template> </template>
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item);"> <NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item)">
<span class="icon"><i class="fa-solid fa-eject" /></span> <span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel Download</span> <span>Cancel Download</span>
</NuxtLink> </NuxtLink>
@ -163,7 +227,10 @@
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"> <NuxtLink
class="dropdown-item"
@click="emitter('getInfo', item.url, item.preset, item.cli)"
>
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
@ -182,12 +249,19 @@
</div> </div>
<div class="columns is-multiline" v-else> <div class="columns is-multiline" v-else>
<LateLoader :unrender="true" :min-height="showThumbnails ? 475 : 265" class="column is-6" <LateLoader
v-for="item in filteredItems" :key="item._id"> :unrender="true"
:min-height="showThumbnails ? 475 : 265"
class="column is-6"
v-for="item in filteredItems"
:key="item._id"
>
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-text-overflow is-block"> <div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" v-tooltip="item.title" :href="item.url">{{ item.title }}</NuxtLink> <NuxtLink target="_blank" v-tooltip="item.title" :href="item.url">{{
item.title
}}</NuxtLink>
</div> </div>
<div class="card-header-icon"> <div class="card-header-icon">
<div class="field is-grouped"> <div class="field is-grouped">
@ -214,14 +288,22 @@
</Popover> </Popover>
<div class="control"> <div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails"> <button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"><i class="fa-solid" <span class="icon"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }" /></span> ><i
class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }"
/></span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<label class="checkbox is-block"> <label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id" <input
:value="item._id"> class="completed-checkbox"
type="checkbox"
v-model="selectedElms"
:id="'checkbox-' + item._id"
:value="item._id"
/>
</label> </label>
</div> </div>
</div> </div>
@ -229,16 +311,27 @@
</header> </header>
<div v-if="showThumbnails" class="card-image"> <div v-if="showThumbnails" class="card-image">
<figure :class="['image', thumbnail_ratio]"> <figure :class="['image', thumbnail_ratio]">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" <span
class="play-overlay"> v-if="isEmbedable(item.url)"
@click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"
>
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)" <img
:src="getImage(config.app.download_path, item)" /> @load="pImg"
@error="onImgError"
v-if="getImage(config.app.download_path, item)"
:src="getImage(config.app.download_path, item)"
/>
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<template v-else> <template v-else>
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)" <img
:src="getImage(config.app.download_path, item)" /> @load="pImg"
@error="onImgError"
v-if="getImage(config.app.download_path, item)"
:src="getImage(config.app.download_path, item)"
/>
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</template> </template>
</figure> </figure>
@ -248,7 +341,10 @@
<div class="column is-12"> <div class="column is-12">
<div class="progress-bar is-unselectable"> <div class="progress-bar is-unselectable">
<div class="progress-percentage" v-html="updateProgress(item)" /> <div class="progress-percentage" v-html="updateProgress(item)" />
<div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div> <div
class="progress"
:style="{ width: percentPipe(item.percent as number) + '%' }"
></div>
</div> </div>
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
@ -259,20 +355,27 @@
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{ item.preset }}</span> <span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{
item.preset
}}</span>
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime" <span
v-rtime="item.datetime" /> v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
:data-datetime="item.datetime"
v-rtime="item.datetime"
/>
</div> </div>
<div class="column is-half-mobile has-text-centered is-unselectable" v-if="item.downloaded_bytes"> <div
class="column is-half-mobile has-text-centered is-unselectable"
v-if="item.downloaded_bytes"
>
{{ formatBytes(item.downloaded_bytes) }} {{ formatBytes(item.downloaded_bytes) }}
</div> </div>
</div> </div>
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<div class="column is-half-mobile"> <div class="column is-half-mobile">
<button class="button is-warning is-fullwidth" @click="confirmCancel(item);"> <button class="button is-warning is-fullwidth" @click="confirmCancel(item)">
<span class="icon"><i class="fa-solid fa-eject" /></span> <span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
@ -284,7 +387,10 @@
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="item.auto_start && !item.status"> <div class="column is-half-mobile" v-if="item.auto_start && !item.status">
<button class="button is-warning is-background-warning-85 is-fullwidth" @click="pauseItem(item)"> <button
class="button is-warning is-background-warning-85 is-fullwidth"
@click="pauseItem(item)"
>
<span class="icon"><i class="fa-solid fa-pause" /></span> <span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span> <span>Pause</span>
</button> </button>
@ -292,14 +398,20 @@
<div class="column"> <div class="column">
<Dropdown icons="fa-solid fa-cogs" label="Actions"> <Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url) as string"> <NuxtLink
class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string"
>
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</template> </template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"> <NuxtLink
class="dropdown-item"
@click="emitter('getInfo', item.url, item.preset, item.cli)"
>
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
@ -318,14 +430,24 @@
<div class="columns is-multiline" v-if="filteredItems.length < 1"> <div class="columns is-multiline" v-if="filteredItems.length < 1">
<div class="column is-12"> <div class="column is-12">
<Message class="is-warning" title="Filter results" icon="fas fa-search" :useClose="true" <Message
@close="() => emitter('clear_search')" v-if="query"> class="is-warning"
<span class="is-block">No results found for: <code>{{ query }}</code>.</span> title="Filter results"
<hr> icon="fas fa-search"
:useClose="true"
@close="() => emitter('clear_search')"
v-if="query"
>
<span class="is-block"
>No results found for: <code>{{ query }}</code
>.</span
>
<hr />
<p> <p>
You can search using any value shown in the items <code><span class="icon"><i You can search using any value shown in the items
class="fa-solid fa-info-circle" /></span> Local Information</code>. You can also do a targeted search <code
using <code><u>key</u>:value</code>. ><span class="icon"><i class="fa-solid fa-info-circle" /></span> Local Information</code
>. You can also do a targeted search using <code><u>key</u>:value</code>.
</p> </p>
<h5>Examples:</h5> <h5>Examples:</h5>
@ -336,7 +458,13 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li> <li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul> </ul>
</Message> </Message>
<Message v-else class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false"> <Message
v-else
class="is-info"
title="No items"
icon="fas fa-exclamation-triangle"
:useClose="false"
>
<p>Download queue is empty.</p> <p>Download queue is empty.</p>
</Message> </Message>
</div> </div>
@ -352,371 +480,376 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes } from '~/utils' import { deepIncludes } from '~/utils';
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string, cli: string): void (e: 'getInfo', url: string, preset: string, cli: string): void;
(e: 'getItemInfo', id: string): void (e: 'getItemInfo', id: string): void;
(e: 'clear_search'): void (e: 'clear_search'): void;
}>() }>();
const props = defineProps<{ const props = defineProps<{
thumbnails?: boolean thumbnails?: boolean;
query?: string query?: string;
}>() }>();
const config = useConfigStore() const config = useConfigStore();
const stateStore = useStateStore() const stateStore = useStateStore();
const socket = useSocketStore() const socket = useSocketStore();
const box = useConfirm() const box = useConfirm();
const toast = useNotification() const toast = useNotification();
const hideThumbnail = useStorage('hideThumbnailQueue', false) const hideThumbnail = useStorage('hideThumbnailQueue', false);
const display_style = useStorage('display_style', 'grid') const display_style = useStorage('display_style', 'grid');
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true);
const bg_opacity = useStorage('random_bg_opacity', 0.95) const bg_opacity = useStorage('random_bg_opacity', 0.95);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1') const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
const show_popover = useStorage<boolean>('show_popover', true) const show_popover = useStorage<boolean>('show_popover', true);
const selectedElms = ref<string[]>([]) const selectedElms = ref<string[]>([]);
const masterSelectAll = ref(false) const masterSelectAll = ref(false);
const embed_url = ref('') const embed_url = ref('');
const isRefreshing = ref(false) const isRefreshing = ref(false);
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null) const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true) const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000) const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value) const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value);
const refreshQueue = async () => { const refreshQueue = async () => {
isRefreshing.value = true isRefreshing.value = true;
try { try {
await stateStore.loadQueue() await stateStore.loadQueue();
} catch { } catch {
toast.error('Failed to refresh queue') toast.error('Failed to refresh queue');
} finally { } finally {
isRefreshing.value = false isRefreshing.value = false;
} }
} };
const startAutoRefresh = () => { const startAutoRefresh = () => {
if (autoRefreshInterval.value) { if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value) clearInterval(autoRefreshInterval.value);
} }
if (!autoRefreshEnabled.value || socket.isConnected) { if (!autoRefreshEnabled.value || socket.isConnected) {
return return;
} }
autoRefreshInterval.value = setInterval(async () => { autoRefreshInterval.value = setInterval(async () => {
if (!socket.isConnected && autoRefreshEnabled.value) { if (!socket.isConnected && autoRefreshEnabled.value) {
await refreshQueue() await refreshQueue();
} }
}, autoRefreshDelay.value) }, autoRefreshDelay.value);
} };
const stopAutoRefresh = () => { const stopAutoRefresh = () => {
if (autoRefreshInterval.value) { if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value) clearInterval(autoRefreshInterval.value);
autoRefreshInterval.value = null autoRefreshInterval.value = null;
} }
} };
watch(() => socket.isConnected, (connected) => { watch(
if (connected) { () => socket.isConnected,
stopAutoRefresh() (connected) => {
} else if (autoRefreshEnabled.value) { if (connected) {
startAutoRefresh() stopAutoRefresh();
} } else if (autoRefreshEnabled.value) {
}) startAutoRefresh();
}
},
);
watch(autoRefreshEnabled, (enabled) => { watch(autoRefreshEnabled, (enabled) => {
if (enabled && !socket.isConnected) { if (enabled && !socket.isConnected) {
startAutoRefresh() startAutoRefresh();
} else { } else {
stopAutoRefresh() stopAutoRefresh();
} }
}) });
onMounted(() => { onMounted(() => {
if (!socket.isConnected && autoRefreshEnabled.value) { if (!socket.isConnected && autoRefreshEnabled.value) {
startAutoRefresh() startAutoRefresh();
} }
}) });
onBeforeUnmount(() => stopAutoRefresh()) onBeforeUnmount(() => stopAutoRefresh());
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
if (value) { if (value) {
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id) selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id);
} else { } else {
selectedElms.value = [] selectedElms.value = [];
} }
}) });
const filteredItems = computed<StoreItem[]>(() => { const filteredItems = computed<StoreItem[]>(() => {
const q = props.query?.toLowerCase() const q = props.query?.toLowerCase();
if (!q) { if (!q) {
return Object.values(stateStore.queue) return Object.values(stateStore.queue);
} }
return Object.values(stateStore.queue).filter((i: StoreItem) => deepIncludes(i, q, new WeakSet())) return Object.values(stateStore.queue).filter((i: StoreItem) =>
}) deepIncludes(i, q, new WeakSet()),
);
});
const hasSelected = computed(() => 0 < selectedElms.value.length) const hasSelected = computed(() => 0 < selectedElms.value.length);
const hasManualStart = computed(() => { const hasManualStart = computed(() => {
if (0 > stateStore.count('queue')) { if (0 > stateStore.count('queue')) {
return false return false;
} }
for (const key in stateStore.queue) { for (const key in stateStore.queue) {
const item = stateStore.queue[key] as StoreItem const item = stateStore.queue[key] as StoreItem;
if (!item.status && false === item.auto_start) { if (!item.status && false === item.auto_start) {
return true return true;
} }
} }
return false return false;
}) });
const hasPausable = computed(() => { const hasPausable = computed(() => {
if (0 > stateStore.count('queue')) { if (0 > stateStore.count('queue')) {
return false return false;
} }
for (const key in stateStore.queue) { for (const key in stateStore.queue) {
const item = stateStore.queue[key] as StoreItem const item = stateStore.queue[key] as StoreItem;
if (!item.status && true === item.auto_start) { if (!item.status && true === item.auto_start) {
return true return true;
} }
} }
return false return false;
}) });
const setIcon = (item: StoreItem): string => { const setIcon = (item: StoreItem): string => {
if (!item.auto_start) { if (!item.auto_start) {
return 'fa-hourglass-half' return 'fa-hourglass-half';
} }
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'fa-globe fa-spin' return 'fa-globe fa-spin';
} }
if ('downloading' === item.status) { if ('downloading' === item.status) {
return 'fa-download' return 'fa-download';
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'fa-cog fa-spin' return 'fa-cog fa-spin';
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'fa-pause-circle' return 'fa-pause-circle';
} }
if (!item.status) { if (!item.status) {
return 'fa-question' return 'fa-question';
} }
return 'fa-spinner fa-spin' return 'fa-spinner fa-spin';
} };
const setStatus = (item: StoreItem): string => { const setStatus = (item: StoreItem): string => {
if (!item.auto_start) { if (!item.auto_start) {
return 'Pending' return 'Pending';
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused' return 'Paused';
} }
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'Streaming' return 'Streaming';
} }
if ('started' === item.status) { if ('started' === item.status) {
return 'Starting' return 'Starting';
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..' return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..';
} }
if (!item.status) { if (!item.status) {
return 'Unknown...' return 'Unknown...';
} }
return ucFirst(item.status) return ucFirst(item.status);
} };
const setIconColor = (item: StoreItem): string => { const setIconColor = (item: StoreItem): string => {
if (['downloading', 'started'].includes(item.status as string)) { if (['downloading', 'started'].includes(item.status as string)) {
return 'has-text-success' return 'has-text-success';
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'has-text-info' return 'has-text-info';
} }
if (!item.auto_start || (null === item.status && true === config.paused)) { if (!item.auto_start || (null === item.status && true === config.paused)) {
return 'has-text-warning' return 'has-text-warning';
} }
return '' return '';
} };
const ETAPipe = (value: number | null): string => { const ETAPipe = (value: number | null): string => {
if (null === value || 0 === value) { if (null === value || 0 === value) {
return 'Live' return 'Live';
} }
if (value < 60) { if (value < 60) {
return `${Math.round(value)}s` return `${Math.round(value)}s`;
} }
if (value < 3600) { if (value < 3600) {
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s` return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
} }
const hours = Math.floor(value / 3600) const hours = Math.floor(value / 3600);
const minutes = value % 3600 const minutes = value % 3600;
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s` return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
} };
const speedPipe = (value: number | null): string => { const speedPipe = (value: number | null): string => {
if (null === value || 0 === value) { if (null === value || 0 === value) {
return '0KB/s' return '0KB/s';
} }
const k = 1024 const k = 1024;
const dm = 2 const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'] const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(value) / Math.log(k)) const i = Math.floor(Math.log(value) / Math.log(k));
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
} };
const percentPipe = (value: number | null): string => { const percentPipe = (value: number | null): string => {
if (null === value || 0 === value) { if (null === value || 0 === value) {
return '00.00' return '00.00';
} }
return parseFloat(String(value)).toFixed(2) return parseFloat(String(value)).toFixed(2);
} };
const updateProgress = (item: StoreItem): string => { const updateProgress = (item: StoreItem): string => {
let string = '' let string = '';
if (!item.auto_start) { if (!item.auto_start) {
return 'Manual start' return 'Manual start';
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Global Pause' return 'Global Pause';
} }
if ('started' === item.status) { if ('started' === item.status) {
return 'Starting' return 'Starting';
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
if (item.postprocessor) { if (item.postprocessor) {
return `<i class="fa fa-cog fa-spin"></i> PP: ${item.postprocessor}` return `<i class="fa fa-cog fa-spin"></i> PP: ${item.postprocessor}`;
} }
return 'Post-processors are running.' return 'Post-processors are running.';
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing' return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing';
} }
if (null != item.status) { if (null != item.status) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live' string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
} }
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..' string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
if (null != item.status && item.eta) { if (null != item.status && item.eta) {
string += ' - ' + ETAPipe(item.eta) string += ' - ' + ETAPipe(item.eta);
} }
return string return string;
} };
const confirmCancel = async (item: StoreItem) => { const confirmCancel = async (item: StoreItem) => {
if (true !== (await box.confirm(`Cancel '${item.title}'?`))) { if (true !== (await box.confirm(`Cancel '${item.title}'?`))) {
return false return false;
} }
cancelItems(item._id) cancelItems(item._id);
return true return true;
} };
const cancelSelected = async () => { const cancelSelected = async () => {
if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) { if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) {
return false return false;
} }
cancelItems(selectedElms.value) cancelItems(selectedElms.value);
selectedElms.value = [] selectedElms.value = [];
masterSelectAll.value = false masterSelectAll.value = false;
return true return true;
} };
const cancelItems = (item: string | string[]) => { const cancelItems = (item: string | string[]) => {
const items: string[] = [] const items: string[] = [];
if ('object' === typeof item) { if ('object' === typeof item) {
for (const key in item) { for (const key in item) {
items.push((item as any)[key]) items.push((item as any)[key]);
} }
} else { } else {
items.push(item) items.push(item);
} }
if (0 > items.length) { if (0 > items.length) {
return return;
} }
stateStore.cancelItems(items) stateStore.cancelItems(items);
} };
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id]) const startItem = async (item: StoreItem) => await stateStore.startItems([item._id]);
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id]) const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id]);
const startItems = async () => { const startItems = async () => {
if (1 > selectedElms.value.length) { if (1 > selectedElms.value.length) {
return return;
} }
const filtered: string[] = [] const filtered: string[] = [];
selectedElms.value.forEach(id => { selectedElms.value.forEach((id) => {
const item = stateStore.get('queue', id) as StoreItem const item = stateStore.get('queue', id) as StoreItem;
if (item && !item.auto_start && !item.status) { if (item && !item.auto_start && !item.status) {
filtered.push(id) filtered.push(id);
} }
}) });
selectedElms.value = [] selectedElms.value = [];
if (1 > filtered.length) { if (1 > filtered.length) {
toast.error('No eligible items to start.') toast.error('No eligible items to start.');
return return;
} }
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) { if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
return false return false;
} }
await stateStore.startItems(filtered) await stateStore.startItems(filtered);
} };
const pauseSelected = async () => { const pauseSelected = async () => {
if (1 > selectedElms.value.length) { if (1 > selectedElms.value.length) {
return return;
} }
const filtered: string[] = [] const filtered: string[] = [];
selectedElms.value.forEach(id => { selectedElms.value.forEach((id) => {
const item = stateStore.get('queue', id) as StoreItem const item = stateStore.get('queue', id) as StoreItem;
if (item && item.auto_start && !item.status) { if (item && item.auto_start && !item.status) {
filtered.push(id) filtered.push(id);
} }
}) });
selectedElms.value = [] selectedElms.value = [];
if (1 > filtered.length) { if (1 > filtered.length) {
toast.error('No eligible items to pause.') toast.error('No eligible items to pause.');
return return;
} }
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) { if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
return false return false;
} }
await stateStore.pauseItems(filtered) await stateStore.pauseItems(filtered);
} };
const pImg = (e: Event) => { const pImg = (e: Event) => {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement;
if (target.naturalHeight > target.naturalWidth) { if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait') target.classList.add('image-portrait');
} }
} };
const onImgError = (e: Event) => { const onImgError = (e: Event) => {
const target = e.target as HTMLImageElement const target = e.target as HTMLImageElement;
if (target.src.endsWith('/images/placeholder.png')) { if (target.src.endsWith('/images/placeholder.png')) {
return return;
} }
target.src = '/images/placeholder.png' target.src = '/images/placeholder.png';
} };
watch(embed_url, v => { watch(embed_url, (v) => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return;
} }
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`) document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
}) });
</script> </script>

View file

@ -1,8 +1,13 @@
<template> <template>
<div class="modal" :class="{ 'is-active': isOpen }"> <div class="modal" :class="{ 'is-active': isOpen }">
<div class="modal-background" @click="emitter('close')" /> <div class="modal-background" @click="emitter('close')" />
<div class="modal-card" <div
:class="{ 'slide-from-right': direction === 'right', 'slide-from-left': direction === 'left' }"> class="modal-card"
:class="{
'slide-from-right': direction === 'right',
'slide-from-left': direction === 'left',
}"
>
<header class="modal-card-head is-rounded-less"> <header class="modal-card-head is-rounded-less">
<p class="modal-card-title">WebUI Settings</p> <p class="modal-card-title">WebUI Settings</p>
<button class="delete" @click="emitter('close')" aria-label="close" /> <button class="delete" @click="emitter('close')" aria-label="close" />
@ -12,7 +17,12 @@
<div class="field"> <div class="field">
<label class="label">Page View</label> <label class="label">Page View</label>
<div class="control"> <div class="control">
<input id="view_mode" type="checkbox" class="switch is-success" v-model="simpleMode"> <input
id="view_mode"
type="checkbox"
class="switch is-success"
v-model="simpleMode"
/>
<label for="view_mode" class="is-unselectable"> <label for="view_mode" class="is-unselectable">
{{ simpleMode ? 'Simple View' : 'Regular View' }} {{ simpleMode ? 'Simple View' : 'Regular View' }}
</label> </label>
@ -36,17 +46,17 @@
<label class="label">Color scheme</label> <label class="label">Color scheme</label>
<div class="control"> <div class="control">
<label for="auto" class="radio"> <label for="auto" class="radio">
<input id="auto" type="radio" v-model="selectedTheme" value="auto"> <input id="auto" type="radio" v-model="selectedTheme" value="auto" />
<span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span> <span class="icon"><i class="fa-solid fa-circle-half-stroke" /></span>
<span>Auto</span> <span>Auto</span>
</label> </label>
<label for="light" class="radio"> <label for="light" class="radio">
<input id="light" type="radio" v-model="selectedTheme" value="light"> <input id="light" type="radio" v-model="selectedTheme" value="light" />
<span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span> <span class="icon has-text-warning"><i class="fa-solid fa-sun" /></span>
<span>Light</span> <span>Light</span>
</label> </label>
<label for="dark" class="radio"> <label for="dark" class="radio">
<input id="dark" type="radio" v-model="selectedTheme" value="dark"> <input id="dark" type="radio" v-model="selectedTheme" value="dark" />
<span class="icon"><i class="fa-solid fa-moon" /></span> <span class="icon"><i class="fa-solid fa-moon" /></span>
<span>Dark</span> <span>Dark</span>
</label> </label>
@ -56,7 +66,7 @@
<div class="field"> <div class="field">
<label class="label">Show Background</label> <label class="label">Show Background</label>
<div class="control"> <div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable"> <input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable" />
<label for="random_bg" class="is-unselectable"> <label for="random_bg" class="is-unselectable">
{{ bg_enable ? 'Yes' : 'No' }} {{ bg_enable ? 'Yes' : 'No' }}
</label> </label>
@ -65,9 +75,16 @@
<div class="field" v-if="bg_enable"> <div class="field" v-if="bg_enable">
<div class="control"> <div class="control">
<button @click="$emit('reload_bg')" class="button is-link is-light is-fullwidth" :disabled="isLoading"> <button
<span class="icon"><i class="fa" @click="$emit('reload_bg')"
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span> class="button is-link is-light is-fullwidth"
:disabled="isLoading"
>
<span class="icon"
><i
class="fa"
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }"
/></span>
<span>Reload Background</span> <span>Reload Background</span>
</button> </button>
</div> </div>
@ -82,7 +99,14 @@
</a> </a>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input class="input" type="range" v-model="bg_opacity" min="0.50" max="1.00" step="0.05"> <input
class="input"
type="range"
v-model="bg_opacity"
min="0.50"
max="1.00"
step="0.05"
/>
</div> </div>
</div> </div>
</div> </div>
@ -101,7 +125,11 @@
<div class="control"> <div class="control">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select v-model="separator"> <select v-model="separator">
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value"> <option
v-for="(sep, index) in separators"
:key="`sep-${index}`"
:value="sep.value"
>
{{ sep.name }} ({{ sep.value }}) {{ sep.name }} ({{ sep.value }})
</option> </option>
</select> </select>
@ -112,7 +140,12 @@
<div class="field"> <div class="field">
<label class="label">Show Thumbnails</label> <label class="label">Show Thumbnails</label>
<div class="control"> <div class="control">
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail"> <input
id="show_thumbnail"
type="checkbox"
class="switch is-success"
v-model="show_thumbnail"
/>
<label for="show_thumbnail" class="is-unselectable"> <label for="show_thumbnail" class="is-unselectable">
{{ show_thumbnail ? 'Yes' : 'No' }} {{ show_thumbnail ? 'Yes' : 'No' }}
</label> </label>
@ -127,11 +160,11 @@
<label class="label">Aspect Ratio</label> <label class="label">Aspect Ratio</label>
<div class="control"> <div class="control">
<label for="ratio_16by9" class="radio"> <label for="ratio_16by9" class="radio">
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9"> <input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9" />
<span>&nbsp;16:9</span> <span>&nbsp;16:9</span>
</label> </label>
<label for="ratio_3by1" class="radio"> <label for="ratio_3by1" class="radio">
<input id="ratio_3by1" type="radio" v-model="thumbnail_ratio" value="is-3by1"> <input id="ratio_3by1" type="radio" v-model="thumbnail_ratio" value="is-3by1" />
<span>&nbsp;3:1</span> <span>&nbsp;3:1</span>
</label> </label>
</div> </div>
@ -143,7 +176,12 @@
<div class="field"> <div class="field">
<label class="label">Popover</label> <label class="label">Popover</label>
<div class="control"> <div class="control">
<input id="show_popover" type="checkbox" class="switch is-success" v-model="show_popover"> <input
id="show_popover"
type="checkbox"
class="switch is-success"
v-model="show_popover"
/>
<label for="show_popover" class="is-unselectable"> <label for="show_popover" class="is-unselectable">
{{ show_popover ? 'Yes' : 'No' }} {{ show_popover ? 'Yes' : 'No' }}
</label> </label>
@ -166,7 +204,12 @@
<div class="field"> <div class="field">
<label class="label">Auto-refresh queue when disconnected</label> <label class="label">Auto-refresh queue when disconnected</label>
<div class="control"> <div class="control">
<input id="queue_auto_refresh" type="checkbox" class="switch is-success" v-model="queue_auto_refresh"> <input
id="queue_auto_refresh"
type="checkbox"
class="switch is-success"
v-model="queue_auto_refresh"
/>
<label for="queue_auto_refresh" class="is-unselectable"> <label for="queue_auto_refresh" class="is-unselectable">
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }} {{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
</label> </label>
@ -186,7 +229,14 @@
</a> </a>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input class="input" type="range" v-model.number="queue_auto_refresh_delay" min="5000" max="60000" step="5000"> <input
class="input"
type="range"
v-model.number="queue_auto_refresh_delay"
min="5000"
max="60000"
step="5000"
/>
</div> </div>
</div> </div>
<p class="help"> <p class="help">
@ -207,7 +257,12 @@
<div class="field"> <div class="field">
<label class="label">Show notifications</label> <label class="label">Show notifications</label>
<div class="control"> <div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts"> <input
id="allow_toasts"
type="checkbox"
class="switch is-success"
v-model="allow_toasts"
/>
<label for="allow_toasts" class="is-unselectable"> <label for="allow_toasts" class="is-unselectable">
{{ allow_toasts ? 'Yes' : 'No' }} {{ allow_toasts ? 'Yes' : 'No' }}
</label> </label>
@ -254,7 +309,12 @@
<div class="field" v-if="allow_toasts && toast_target === 'toast'"> <div class="field" v-if="allow_toasts && toast_target === 'toast'">
<label class="label">Dismiss notification on click</label> <label class="label">Dismiss notification on click</label>
<div class="control"> <div class="control">
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click"> <input
id="dismiss_on_click"
type="checkbox"
class="switch is-success"
v-model="toast_dismiss_on_click"
/>
<label for="dismiss_on_click" class="is-unselectable"> <label for="dismiss_on_click" class="is-unselectable">
{{ toast_dismiss_on_click ? 'Yes' : 'No' }} {{ toast_dismiss_on_click ? 'Yes' : 'No' }}
</label> </label>
@ -267,83 +327,91 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css';
import { watch, onMounted, onBeforeUnmount, ref } from 'vue' import { watch, onMounted, onBeforeUnmount, ref } from 'vue';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { POSITION } from 'vue-toastification' import { POSITION } from 'vue-toastification';
import { useConfigStore } from '~/stores/ConfigStore' import { useConfigStore } from '~/stores/ConfigStore';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import type { notificationTarget } from '~/composables/useNotification' import type { notificationTarget } from '~/composables/useNotification';
const props = withDefaults(defineProps<{ const props = withDefaults(
isOpen?: boolean defineProps<{
direction?: 'left' | 'right' isOpen?: boolean;
isLoading?: boolean direction?: 'left' | 'right';
}>(), { isLoading?: boolean;
isOpen: false, }>(),
direction: 'right', {
isLoading: false isOpen: false,
}) direction: 'right',
isLoading: false,
},
);
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>() const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>();
const config = useConfigStore() const config = useConfigStore();
const notification = useNotification() const notification = useNotification();
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto') const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto');
const allow_toasts = useStorage<boolean>('allow_toasts', true) const allow_toasts = useStorage<boolean>('allow_toasts', true);
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT) const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true) const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true);
const toast_target = useStorage<notificationTarget>('toast_target', 'toast') const toast_target = useStorage<notificationTarget>('toast_target', 'toast');
const show_thumbnail = useStorage<boolean>('show_thumbnail', true) const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
const show_popover = useStorage<boolean>('show_popover', true) const show_popover = useStorage<boolean>('show_popover', true);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1') const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',') const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',');
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false) const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true) const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true);
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000) const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000);
const isSecureContext = ref<boolean>(false) const isSecureContext = ref<boolean>(false);
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if ('Escape' === e.key && props.isOpen) { if ('Escape' === e.key && props.isOpen) {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
emitter('close') emitter('close');
} }
} };
onMounted(async () => { onMounted(async () => {
isSecureContext.value = window.isSecureContext isSecureContext.value = window.isSecureContext;
await nextTick() await nextTick();
if ('browser' === toast_target.value && !isSecureContext.value) { if ('browser' === toast_target.value && !isSecureContext.value) {
toast_target.value = 'toast' toast_target.value = 'toast';
} }
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown);
}) });
onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown)) onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown));
const onNotificationTargetChange = async (): Promise<void> => { const onNotificationTargetChange = async (): Promise<void> => {
if ('browser' === toast_target.value) { if ('browser' === toast_target.value) {
const permission = await notification.requestBrowserPermission() const permission = await notification.requestBrowserPermission();
if ('granted' !== permission) { if ('granted' !== permission) {
toast_target.value = 'toast' toast_target.value = 'toast';
notification.warning('Browser notification permission denied. Reverting to toast notifications.') notification.warning(
'Browser notification permission denied. Reverting to toast notifications.',
);
} }
} }
} };
watch(() => props.isOpen, isOpen => { watch(
if (isOpen) { () => props.isOpen,
document.body.classList.add('settings-panel-open') (isOpen) => {
} else { if (isOpen) {
document.body.classList.remove('settings-panel-open') document.body.classList.add('settings-panel-open');
} } else {
}) document.body.classList.remove('settings-panel-open');
}
},
);
</script> </script>
<style scoped> <style scoped>
@ -386,7 +454,6 @@ watch(() => props.isOpen, isOpen => {
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.modal-card.slide-from-right, .modal-card.slide-from-right,
.modal-card.slide-from-left { .modal-card.slide-from-left {
width: 100vw; width: 100vw;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
<style scoped> <style scoped>
code { code {
color: var(--bulma-code) !important color: var(--bulma-code) !important;
} }
</style> </style>
@ -16,14 +16,19 @@ code {
</span> </span>
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input id="url" v-model="url" type="url" class="input" :class="{ 'is-danger': urlError }" <input
placeholder="https://..." required /> id="url"
v-model="url"
type="url"
class="input"
:class="{ 'is-danger': urlError }"
placeholder="https://..."
required
/>
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div> </div>
<p v-if="urlError" class="help is-danger">{{ urlError }}</p> <p v-if="urlError" class="help is-danger">{{ urlError }}</p>
<p v-else class="help is-bold"> <p v-else class="help is-bold">Enter the URL of the resource you want to inspect.</p>
Enter the URL of the resource you want to inspect.
</p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="preset"> <label class="label" for="preset">
@ -35,13 +40,24 @@ code {
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="preset"> <select id="preset" class="is-fullwidth" v-model="preset">
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0"> <optgroup
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name"> label="Custom presets"
v-if="config?.presets.filter((p) => !p?.default).length > 0"
>
<option
v-for="cPreset in filter_presets(false)"
:key="cPreset.name"
:value="cPreset.name"
>
{{ cPreset.name }} {{ cPreset.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="Default presets"> <optgroup label="Default presets">
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name"> <option
v-for="dPreset in filter_presets(true)"
:key="dPreset.name"
:value="dPreset.name"
>
{{ dPreset.name }} {{ dPreset.name }}
</option> </option>
</optgroup> </optgroup>
@ -50,8 +66,8 @@ code {
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
</div> </div>
<p class="help is-bold"> <p class="help is-bold">
Select a preset to apply its settings during inspection. In real scenario, the preset will be based on what is Select a preset to apply its settings during inspection. In real scenario, the preset will
selected when creating the task. be based on what is selected when creating the task.
</p> </p>
</div> </div>
<div class="field"> <div class="field">
@ -62,12 +78,18 @@ code {
</span> </span>
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input id="handler" v-model="handler" type="text" class="input" placeholder="Handler class name" /> <input
id="handler"
v-model="handler"
type="text"
class="input"
placeholder="Handler class name"
/>
<span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span>
</div> </div>
<p class="help is-bold"> <p class="help is-bold">
In real scenario, the system auto-detects the appropriate handler based on the URL. This field is for testing In real scenario, the system auto-detects the appropriate handler based on the URL. This
purposes only. field is for testing purposes only.
</p> </p>
</div> </div>
<div class="field is-grouped is-grouped-right"> <div class="field is-grouped is-grouped-right">
@ -96,8 +118,12 @@ code {
</Message> </Message>
<div v-if="response" class="mt-4"> <div v-if="response" class="mt-4">
<Message v-if="response.error" class="is-danger" title="Error" <Message
icon="fas fa-exclamation-triangle"> v-if="response.error"
class="is-danger"
title="Error"
icon="fas fa-exclamation-triangle"
>
<p>{{ response.error }}</p> <p>{{ response.error }}</p>
<p v-if="response.message">{{ response.message }}</p> <p v-if="response.message">{{ response.message }}</p>
</Message> </Message>
@ -110,76 +136,92 @@ code {
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue';
import { request } from '~/utils' import { request } from '~/utils';
import { useConfigStore } from '~/stores/ConfigStore' import { useConfigStore } from '~/stores/ConfigStore';
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect' import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect';
const props = defineProps<{ const props = defineProps<{
url?: string url?: string;
preset?: string preset?: string;
handler?: string handler?: string;
}>() }>();
const config = useConfigStore() const config = useConfigStore();
const url = ref<string>(props.url ?? '') const url = ref<string>(props.url ?? '');
const preset = ref<string>(props.preset || config.app.default_preset || '') const preset = ref<string>(props.preset || config.app.default_preset || '');
const handler = ref<string>(props.handler ?? '') const handler = ref<string>(props.handler ?? '');
const loading = ref<boolean>(false) const loading = ref<boolean>(false);
const response = ref<TaskInspectResponse | null>(null) const response = ref<TaskInspectResponse | null>(null);
const urlError = ref<string>('') const urlError = ref<string>('');
// Watch for prop changes and update fields // Watch for prop changes and update fields
watch(() => props.url, val => { if (val !== undefined) url.value = val }) watch(
watch(() => props.preset, val => { if (val !== undefined) preset.value = val }) () => props.url,
watch(() => props.handler, val => { if (val !== undefined) handler.value = val }) (val) => {
if (val !== undefined) url.value = val;
},
);
watch(
() => props.preset,
(val) => {
if (val !== undefined) preset.value = val;
},
);
watch(
() => props.handler,
(val) => {
if (val !== undefined) handler.value = val;
},
);
const validateUrl = (val: string): boolean => { const validateUrl = (val: string): boolean => {
try { try {
const u = new URL(val) const u = new URL(val);
return u.protocol === 'http:' || u.protocol === 'https:' return u.protocol === 'http:' || u.protocol === 'https:';
} catch { } catch {
return false return false;
} }
} };
async function onSubmit() { async function onSubmit() {
urlError.value = '' urlError.value = '';
response.value = null response.value = null;
if (!url.value || !validateUrl(url.value)) { if (!url.value || !validateUrl(url.value)) {
urlError.value = 'Please enter a valid URL.' urlError.value = 'Please enter a valid URL.';
return return;
} }
loading.value = true loading.value = true;
const payload: TaskInspectRequest = { const payload: TaskInspectRequest = {
url: url.value.trim(), url: url.value.trim(),
preset: preset.value.trim() || undefined, preset: preset.value.trim() || undefined,
handler: handler.value.trim() || undefined, handler: handler.value.trim() || undefined,
} };
try { try {
const res = await request('/api/tasks/inspect', { const res = await request('/api/tasks/inspect', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
response.value = await res.json() response.value = await res.json();
} catch (err: any) { } catch (err: any) {
response.value = { error: err?.message || 'Unknown error' } response.value = { error: err?.message || 'Unknown error' };
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
const onReset = () => { const onReset = () => {
url.value = props.url || '' url.value = props.url || '';
preset.value = props.preset || config.app.default_preset || '' preset.value = props.preset || config.app.default_preset || '';
handler.value = props.handler || '' handler.value = props.handler || '';
response.value = null response.value = null;
urlError.value = '' urlError.value = '';
} };
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) =>
config.presets.filter((item) => item.default === flag);
</script> </script>

View file

@ -1,32 +1,53 @@
<template> <template>
<div class="file-dropzone is-relative" :class="{ 'is-dragging': isDragging }" @drop.prevent="handleDrop" <div
@dragover.prevent="handleDragOver" @dragenter.prevent="handleDragEnter" @dragleave.prevent="handleDragLeave" class="file-dropzone is-relative"
@click="handleClick"> :class="{ 'is-dragging': isDragging }"
@drop.prevent="handleDrop"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@click="handleClick"
>
<textarea
ref="textareaRef"
class="control textarea is-fullwidth"
:class="{ 'is-focused': isDragging }"
:value="model"
@input="handleInput"
:disabled="disabled"
:placeholder="placeholder"
:id="id"
/>
<textarea ref="textareaRef" class="control textarea is-fullwidth" :class="{ 'is-focused': isDragging }" <div
:value="model" @input="handleInput" :disabled="disabled" :placeholder="placeholder" :id="id" /> v-if="isDragging"
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center"
<div v-if="isDragging" >
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center">
<div class="has-text-centered has-text-dark"> <div class="has-text-centered has-text-dark">
<span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span> <span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span>
<p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p> <p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p>
</div> </div>
</div> </div>
<input ref="fileInputRef" type="file" :accept="accept" @change="handleFileSelect" style="display: none" /> <input
ref="fileInputRef"
type="file"
:accept="accept"
@change="handleFileSelect"
style="display: none"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue';
interface Props { interface Props {
disabled?: boolean disabled?: boolean;
placeholder?: string placeholder?: string;
id?: string id?: string;
accept?: string accept?: string;
maxSize?: number maxSize?: number;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -34,202 +55,202 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: '', placeholder: '',
id: '', id: '',
accept: '', accept: '',
maxSize: 2 * 1024 * 1024 maxSize: 2 * 1024 * 1024,
}) });
const model = defineModel<string>({ default: '' }) const model = defineModel<string>({ default: '' });
const emit = defineEmits<{ error: [message: string] }>() const emit = defineEmits<{ error: [message: string] }>();
const textareaRef = ref<HTMLTextAreaElement | null>(null) const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null);
const isDragging = ref<boolean>(false) const isDragging = ref<boolean>(false);
const dragCounter = ref<number>(0) const dragCounter = ref<number>(0);
const handleInput = (event: Event): void => { const handleInput = (event: Event): void => {
const target = event.target as HTMLTextAreaElement const target = event.target as HTMLTextAreaElement;
model.value = target.value model.value = target.value;
} };
const handleDragEnter = (event: DragEvent): void => { const handleDragEnter = (event: DragEvent): void => {
if (props.disabled) { if (props.disabled) {
return return;
} }
dragCounter.value++ dragCounter.value++;
if (event.dataTransfer?.types.includes('Files')) { if (event.dataTransfer?.types.includes('Files')) {
isDragging.value = true isDragging.value = true;
} }
} };
const handleDragLeave = (_event: DragEvent): void => { const handleDragLeave = (_event: DragEvent): void => {
if (props.disabled) { if (props.disabled) {
return return;
} }
dragCounter.value-- dragCounter.value--;
if (0 === dragCounter.value) { if (0 === dragCounter.value) {
isDragging.value = false isDragging.value = false;
} }
} };
const handleDragOver = (event: DragEvent): void => { const handleDragOver = (event: DragEvent): void => {
if (props.disabled) { if (props.disabled) {
return return;
} }
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy' event.dataTransfer.dropEffect = 'copy';
} }
} };
const handleDrop = async (event: DragEvent): Promise<void> => { const handleDrop = async (event: DragEvent): Promise<void> => {
if (props.disabled) { if (props.disabled) {
return return;
} }
isDragging.value = false isDragging.value = false;
dragCounter.value = 0 dragCounter.value = 0;
const files = event.dataTransfer?.files const files = event.dataTransfer?.files;
if (!files || 0 === files.length) { if (!files || 0 === files.length) {
return return;
} }
const file = files[0] const file = files[0];
if (file) { if (file) {
await processFile(file) await processFile(file);
} }
} };
const handleClick = (event: MouseEvent): void => { const handleClick = (event: MouseEvent): void => {
if (props.disabled) { if (props.disabled) {
return return;
} }
const selection = window.getSelection() const selection = window.getSelection();
if (selection && selection.toString().length > 0) { if (selection && selection.toString().length > 0) {
return return;
} }
if (event && event.target === textareaRef.value) { if (event && event.target === textareaRef.value) {
return return;
} }
} };
const handleFileSelect = async (event: Event): Promise<void> => { const handleFileSelect = async (event: Event): Promise<void> => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement;
const files = target.files const files = target.files;
if (!files || 0 === files.length) { if (!files || 0 === files.length) {
return return;
} }
const file = files[0] const file = files[0];
if (file) { if (file) {
await processFile(file) await processFile(file);
} }
if (fileInputRef.value) { if (fileInputRef.value) {
fileInputRef.value.value = '' fileInputRef.value.value = '';
} }
} };
const processFile = async (file: File): Promise<void> => { const processFile = async (file: File): Promise<void> => {
try { try {
if (file.size > props.maxSize) { if (file.size > props.maxSize) {
const sizeMB = (file.size / 1024 / 1024).toFixed(2) const sizeMB = (file.size / 1024 / 1024).toFixed(2);
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2) const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2);
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.` const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`;
emit('error', errorMsg) emit('error', errorMsg);
console.error(errorMsg) console.error(errorMsg);
return return;
} }
const isBinary = await checkIfBinary(file) const isBinary = await checkIfBinary(file);
if (isBinary) { if (isBinary) {
const errorMsg = 'File appears to be binary. Please provide a text file.' const errorMsg = 'File appears to be binary. Please provide a text file.';
emit('error', errorMsg) emit('error', errorMsg);
console.error(errorMsg) console.error(errorMsg);
return return;
} }
const text = await readFileAsText(file) const text = await readFileAsText(file);
model.value = text model.value = text;
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to read file' const errorMsg = error instanceof Error ? error.message : 'Failed to read file';
emit('error', errorMsg) emit('error', errorMsg);
console.error('Failed to read file:', error) console.error('Failed to read file:', error);
} }
} };
const checkIfBinary = async (file: File): Promise<boolean> => { const checkIfBinary = async (file: File): Promise<boolean> => {
const chunkSize = 8192 const chunkSize = 8192;
const blob = file.slice(0, chunkSize) const blob = file.slice(0, chunkSize);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => { reader.onload = (e: ProgressEvent<FileReader>) => {
if (!e.target?.result) { if (!e.target?.result) {
resolve(false) resolve(false);
return return;
} }
const bytes = new Uint8Array(e.target.result as ArrayBuffer) const bytes = new Uint8Array(e.target.result as ArrayBuffer);
let nullBytes = 0 let nullBytes = 0;
let nonPrintable = 0 let nonPrintable = 0;
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i] const byte = bytes[i];
if (undefined === byte) { if (undefined === byte) {
continue continue;
} }
if (0 === byte) { if (0 === byte) {
nullBytes++ nullBytes++;
} }
if (9 !== byte && 10 !== byte && 13 !== byte && (byte < 32 || byte > 126)) { if (9 !== byte && 10 !== byte && 13 !== byte && (byte < 32 || byte > 126)) {
nonPrintable++ nonPrintable++;
} }
} }
resolve(nullBytes > 0 || (nonPrintable / bytes.length) > 0.3) resolve(nullBytes > 0 || nonPrintable / bytes.length > 0.3);
} };
reader.onerror = () => reject(new Error('Failed to read file for binary check')) reader.onerror = () => reject(new Error('Failed to read file for binary check'));
reader.readAsArrayBuffer(blob) reader.readAsArrayBuffer(blob);
}) });
} };
const readFileAsText = (file: File): Promise<string> => { const readFileAsText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => { reader.onload = (e: ProgressEvent<FileReader>) => {
if (e.target?.result) { if (e.target?.result) {
resolve(e.target.result as string) resolve(e.target.result as string);
} else { } else {
reject(new Error('Failed to read file')) reject(new Error('Failed to read file'));
} }
} };
reader.onerror = () => reject(new Error('File reading error')) reader.onerror = () => reject(new Error('File reading error'));
reader.readAsText(file) reader.readAsText(file);
}) });
} };
const triggerFileSelect = (): void => { const triggerFileSelect = (): void => {
if (props.disabled) { if (props.disabled) {
return return;
} }
fileInputRef.value?.click() fileInputRef.value?.click();
} };
defineExpose({ triggerFileSelect }) defineExpose({ triggerFileSelect });
</script> </script>
<style> <style>

View file

@ -1,18 +1,43 @@
<template> <template>
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;"> <div
<div class="control" style="width:100%;"> class="dropdown"
<textarea :id="id" ref="textareaRef" v-model="localValue" @input="onInput" @focus="onFocus" @blur="hideList" :class="{ 'is-active': showList && filteredOptions.length }"
@keydown="onKeydown" @keyup="updateCaret" @click="updateCaret" @mouseup="updateCaret" style="width: 100%"
class="control is-fullwidth textarea" :placeholder="placeholder" autocomplete="off" >
style="width:100%; position:relative; z-index:2;" rows="4" :disabled="disabled" /> <div class="control" style="width: 100%">
<textarea
:id="id"
ref="textareaRef"
v-model="localValue"
@input="onInput"
@focus="onFocus"
@blur="hideList"
@keydown="onKeydown"
@keyup="updateCaret"
@click="updateCaret"
@mouseup="updateCaret"
class="control is-fullwidth textarea"
:placeholder="placeholder"
autocomplete="off"
style="width: 100%; position: relative; z-index: 2"
rows="4"
:disabled="disabled"
/>
</div> </div>
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;"> <div class="dropdown-menu" role="menu" style="width: 100%; z-index: 3">
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;"> <div class="dropdown-content" style="width: 100%; max-height: 11em; overflow-y: auto">
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="appendFlag(option.value)" <a
v-for="(option, idx) in filteredOptions"
:key="option.value"
@mousedown.prevent="appendFlag(option.value)"
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]" :class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
style="display:flex; justify-content:space-between;" ref="dropdownItems"> style="display: flex; justify-content: space-between"
ref="dropdownItems"
>
<span class="has-text-weight-bold">{{ option.value }}</span> <span class="has-text-weight-bold">{{ option.value }}</span>
<span class="has-text-grey-light is-text-overflow" style="margin-left:1em;">{{ option.description }}</span> <span class="has-text-grey-light is-text-overflow" style="margin-left: 1em">{{
option.description
}}</span>
</a> </a>
</div> </div>
</div> </div>
@ -20,193 +45,204 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue' import { ref, computed, watch, nextTick } from 'vue';
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete';
const props = defineProps<{ const props = defineProps<{
options: AutoCompleteOptions options: AutoCompleteOptions;
placeholder?: string placeholder?: string;
disabled?: boolean disabled?: boolean;
id?: string id?: string;
}>() }>();
const model = defineModel<string>() const model = defineModel<string>();
const localValue = ref(model.value || '') const localValue = ref(model.value || '');
const textareaRef = ref<HTMLTextAreaElement | null>(null) const textareaRef = ref<HTMLTextAreaElement | null>(null);
const caretIndex = ref(0) const caretIndex = ref(0);
const showList = ref(false) const showList = ref(false);
const highlightedIndex = ref(-1) const highlightedIndex = ref(-1);
const dropdownItems = ref<HTMLElement[]>([]) const dropdownItems = ref<HTMLElement[]>([]);
watch(model, val => localValue.value = val || '') watch(model, (val) => (localValue.value = val || ''));
watch(localValue, val => model.value = val) watch(localValue, (val) => (model.value = val));
// Compute token at caret position // Compute token at caret position
const getCurrentToken = (value: string, caret: number) => { const getCurrentToken = (value: string, caret: number) => {
const left = value.slice(0, caret) const left = value.slice(0, caret);
const right = value.slice(caret) const right = value.slice(caret);
const leftMatch = left.match(/(\S+)$/) const leftMatch = left.match(/(\S+)$/);
const rightMatch = right.match(/^(\S+)/) const rightMatch = right.match(/^(\S+)/);
const leftToken: string = (leftMatch?.[1] ?? '') as string const leftToken: string = (leftMatch?.[1] ?? '') as string;
const rightToken: string = (rightMatch?.[1] ?? '') as string const rightToken: string = (rightMatch?.[1] ?? '') as string;
const token = leftToken + rightToken const token = leftToken + rightToken;
const start = leftMatch ? caret - leftToken.length : caret const start = leftMatch ? caret - leftToken.length : caret;
const end = rightMatch ? caret + rightToken.length : caret const end = rightMatch ? caret + rightToken.length : caret;
return { token, start, end } return { token, start, end };
} };
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const { token } = getCurrentToken(localValue.value, caretIndex.value) const { token } = getCurrentToken(localValue.value, caretIndex.value);
// Hide suggestions once '=' is present in the current token // Hide suggestions once '=' is present in the current token
if (!token || token.includes('=')) { if (!token || token.includes('=')) {
return [] return [];
} }
// Only suggest when typing a long flag starting with `--` // Only suggest when typing a long flag starting with `--`
if (!token.startsWith('--')) { if (!token.startsWith('--')) {
return [] return [];
} }
// Hide suggestions if token exactly matches an option value // Hide suggestions if token exactly matches an option value
if (props.options.some(opt => opt.value === token)) { if (props.options.some((opt) => opt.value === token)) {
return [] return [];
} }
const val = token.toLowerCase() const val = token.toLowerCase();
const startsWithFlag = [] const startsWithFlag = [];
const includesFlag = [] const includesFlag = [];
const includesDesc = [] const includesDesc = [];
for (const opt of props.options) { for (const opt of props.options) {
const flag = opt.value.toLowerCase() const flag = opt.value.toLowerCase();
const desc = opt.description.toLowerCase() const desc = opt.description.toLowerCase();
if (flag.startsWith(val)) { if (flag.startsWith(val)) {
startsWithFlag.push(opt) startsWithFlag.push(opt);
} else if (flag.includes(val)) { } else if (flag.includes(val)) {
includesFlag.push(opt) includesFlag.push(opt);
} else if (desc.includes(val)) { } else if (desc.includes(val)) {
includesDesc.push(opt) includesDesc.push(opt);
} }
} }
return [...startsWithFlag, ...includesFlag, ...includesDesc] return [...startsWithFlag, ...includesFlag, ...includesDesc];
}) });
const appendFlag = (flag: string) => { const appendFlag = (flag: string) => {
const value = localValue.value const value = localValue.value;
const caret = caretIndex.value const caret = caretIndex.value;
const { token, start, end } = getCurrentToken(value, caret) const { token, start, end } = getCurrentToken(value, caret);
if (token) { if (token) {
// Replace only the flag part of the token, preserving any '=value' suffix in the same token // Replace only the flag part of the token, preserving any '=value' suffix in the same token
const eqPos = token.indexOf('=') const eqPos = token.indexOf('=');
const after = eqPos !== -1 ? token.slice(eqPos) : '' const after = eqPos !== -1 ? token.slice(eqPos) : '';
localValue.value = value.slice(0, start) + flag + after + value.slice(end) localValue.value = value.slice(0, start) + flag + after + value.slice(end);
nextTick(() => { nextTick(() => {
const pos = start + flag.length + after.length const pos = start + flag.length + after.length;
if (textareaRef.value) { if (textareaRef.value) {
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
caretIndex.value = pos caretIndex.value = pos;
} }
}) });
} else { } else {
// No token at caret: append at caret position // No token at caret: append at caret position
const needsSpace = caret > 0 && value[caret - 1] !== ' ' const needsSpace = caret > 0 && value[caret - 1] !== ' ';
localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret) localValue.value = value.slice(0, caret) + (needsSpace ? ' ' : '') + flag + value.slice(caret);
nextTick(() => { nextTick(() => {
const pos = caret + (needsSpace ? 1 : 0) + flag.length const pos = caret + (needsSpace ? 1 : 0) + flag.length;
if (textareaRef.value) { if (textareaRef.value) {
textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos textareaRef.value.selectionStart = textareaRef.value.selectionEnd = pos;
caretIndex.value = pos caretIndex.value = pos;
} }
}) });
} }
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
} };
const updateCaret = () => { const updateCaret = () => {
caretIndex.value = textareaRef.value ? (textareaRef.value.selectionStart ?? localValue.value.length) : localValue.value.length caretIndex.value = textareaRef.value
} ? (textareaRef.value.selectionStart ?? localValue.value.length)
: localValue.value.length;
};
const onFocus = () => { const onFocus = () => {
updateCaret() updateCaret();
showList.value = true showList.value = true;
} };
const onInput = () => { const onInput = () => {
updateCaret() updateCaret();
const { token } = getCurrentToken(localValue.value, caretIndex.value) const { token } = getCurrentToken(localValue.value, caretIndex.value);
const hasEqual = token.includes('=') const hasEqual = token.includes('=');
const isFlagTrigger = token.startsWith('--') && !hasEqual const isFlagTrigger = token.startsWith('--') && !hasEqual;
showList.value = isFlagTrigger && filteredOptions.value.length > 0 showList.value = isFlagTrigger && filteredOptions.value.length > 0;
highlightedIndex.value = showList.value ? 0 : -1 highlightedIndex.value = showList.value ? 0 : -1;
} };
// Reset scroll position when filtered options change // Reset scroll position when filtered options change
watch(filteredOptions, () => { watch(filteredOptions, () => {
highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1 highlightedIndex.value = filteredOptions.value.length > 0 && showList.value ? 0 : -1;
nextTick(() => { nextTick(() => {
const dropdown = document.querySelector('.dropdown-content') const dropdown = document.querySelector('.dropdown-content');
if (dropdown) { if (dropdown) {
dropdown.scrollTop = 0 dropdown.scrollTop = 0;
} }
const items = document.querySelectorAll('.dropdown-item') const items = document.querySelectorAll('.dropdown-item');
dropdownItems.value = Array.from(items) as HTMLElement[] dropdownItems.value = Array.from(items) as HTMLElement[];
}) });
}) });
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100) const hideList = () =>
setTimeout(() => {
showList.value = false;
highlightedIndex.value = -1;
}, 100);
const onKeydown = (e: KeyboardEvent) => { const onKeydown = (e: KeyboardEvent) => {
// Track caret and allow ESC to immediately close suggestions and restore normal keys // Track caret and allow ESC to immediately close suggestions and restore normal keys
updateCaret() updateCaret();
if (e.key === 'Escape') { if (e.key === 'Escape') {
showList.value = false showList.value = false;
highlightedIndex.value = -1 highlightedIndex.value = -1;
return return;
} }
if (!showList.value || !filteredOptions.value.length) { if (!showList.value || !filteredOptions.value.length) {
return return;
} }
const pageSize = 5 const pageSize = 5;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1) highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1);
nextTick(() => { nextTick(() => {
const el = dropdownItems.value[highlightedIndex.value] const el = dropdownItems.value[highlightedIndex.value];
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' });
}) });
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0) highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
nextTick(() => { nextTick(() => {
const el = dropdownItems.value[highlightedIndex.value] const el = dropdownItems.value[highlightedIndex.value];
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' });
}) });
} else if (e.key === 'PageDown') { } else if (e.key === 'PageDown') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1) highlightedIndex.value = Math.min(
highlightedIndex.value + pageSize,
filteredOptions.value.length - 1,
);
nextTick(() => { nextTick(() => {
const el = dropdownItems.value[highlightedIndex.value] const el = dropdownItems.value[highlightedIndex.value];
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' });
}) });
} else if (e.key === 'PageUp') { } else if (e.key === 'PageUp') {
e.preventDefault() e.preventDefault();
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0) highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0);
nextTick(() => { nextTick(() => {
const el = dropdownItems.value[highlightedIndex.value] const el = dropdownItems.value[highlightedIndex.value];
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' });
}) });
} else if (e.key === 'Enter' || e.key === 'Tab') { } else if (e.key === 'Enter' || e.key === 'Tab') {
const { token } = getCurrentToken(localValue.value, caretIndex.value) const { token } = getCurrentToken(localValue.value, caretIndex.value);
const hasEqual = token.includes('=') const hasEqual = token.includes('=');
const isFlagTrigger = token.startsWith('--') && !hasEqual const isFlagTrigger = token.startsWith('--') && !hasEqual;
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ? const selected =
filteredOptions.value[highlightedIndex.value] : undefined highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length
? filteredOptions.value[highlightedIndex.value]
: undefined;
// Only autocomplete if there's a partial word being typed // Only autocomplete if there's a partial word being typed
if (selected && isFlagTrigger) { if (selected && isFlagTrigger) {
e.preventDefault() e.preventDefault();
appendFlag(selected.value) appendFlag(selected.value);
} }
} }
// dropdownItems is updated via a single top-level watch(filteredOptions) // dropdownItems is updated via a single top-level watch(filteredOptions)
} };
</script> </script>

View file

@ -83,25 +83,51 @@
<template> <template>
<div v-if="infoLoaded"> <div v-if="infoLoaded">
<div style="position: relative;"> <div style="position: relative">
<video class="player" ref="video" :poster="uri(thumbnail)" playsinline controls crossorigin="anonymous" <video
preload="auto" autoplay> class="player"
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror" ref="video"
:type="source.type" /> :poster="uri(thumbnail)"
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label" playsinline
:srclang="track.lang" :src="track.file" :default="notFirefox && 0 === i" /> controls
crossorigin="anonymous"
preload="auto"
autoplay
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
@error="source.onerror"
:type="source.type"
/>
<track
v-for="(track, i) in tracks"
:key="track.file"
:kind="track.kind"
:label="track.label"
:srclang="track.lang"
:src="track.file"
:default="notFirefox && 0 === i"
/>
</video> </video>
<div class="is-flex is-justify-content-space-between"> <div class="is-flex is-justify-content-space-between">
<div> <div>
<span v-if="infoLoaded && !usingHls && hasVideoStream" class="is-hidden-mobile has-text-info is-pointer" <span
@click.prevent="forceSwitchToHls"> v-if="infoLoaded && !usingHls && hasVideoStream"
class="is-hidden-mobile has-text-info is-pointer"
@click.prevent="forceSwitchToHls"
>
<span class="icon"><i class="fa-solid fa-arrows-rotate" /></span> <span class="icon"><i class="fa-solid fa-arrows-rotate" /></span>
<span>Trouble playing? switch to HLS stream.</span> <span>Trouble playing? switch to HLS stream.</span>
</span> </span>
</div> </div>
<div> <div>
<span class="is-hidden-mobile has-text-grey-lighter is-pointer" @click="showHelp = !showHelp"> <span
class="is-hidden-mobile has-text-grey-lighter is-pointer"
@click="showHelp = !showHelp"
>
<span class="icon"><i class="fa-solid fa-question" /></span> <span class="icon"><i class="fa-solid fa-question" /></span>
<span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span> <span>Show keyboard shortcuts with <kbd>?</kbd> or <kbd>/</kbd></span>
</span> </span>
@ -109,7 +135,7 @@
</div> </div>
<div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false"> <div v-if="showHelp" class="keyboard-help" @click.self="showHelp = false">
<h2 style="margin-bottom: 1.5rem;">Keyboard Shortcuts</h2> <h2 style="margin-bottom: 1.5rem">Keyboard Shortcuts</h2>
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div class="shortcut-section"> <div class="shortcut-section">
@ -238,11 +264,13 @@
</div> </div>
</div> </div>
<div class="help-close-hint">Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close</div> <div class="help-close-hint">
Click outside or press <kbd>?</kbd> or <kbd>/</kbd> to close
</div>
</div> </div>
</div> </div>
</div> </div>
<div style="text-align: center;" v-else> <div style="text-align: center" v-else>
<div class="icon"> <div class="icon">
<i class="fa-solid fa-spinner fa-spin fa-4x" /> <i class="fa-solid fa-spinner fa-spin fa-4x" />
</div> </div>
@ -250,311 +278,325 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { watch } from 'vue' import { watch } from 'vue';
import Hls from 'hls.js' import Hls from 'hls.js';
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils';
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts' import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store';
import type { file_info, video_source_element, video_track_element } from '~/types/video' import type { file_info, video_source_element, video_track_element } from '~/types/video';
const config = useConfigStore() const config = useConfigStore();
const props = defineProps<{ item: StoreItem }>() const props = defineProps<{ item: StoreItem }>();
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'closeModel'): void, (e: 'closeModel'): void;
(e: 'error', message: string): void, (e: 'error', message: string): void;
}>() }>();
const video = useTemplateRef<HTMLVideoElement>('video') const video = useTemplateRef<HTMLVideoElement>('video');
const tracks = ref<Array<video_track_element>>([]) const tracks = ref<Array<video_track_element>>([]);
const sources = ref<Array<video_source_element>>([]) const sources = ref<Array<video_source_element>>([]);
const thumbnail = ref('/images/placeholder.png') const thumbnail = ref('/images/placeholder.png');
const artist = ref('') const artist = ref('');
const title = ref('') const title = ref('');
const isAudio = ref(false) const isAudio = ref(false);
const hasVideoStream = ref(false) const hasVideoStream = ref(false);
const videoWidth = ref<number | undefined>(undefined) const videoWidth = ref<number | undefined>(undefined);
const videoHeight = ref<number | undefined>(undefined) const videoHeight = ref<number | undefined>(undefined);
const volume = useStorage('player_volume', 1) const volume = useStorage('player_volume', 1);
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox') const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox');
const infoLoaded = ref(false) const infoLoaded = ref(false);
const destroyed = ref(false) const destroyed = ref(false);
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent) const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
const havePoster = ref(false) const havePoster = ref(false);
const showHelp = ref(false) const showHelp = ref(false);
const usingHls = ref(false) const usingHls = ref(false);
let unbindMediaSessionListeners: null | (() => void) = null let unbindMediaSessionListeners: null | (() => void) = null;
let hls: Hls | null = null let hls: Hls | null = null;
let cleanupKeyboardShortcuts: null | (() => void) = null let cleanupKeyboardShortcuts: null | (() => void) = null;
const handle_event = (e: KeyboardEvent) => { const handle_event = (e: KeyboardEvent) => {
if ('Escape' !== e.key) { if ('Escape' !== e.key) {
return return;
} }
emitter('closeModel') emitter('closeModel');
} };
const bindMediaSessionListeners = (el: HTMLVideoElement) => { const bindMediaSessionListeners = (el: HTMLVideoElement) => {
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget) const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget);
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget) const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget);
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget) const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget);
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget) const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget);
const onPause = async (e: Event) => { const onPause = async (e: Event) => {
const target = (e.currentTarget as HTMLVideoElement) ?? null const target = (e.currentTarget as HTMLVideoElement) ?? null;
if (!target || destroyed.value) { if (!target || destroyed.value) {
return return;
} }
const dataUrl = await captureFrame(target) const dataUrl = await captureFrame(target);
if (dataUrl) { if (dataUrl) {
thumbnail.value = dataUrl thumbnail.value = dataUrl;
havePoster.value = true havePoster.value = true;
applyMediaSessionMetadata() applyMediaSessionMetadata();
} }
} };
el.addEventListener('loadedmetadata', onLoadedMetadata) el.addEventListener('loadedmetadata', onLoadedMetadata);
el.addEventListener('timeupdate', onTimeUpdate) el.addEventListener('timeupdate', onTimeUpdate);
el.addEventListener('ratechange', onRateChange) el.addEventListener('ratechange', onRateChange);
el.addEventListener('seeked', onSeeked) el.addEventListener('seeked', onSeeked);
el.addEventListener('pause', onPause) el.addEventListener('pause', onPause);
return () => { return () => {
el.removeEventListener('loadedmetadata', onLoadedMetadata) el.removeEventListener('loadedmetadata', onLoadedMetadata);
el.removeEventListener('timeupdate', onTimeUpdate) el.removeEventListener('timeupdate', onTimeUpdate);
el.removeEventListener('ratechange', onRateChange) el.removeEventListener('ratechange', onRateChange);
el.removeEventListener('seeked', onSeeked) el.removeEventListener('seeked', onSeeked);
el.removeEventListener('pause', onPause) el.removeEventListener('pause', onPause);
} };
} };
const updateMediaSessionPosition = (target: EventTarget | null) => { const updateMediaSessionPosition = (target: EventTarget | null) => {
if (false === ('mediaSession' in navigator)) { if (false === 'mediaSession' in navigator) {
return return;
} }
const el = (target as HTMLVideoElement) ?? null const el = (target as HTMLVideoElement) ?? null;
if (!el || destroyed.value) { if (!el || destroyed.value) {
return return;
} }
const d = el.duration const d = el.duration;
if (false === Number.isFinite(d) || 0 >= d) { if (false === Number.isFinite(d) || 0 >= d) {
return return;
} }
try { try {
navigator.mediaSession.setPositionState({ navigator.mediaSession.setPositionState({
duration: d, duration: d,
playbackRate: el.playbackRate, playbackRate: el.playbackRate,
position: el.currentTime, position: el.currentTime,
}) });
} catch { } } catch {}
} };
const volume_change_handler = () => { const volume_change_handler = () => {
const el = video.value const el = video.value;
if (!el) { if (!el) {
return return;
} }
volume.value = el.volume volume.value = el.volume;
updateMediaSessionPosition(el) updateMediaSessionPosition(el);
} };
const captureFrame = async (el: HTMLVideoElement): Promise<string> => { const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
if (!el || destroyed.value) { if (!el || destroyed.value) {
return '' return '';
} }
if (0 === el.videoWidth || 0 === el.videoHeight) { if (0 === el.videoWidth || 0 === el.videoHeight) {
return '' return '';
} }
const w = el.videoWidth const w = el.videoWidth;
const h = el.videoHeight const h = el.videoHeight;
try { try {
if ('OffscreenCanvas' in window) { if ('OffscreenCanvas' in window) {
const c = new (window as any).OffscreenCanvas(w, h) const c = new (window as any).OffscreenCanvas(w, h);
const ctx = c.getContext('2d') const ctx = c.getContext('2d');
if (!ctx) { return '' } if (!ctx) {
ctx.drawImage(el, 0, 0, w, h) return '';
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 }) }
return await new Promise<string>(r => { ctx.drawImage(el, 0, 0, w, h);
const fr = new FileReader() const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 });
fr.onload = () => r(String(fr.result)) return await new Promise<string>((r) => {
fr.readAsDataURL(blob) const fr = new FileReader();
}) fr.onload = () => r(String(fr.result));
fr.readAsDataURL(blob);
});
} else { } else {
const c = document.createElement('canvas') const c = document.createElement('canvas');
c.width = w c.width = w;
c.height = h c.height = h;
const ctx = c.getContext('2d') const ctx = c.getContext('2d');
if (!ctx) { return '' } if (!ctx) {
ctx.drawImage(el, 0, 0, w, h) return '';
return c.toDataURL('image/jpeg', 0.86) }
ctx.drawImage(el, 0, 0, w, h);
return c.toDataURL('image/jpeg', 0.86);
} }
} catch { } catch {
return '' return '';
} }
} };
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => { const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
if (!el || destroyed.value) { if (!el || destroyed.value) {
return return;
} }
if (havePoster.value) { if (havePoster.value) {
return return;
} }
if (0 === el.videoWidth || 0 === el.videoHeight) { if (0 === el.videoWidth || 0 === el.videoHeight) {
return return;
} }
const dataUrl = await captureFrame(el) const dataUrl = await captureFrame(el);
if (!dataUrl) { if (!dataUrl) {
return return;
} }
thumbnail.value = dataUrl thumbnail.value = dataUrl;
havePoster.value = true havePoster.value = true;
applyMediaSessionMetadata() applyMediaSessionMetadata();
} };
const restoreDefaultTextTrack = async () => { const restoreDefaultTextTrack = async () => {
const el = video.value const el = video.value;
if (!el) { if (!el) {
return return;
} }
try { try {
const tracksList = el.textTracks const tracksList = el.textTracks;
if (!tracksList || 0 === tracksList.length) { if (!tracksList || 0 === tracksList.length) {
return return;
} }
// Check if first track has cues - if not, we need to reload tracks // Check if first track has cues - if not, we need to reload tracks
const firstTrack = tracksList[0] as TextTrack | undefined const firstTrack = tracksList[0] as TextTrack | undefined;
const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0) const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0);
if (needsReload) { if (needsReload) {
const trackElements = el.querySelectorAll('track') const trackElements = el.querySelectorAll('track');
trackElements.forEach((trackEl, idx) => { trackElements.forEach((trackEl, idx) => {
const parent = trackEl.parentNode const parent = trackEl.parentNode;
const clone = trackEl.cloneNode(true) as HTMLTrackElement const clone = trackEl.cloneNode(true) as HTMLTrackElement;
trackEl.remove() trackEl.remove();
setTimeout(() => { setTimeout(() => {
if (parent) { if (parent) {
parent.appendChild(clone) parent.appendChild(clone);
// Set mode after cues load // Set mode after cues load
clone.addEventListener('load', () => { clone.addEventListener(
const trackObj = clone.track 'load',
if (trackObj) { () => {
trackObj.mode = idx === 0 ? 'showing' : 'disabled' const trackObj = clone.track;
} if (trackObj) {
}, { once: true }) trackObj.mode = idx === 0 ? 'showing' : 'disabled';
}
},
{ once: true },
);
} }
}, idx * 10) }, idx * 10);
}) });
return return;
} }
for (let i = 0; i < tracksList.length; i += 1) { for (let i = 0; i < tracksList.length; i += 1) {
const track = tracksList[i] as TextTrack | undefined const track = tracksList[i] as TextTrack | undefined;
if (track) { if (track) {
track.mode = 'disabled' track.mode = 'disabled';
} }
} }
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50));
for (let i = 0; i < tracksList.length; i += 1) { for (let i = 0; i < tracksList.length; i += 1) {
const track = tracksList[i] as TextTrack | undefined const track = tracksList[i] as TextTrack | undefined;
if (!track) { if (!track) {
continue continue;
} }
const newMode = 0 === i ? 'showing' : 'disabled' const newMode = 0 === i ? 'showing' : 'disabled';
track.mode = newMode track.mode = newMode;
} }
} catch (error) { } catch (error) {
console.warn('Failed to restore subtitle track state', error) console.warn('Failed to restore subtitle track state', error);
} }
} };
onMounted(async () => { onMounted(async () => {
disableOpacity() disableOpacity();
const req = await request(makeDownload(config, props.item, 'api/file/info')) const req = await request(makeDownload(config, props.item, 'api/file/info'));
const response: file_info = await req.json() const response: file_info = await req.json();
if (!req.ok) { if (!req.ok) {
emitter('error', response?.error || 'Failed to fetch video info. Unknown error') emitter('error', response?.error || 'Failed to fetch video info. Unknown error');
emitter('closeModel') emitter('closeModel');
return return;
} }
await nextTick() await nextTick();
if (props.item.extras?.thumbnail) { if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail) thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail);
havePoster.value = true havePoster.value = true;
} else { } else {
if (response.sidecar?.image?.[0]?.file) { if (response.sidecar?.image?.[0]?.file) {
thumbnail.value = makeDownload(config, { 'filename': response.sidecar.image[0].file }) thumbnail.value = makeDownload(config, { filename: response.sidecar.image[0].file });
havePoster.value = true havePoster.value = true;
} }
} }
hasVideoStream.value = Array.isArray(response.ffprobe?.video) hasVideoStream.value =
&& response.ffprobe.video.some(s => 'video' === s.codec_type) Array.isArray(response.ffprobe?.video) &&
response.ffprobe.video.some((s) => 'video' === s.codec_type);
// Extract video dimensions to prevent layout reflow // Extract video dimensions to prevent layout reflow
if (hasVideoStream.value && response.ffprobe?.video) { if (hasVideoStream.value && response.ffprobe?.video) {
const videoStream = response.ffprobe.video.find(s => 'video' === s.codec_type) const videoStream = response.ffprobe.video.find((s) => 'video' === s.codec_type);
if (videoStream?.width && videoStream?.height) { if (videoStream?.width && videoStream?.height) {
videoWidth.value = videoStream.width videoWidth.value = videoStream.width;
videoHeight.value = videoStream.height videoHeight.value = videoStream.height;
} }
} }
if (!props.item.extras?.is_video && props.item.extras?.is_audio) { if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true isAudio.value = true;
} else { } else {
if (false === hasVideoStream.value) { if (false === hasVideoStream.value) {
isAudio.value = true isAudio.value = true;
} }
} }
if (isApple) { if (isApple) {
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4') const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4');
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8', allowedCodec ? false : true) const src = makeDownload(
config,
props.item,
allowedCodec ? 'api/download' : 'm3u8',
allowedCodec ? false : true,
);
sources.value.push({ sources.value.push({
src, src,
type: allowedCodec ? response.mimetype : 'application/x-mpegURL', type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: (err: Event) => src_error(err), onerror: (err: Event) => src_error(err),
}) });
usingHls.value = !allowedCodec usingHls.value = !allowedCodec;
} else { } else {
const src = makeDownload(config, props.item, 'api/download', false) const src = makeDownload(config, props.item, 'api/download', false);
sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err), }) sources.value.push({ src, type: response.mimetype, onerror: (err: Event) => src_error(err) });
usingHls.value = false usingHls.value = false;
} }
if (props.item.extras?.channel) { if (props.item.extras?.channel) {
artist.value = props.item.extras.channel artist.value = props.item.extras.channel;
} }
if (!artist.value && props.item.extras?.uploader) { if (!artist.value && props.item.extras?.uploader) {
artist.value = props.item.extras.uploader artist.value = props.item.extras.uploader;
} }
if (props.item?.title) { if (props.item?.title) {
title.value = props.item.title title.value = props.item.title;
} else { } else {
if (response?.title) { if (response?.title) {
title.value = response.title title.value = response.title;
} else { } else {
if (response.ffprobe?.metadata?.tags?.title) { if (response.ffprobe?.metadata?.tags?.title) {
title.value = response.ffprobe.metadata.tags.title title.value = response.ffprobe.metadata.tags.title;
} }
} }
} }
@ -564,146 +606,145 @@ onMounted(async () => {
kind: 'captions', kind: 'captions',
label: cap.name, label: cap.name,
lang: cap.lang, lang: cap.lang,
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt` file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`,
}) });
}) });
if (isApple) { if (isApple) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block') document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
} }
infoLoaded.value = true infoLoaded.value = true;
await nextTick() await nextTick();
prepareVideoPlayer() prepareVideoPlayer();
if (video.value) { if (video.value) {
unbindMediaSessionListeners = bindMediaSessionListeners(video.value) unbindMediaSessionListeners = bindMediaSessionListeners(video.value);
} }
const keyboardShortcutsResult = useKeyboardShortcuts({ const keyboardShortcutsResult = useKeyboardShortcuts({
videoElement: video, videoElement: video,
enabled: ref(true), enabled: ref(true),
closePlayer: () => emitter('closeModel'), closePlayer: () => emitter('closeModel'),
}) });
cleanupKeyboardShortcuts = keyboardShortcutsResult.attach() cleanupKeyboardShortcuts = keyboardShortcutsResult.attach();
watch(keyboardShortcutsResult.showHelp, newVal => showHelp.value = newVal) watch(keyboardShortcutsResult.showHelp, (newVal) => (showHelp.value = newVal));
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event);
}) });
const applyMediaSessionMetadata = () => { const applyMediaSessionMetadata = () => {
if (false === ('mediaSession' in navigator)) { if (false === 'mediaSession' in navigator) {
return return;
} }
const meta: MediaMetadataInit = { title: title.value } const meta: MediaMetadataInit = { title: title.value };
if (artist.value) { if (artist.value) {
meta.artist = artist.value meta.artist = artist.value;
} }
if (thumbnail.value) { if (thumbnail.value) {
meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }] meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }];
} }
try { try {
navigator.mediaSession.metadata = new MediaMetadata(meta) navigator.mediaSession.metadata = new MediaMetadata(meta);
} catch { } } catch {}
} };
onUpdated(() => prepareVideoPlayer()) onUpdated(() => prepareVideoPlayer());
onBeforeUnmount(() => { onBeforeUnmount(() => {
enableOpacity() enableOpacity();
if (hls) { if (hls) {
hls.destroy() hls.destroy();
hls = null hls = null;
} }
usingHls.value = false usingHls.value = false;
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event);
if (cleanupKeyboardShortcuts) { if (cleanupKeyboardShortcuts) {
cleanupKeyboardShortcuts() cleanupKeyboardShortcuts();
cleanupKeyboardShortcuts = null cleanupKeyboardShortcuts = null;
} }
if (unbindMediaSessionListeners) { if (unbindMediaSessionListeners) {
unbindMediaSessionListeners() unbindMediaSessionListeners();
unbindMediaSessionListeners = null unbindMediaSessionListeners = null;
} }
if (title.value) { if (title.value) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube';
} }
if (!video.value) { if (!video.value) {
return return;
} }
destroyed.value = true destroyed.value = true;
try { try {
video.value.pause() video.value.pause();
video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src')) video.value.querySelectorAll('source').forEach((source) => source.removeAttribute('src'));
video.value.removeEventListener('volumechange', volume_change_handler) video.value.removeEventListener('volumechange', volume_change_handler);
video.value.load() video.value.load();
} catch (e) {
console.error(e);
} }
catch (e) { });
console.error(e)
}
})
const prepareVideoPlayer = () => { const prepareVideoPlayer = () => {
if (!infoLoaded.value) { if (!infoLoaded.value) {
return return;
} }
applyMediaSessionMetadata() applyMediaSessionMetadata();
if (title.value) { if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}` window.document.title = `YTPTube - Playing: ${title.value}`;
} }
if (!video.value) { if (!video.value) {
return return;
} }
video.value.volume = volume.value video.value.volume = volume.value;
video.value.addEventListener('volumechange', volume_change_handler) video.value.addEventListener('volumechange', volume_change_handler);
restoreDefaultTextTrack() restoreDefaultTextTrack();
if (hasVideoStream.value) { if (hasVideoStream.value) {
if ('requestVideoFrameCallback' in video.value) { if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!)) (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
} else { } else {
const tryOnce = () => captureFirstFramePoster(video.value!); const tryOnce = () => captureFirstFramePoster(video.value!);
; (video.value as any).addEventListener('loadeddata', tryOnce, { once: true }) (video.value as any).addEventListener('loadeddata', tryOnce, { once: true });
} }
} }
} };
const src_error = async (e: any) => { const src_error = async (e: any) => {
if (hls) { if (hls) {
return return;
} }
await nextTick() await nextTick();
if (destroyed.value) { if (destroyed.value) {
return return;
} }
if (video.value && (notFirefox && video.value.paused)) { if (video.value && notFirefox && video.value.paused) {
return return;
} }
console.warn('Source failed to load, attempting HLS fallback via hls.js...', e) console.warn('Source failed to load, attempting HLS fallback via hls.js...', e);
attach_hls(makeDownload(config, props.item, 'm3u8', true)) attach_hls(makeDownload(config, props.item, 'm3u8', true));
} };
const attach_hls = (link: string) => { const attach_hls = (link: string) => {
if (!video.value) { if (!video.value) {
return return;
} }
hls = new Hls({ hls = new Hls({
@ -712,45 +753,45 @@ const attach_hls = (link: string) => {
lowLatencyMode: true, lowLatencyMode: true,
backBufferLength: 120, backBufferLength: 120,
fragLoadingTimeOut: 200000, fragLoadingTimeOut: 200000,
}) });
hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata()) hls.on(Hls.Events.MANIFEST_PARSED, () => applyMediaSessionMetadata());
hls.on(Hls.Events.MANIFEST_PARSED, async () => { hls.on(Hls.Events.MANIFEST_PARSED, async () => {
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100));
await restoreDefaultTextTrack() await restoreDefaultTextTrack();
}) });
hls.on(Hls.Events.MEDIA_ATTACHED, async () => { hls.on(Hls.Events.MEDIA_ATTACHED, async () => {
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise((resolve) => setTimeout(resolve, 200));
await restoreDefaultTextTrack() await restoreDefaultTextTrack();
}) });
hls.on(Hls.Events.LEVEL_LOADED, () => { hls.on(Hls.Events.LEVEL_LOADED, () => {
if (video.value) { if (video.value) {
if ('requestVideoFrameCallback' in video.value) { if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!)) (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!));
} else { } else {
const once = () => captureFirstFramePoster(video.value!) const once = () => captureFirstFramePoster(video.value!);
; (video.value as any).addEventListener('loadeddata', once, { once: true }) (video.value as any).addEventListener('loadeddata', once, { once: true });
} }
} }
}) });
hls.loadSource(link) hls.loadSource(link);
hls.attachMedia(video.value) hls.attachMedia(video.value);
usingHls.value = true usingHls.value = true;
} };
const forceSwitchToHls = () => { const forceSwitchToHls = () => {
if (usingHls.value) { if (usingHls.value) {
return return;
} }
if (!hasVideoStream.value) { if (!hasVideoStream.value) {
useNotification().error('Cannot switch to HLS: stream has no video track.') useNotification().error('Cannot switch to HLS: stream has no video track.');
return return;
} }
attach_hls(makeDownload(config, props.item, 'm3u8', true)) attach_hls(makeDownload(config, props.item, 'm3u8', true));
} };
</script> </script>

View file

@ -1,14 +1,18 @@
<!-- ui/app/pages/options.vue --> <!-- ui/app/pages/options.vue -->
<template> <template>
<div class="p-1 container" style="border-radius: 0; padding:0"> <div class="p-1 container" style="border-radius: 0; padding: 0">
<div class="box m-2"> <div class="box m-2">
<div class="columns is-multiline is-vcentered"> <div class="columns is-multiline is-vcentered">
<div class="column is-12 is-6-desktop"> <div class="column is-12 is-6-desktop">
<label class="label is-small">Search</label> <label class="label is-small">Search</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input v-model.trim="filters.query" type="text" class="input" placeholder="Filter by flag or description..." <input
autocomplete="off" /> v-model.trim="filters.query"
type="text"
class="input"
placeholder="Filter by flag or description..."
autocomplete="off"
/>
<span class="icon is-left"><i class="fa-solid fa-magnifying-glass" /></span> <span class="icon is-left"><i class="fa-solid fa-magnifying-glass" /></span>
</div> </div>
</div> </div>
@ -58,12 +62,27 @@
<div class="column is-12 is-6-desktop"> <div class="column is-12 is-6-desktop">
<label class="label is-small">Flags</label> <label class="label is-small">Flags</label>
<div class="buttons are-small"> <div class="buttons are-small">
<button class="button" :class="{ 'is-link': filters.flagKind === 'any' }" <button
@click="filters.flagKind = 'any'">Any</button> class="button"
<button class="button" :class="{ 'is-link': filters.flagKind === 'short' }" :class="{ 'is-link': filters.flagKind === 'any' }"
@click="filters.flagKind = 'short'">Short Only (-x)</button> @click="filters.flagKind = 'any'"
<button class="button" :class="{ 'is-link': filters.flagKind === 'long' }" >
@click="filters.flagKind = 'long'">Long Only (--xyz)</button> Any
</button>
<button
class="button"
:class="{ 'is-link': filters.flagKind === 'short' }"
@click="filters.flagKind = 'short'"
>
Short Only (-x)
</button>
<button
class="button"
:class="{ 'is-link': filters.flagKind === 'long' }"
@click="filters.flagKind = 'long'"
>
Long Only (--xyz)
</button>
</div> </div>
</div> </div>
</div> </div>
@ -78,7 +97,10 @@
<h2 class="title is-6 mb-3"> <h2 class="title is-6 mb-3">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-regular fa-folder-open" /></span> <span class="icon"><i class="fa-regular fa-folder-open" /></span>
<span>{{ group.name }} <small class="has-text-grey">({{ group.items.length }})</small></span> <span
>{{ group.name }}
<small class="has-text-grey">({{ group.items.length }})</small></span
>
</span> </span>
</h2> </h2>
<div class="table-container"> <div class="table-container">
@ -93,8 +115,10 @@
<template v-for="opt in group.items" :key="opt.flags.join('|')"> <template v-for="opt in group.items" :key="opt.flags.join('|')">
<tr v-if="!opt.ignored"> <tr v-if="!opt.ignored">
<td> <td>
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable" <i
@click="copyFlag(opt.flags)" /> class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
@click="copyFlag(opt.flags)"
/>
<div class="is-flex is-align-items-center"> <div class="is-flex is-align-items-center">
<div class="tags"> <div class="tags">
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span> <span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
@ -102,7 +126,9 @@
</div> </div>
</td> </td>
<td> <td>
<span v-if="opt.description && 0 !== opt.description.length">{{ opt.description }}</span> <span v-if="opt.description && 0 !== opt.description.length">{{
opt.description
}}</span>
<span v-else class="has-text-grey"></span> <span v-else class="has-text-grey"></span>
</td> </td>
</tr> </tr>
@ -127,8 +153,10 @@
<template v-for="opt in visible" :key="opt.flags.join('|')"> <template v-for="opt in visible" :key="opt.flags.join('|')">
<tr v-if="!opt.ignored"> <tr v-if="!opt.ignored">
<td> <td>
<i class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable" <i
@click="copyFlag(opt.flags)" /> class="has-text-primary is-pointer is-pulled-right fa-regular fa-copy is-unselectable"
@click="copyFlag(opt.flags)"
/>
<div class="is-flex is-align-items-center"> <div class="is-flex is-align-items-center">
<div class="tags"> <div class="tags">
<span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span> <span v-for="f in opt.flags" :key="f" class="tag is-info">{{ f }}</span>
@ -139,7 +167,9 @@
{{ opt.group || 'root' }} {{ opt.group || 'root' }}
</td> </td>
<td> <td>
<span v-if="opt.description && 0 !== opt.description.length">{{ opt.description }}</span> <span v-if="opt.description && 0 !== opt.description.length">{{
opt.description
}}</span>
<span v-else class="has-text-grey"></span> <span v-else class="has-text-grey"></span>
</td> </td>
</tr> </tr>
@ -152,127 +182,129 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { YTDLPOption } from '~/types/ytdlp' import type { YTDLPOption } from '~/types/ytdlp';
const isLoading = ref(false) const isLoading = ref(false);
const options = ref<YTDLPOption[]>([]) const options = ref<YTDLPOption[]>([]);
const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped') const displayMode = useStorage<'grouped' | 'list'>('opts_display', 'grouped');
const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag') const sortBy = useStorage<'flag' | 'group'>('opts_sort_by', 'flag');
const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc') const sortDir = useStorage<'asc' | 'desc'>('opts_sort_dir', 'asc');
const filters = reactive({ const filters = reactive({
query: '', query: '',
group: '', group: '',
flagKind: 'any' as 'any' | 'short' | 'long', flagKind: 'any' as 'any' | 'short' | 'long',
}) });
const reload = async (): Promise<void> => { const reload = async (): Promise<void> => {
try { try {
isLoading.value = true isLoading.value = true;
const resp = await request('/api/yt-dlp/options') const resp = await request('/api/yt-dlp/options');
if (!resp.ok) { if (!resp.ok) {
return return;
} }
const data = await resp.json() const data = await resp.json();
if (Array.isArray(data)) { if (Array.isArray(data)) {
options.value = data as YTDLPOption[] options.value = data as YTDLPOption[];
} }
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
} };
const copyFlag = async (flags: string[]): Promise<void> => { const copyFlag = async (flags: string[]): Promise<void> => {
const longFlag = flags.find(f => f.startsWith('--')) const longFlag = flags.find((f) => f.startsWith('--'));
if (!longFlag) { if (!longFlag) {
return return;
} }
copyText(longFlag) copyText(longFlag);
} };
onMounted(async () => await reload()) onMounted(async () => await reload());
const groupNames = computed<string[]>(() => { const groupNames = computed<string[]>(() => {
const s = new Set<string>() const s = new Set<string>();
for (const o of options.value) { for (const o of options.value) {
s.add(o.group || 'root') s.add(o.group || 'root');
} }
return Array.from(s).sort((a, b) => a.localeCompare(b)) return Array.from(s).sort((a, b) => a.localeCompare(b));
}) });
const filtered = computed<YTDLPOption[]>(() => { const filtered = computed<YTDLPOption[]>(() => {
const q = filters.query.toLowerCase() const q = filters.query.toLowerCase();
const g = filters.group const g = filters.group;
return options.value.filter((o) => { return options.value.filter((o) => {
if (g && (o.group || 'root') !== g) { if (g && (o.group || 'root') !== g) {
return false return false;
} }
if ('short' === filters.flagKind && !o.flags.some((f) => /^-\w(,|$)|^-\w$/.test(f))) { if ('short' === filters.flagKind && !o.flags.some((f) => /^-\w(,|$)|^-\w$/.test(f))) {
return false return false;
} }
if ('long' === filters.flagKind && !o.flags.some((f) => /^--[a-zA-Z0-9][\w-]*/.test(f))) { if ('long' === filters.flagKind && !o.flags.some((f) => /^--[a-zA-Z0-9][\w-]*/.test(f))) {
return false return false;
} }
if (0 !== q.length) { if (0 !== q.length) {
const hay = [o.flags.join(' '), o.description || '', o.group || 'root'].join(' ').toLowerCase() const hay = [o.flags.join(' '), o.description || '', o.group || 'root']
.join(' ')
.toLowerCase();
if (-1 === hay.indexOf(q)) { if (-1 === hay.indexOf(q)) {
return false return false;
} }
} }
return true return true;
}) });
}) });
const sorted = computed<YTDLPOption[]>(() => { const sorted = computed<YTDLPOption[]>(() => {
const dir = 'asc' === sortDir.value ? 1 : -1 const dir = 'asc' === sortDir.value ? 1 : -1;
const arr = [...filtered.value] const arr = [...filtered.value];
arr.sort((a, b) => { arr.sort((a, b) => {
if ('group' === sortBy.value) { if ('group' === sortBy.value) {
const ga = (a.group || 'root').localeCompare(b.group || 'root') const ga = (a.group || 'root').localeCompare(b.group || 'root');
if (0 !== ga) { if (0 !== ga) {
return ga * dir return ga * dir;
} }
} }
const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '') const fa = (a.flags[0] || '').localeCompare(b.flags[0] || '');
return fa * dir return fa * dir;
}) });
return arr return arr;
}) });
const visible = computed<YTDLPOption[]>(() => sorted.value) const visible = computed<YTDLPOption[]>(() => sorted.value);
const grouped = computed<{ name: string, items: YTDLPOption[] }[]>(() => { const grouped = computed<{ name: string; items: YTDLPOption[] }[]>(() => {
const map = new Map<string, YTDLPOption[]>() const map = new Map<string, YTDLPOption[]>();
for (const o of visible.value) { for (const o of visible.value) {
const key = o.group || 'root' const key = o.group || 'root';
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, []) map.set(key, []);
} }
map.get(key)!.push(o) map.get(key)!.push(o);
} }
const dir = 'asc' === sortDir.value ? 1 : -1 const dir = 'asc' === sortDir.value ? 1 : -1;
const out = Array.from(map.entries()).map(([name, items]) => ({ name, items })) const out = Array.from(map.entries()).map(([name, items]) => ({ name, items }));
if ('group' === sortBy.value) { if ('group' === sortBy.value) {
out.sort((a, b) => a.name.localeCompare(b.name) * dir) out.sort((a, b) => a.name.localeCompare(b.name) * dir);
} else { } else {
// When sorting by flag, groups should still be in alphabetical order // When sorting by flag, groups should still be in alphabetical order
out.sort((a, b) => a.name.localeCompare(b.name)) out.sort((a, b) => a.name.localeCompare(b.name));
} }
return out return out;
}) });
</script> </script>
<style scoped> <style scoped>

View file

@ -3,7 +3,8 @@
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<section <section
class="hero has-text-centered is-flex is-align-items-center is-justify-content-center"> class="hero has-text-centered is-flex is-align-items-center is-justify-content-center"
>
<div class="goodbye-box"> <div class="goodbye-box">
<div class="goodbye-title">Goodbye!</div> <div class="goodbye-title">Goodbye!</div>
<p class="goodbye-subtitle">YTPTube has shut down.</p> <p class="goodbye-subtitle">YTPTube has shut down.</p>
@ -72,7 +73,8 @@
} }
@keyframes floatTitle { @keyframes floatTitle {
0%, 100% { 0%,
100% {
transform: translateY(0px); transform: translateY(0px);
} }
50% { 50% {

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 { useNotification } from '~/composables/useNotification';
import { useConfigStore } from '~/stores/ConfigStore' import { useConfigStore } from '~/stores/ConfigStore';
import { request, parse_api_error, sTrim, encodePath } from '~/utils' import { request, parse_api_error, sTrim, encodePath } from '~/utils';
import type { FileItem, Pagination } from '~/types/filebrowser' import type { FileItem, Pagination } from '~/types/filebrowser';
const items = ref<FileItem[]>([]) const items = ref<FileItem[]>([]);
const path = ref<string>('/') const path = ref<string>('/');
const pagination = ref<Pagination>({ const pagination = ref<Pagination>({
page: 1, page: 1,
per_page: 50, per_page: 50,
@ -14,219 +14,228 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
const selectedElms = ref<string[]>([]) const selectedElms = ref<string[]>([]);
const masterSelectAll = ref<boolean>(false) const masterSelectAll = ref<boolean>(false);
const sort_by = ref<string>('name') const sort_by = ref<string>('name');
const sort_order = ref<string>('asc') const sort_order = ref<string>('asc');
const search = ref<string>('') const search = ref<string>('');
const throwInstead = ref(false) const throwInstead = ref(false);
const notify = useNotification() const notify = useNotification();
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
const buildQueryParams = (page?: number): string => { const buildQueryParams = (page?: number): string => {
const config = useConfigStore() const config = useConfigStore();
const params = new URLSearchParams() const params = new URLSearchParams();
params.set('page', String(page ?? pagination.value.page)) params.set('page', String(page ?? pagination.value.page));
params.set('per_page', String(config.app.default_pagination || 50)) params.set('per_page', String(config.app.default_pagination || 50));
params.set('sort_by', sort_by.value) params.set('sort_by', sort_by.value);
params.set('sort_order', sort_order.value) params.set('sort_order', sort_order.value);
if (search.value) { if (search.value) {
params.set('search', search.value) params.set('search', search.value);
} }
return params.toString() return params.toString();
} };
const loadContents = async (dir: string = '/', page: number = 1): Promise<boolean> => { const loadContents = async (dir: string = '/', page: number = 1): Promise<boolean> => {
isLoading.value = true isLoading.value = true;
try { try {
if (typeof dir !== 'string') { if (typeof dir !== 'string') {
dir = '/' dir = '/';
} }
dir = encodePath(sTrim(dir, '/')) dir = encodePath(sTrim(dir, '/'));
const query = buildQueryParams(page) const query = buildQueryParams(page);
const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`) const response = await request(`/api/file/browser/${sTrim(dir, '/')}?${query}`);
await ensureSuccess(response) await ensureSuccess(response);
const data = await response.json() const data = await response.json();
items.value = data.contents || [] items.value = data.contents || [];
path.value = data.path || '/' path.value = data.path || '/';
if (data.pagination) { if (data.pagination) {
pagination.value = data.pagination pagination.value = data.pagination;
} }
selectedElms.value = [] selectedElms.value = [];
masterSelectAll.value = false masterSelectAll.value = false;
lastError.value = null lastError.value = null;
return true return true;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return false;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return false
}
finally {
isLoading.value = false
}
}
const changeSort = async (by: string): Promise<void> => { const changeSort = async (by: string): Promise<void> => {
if (!['name', 'size', 'date', 'type'].includes(by)) { if (!['name', 'size', 'date', 'type'].includes(by)) {
return return;
} }
if (by !== sort_by.value) { if (by !== sort_by.value) {
sort_by.value = by sort_by.value = by;
} } else {
else { sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc';
sort_order.value = sort_order.value === 'asc' ? 'desc' : 'asc'
} }
await loadContents(path.value, 1) await loadContents(path.value, 1);
} };
const setSearch = async (value: string): Promise<void> => { const setSearch = async (value: string): Promise<void> => {
search.value = value search.value = value;
await loadContents(path.value, 1) await loadContents(path.value, 1);
} };
const setSortBy = (value: string): void => { const setSortBy = (value: string): void => {
sort_by.value = value sort_by.value = value;
} };
const setSortOrder = (value: string): void => { const setSortOrder = (value: string): void => {
sort_order.value = value sort_order.value = value;
} };
const setSearchValue = (value: string): void => { const setSearchValue = (value: string): void => {
search.value = value search.value = value;
} };
const setPage = (value: number): void => { const setPage = (value: number): void => {
pagination.value.page = value pagination.value.page = value;
} };
const changePage = async (page: number): Promise<void> => { const changePage = async (page: number): Promise<void> => {
await loadContents(path.value, page) await loadContents(path.value, page);
} };
const performAction = async ( const performAction = async (
item: FileItem, item: FileItem,
action: string, action: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
callback?: (item: FileItem, action: string, data: Record<string, unknown>, source: FileItem) => void, callback?: (
multiple: boolean = false item: FileItem,
action: string,
data: Record<string, unknown>,
source: FileItem,
) => void,
multiple: boolean = false,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request('/api/file/actions', { const response = await request('/api/file/actions', {
method: 'POST', method: 'POST',
body: JSON.stringify([{ path: item.path, action, ...payload }]), body: JSON.stringify([{ path: item.path, action, ...payload }]),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const results = await response.json() as Array<{ path: string, status: boolean, error?: string, [key: string]: unknown }> const results = (await response.json()) as Array<{
path: string;
status: boolean;
error?: string;
[key: string]: unknown;
}>;
for (const result of results) { for (const result of results) {
if (!multiple && result.path !== item.path) { if (!multiple && result.path !== item.path) {
continue continue;
} }
if (!multiple && !result.status) { if (!multiple && !result.status) {
notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`) notify.error(`Failed to perform action: ${result.error || 'Unknown error'}`);
return false return false;
} }
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
if (!multiple) { if (!multiple) {
callback(item, action, payload as Record<string, unknown>, item) callback(item, action, payload as Record<string, unknown>, item);
} } else {
else { const matchedItem = items.value.find((it) => it.path === result.path);
const matchedItem = items.value.find(it => it.path === result.path)
if (matchedItem) { if (matchedItem) {
callback(matchedItem, action, result as Record<string, unknown>, toRaw(item)) callback(matchedItem, action, result as Record<string, unknown>, toRaw(item));
} }
} }
} }
} }
return true return true;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return false
}
}
const performMassAction = async ( const performMassAction = async (
actions: Array<{ path: string, action: string, [key: string]: unknown }>, actions: Array<{ path: string; action: string; [key: string]: unknown }>,
callback?: (result: { path: string, status: boolean, error?: string }) => void callback?: (result: { path: string; status: boolean; error?: string }) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request('/api/file/actions', { const response = await request('/api/file/actions', {
method: 'POST', method: 'POST',
body: JSON.stringify(actions), body: JSON.stringify(actions),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const results = await response.json() as Array<{ path: string, status: boolean, error?: string }> const results = (await response.json()) as Array<{
path: string;
status: boolean;
error?: string;
}>;
for (const result of results) { for (const result of results) {
if (!result.status) { if (!result.status) {
notify.error(`Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`) notify.error(
continue `Failed to perform action on '${result.path}': ${result.error || 'Unknown error'}`,
);
continue;
} }
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
callback(result) callback(result);
} }
} }
return true return true;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return false
}
}
const createDirectory = async (dir: string, newDir: string): Promise<boolean> => { const createDirectory = async (dir: string, newDir: string): Promise<boolean> => {
const trimmedDir = sTrim(newDir, '/') const trimmedDir = sTrim(newDir, '/');
if (!trimmedDir || trimmedDir === dir) { if (!trimmedDir || trimmedDir === dir) {
return false return false;
} }
const success = await performAction( const success = await performAction(
@ -234,17 +243,17 @@ const createDirectory = async (dir: string, newDir: string): Promise<boolean> =>
'directory', 'directory',
{ new_dir: trimmedDir }, { new_dir: trimmedDir },
() => { () => {
notify.success(`Successfully created '${trimmedDir}'.`) notify.success(`Successfully created '${trimmedDir}'.`);
} },
) );
return success return success;
} };
const renameItem = async (item: FileItem, newName: string): Promise<boolean> => { const renameItem = async (item: FileItem, newName: string): Promise<boolean> => {
const trimmedName = newName.trim() const trimmedName = newName.trim();
if (!trimmedName || trimmedName === item.name) { if (!trimmedName || trimmedName === item.name) {
return false return false;
} }
return await performAction( return await performAction(
@ -252,33 +261,28 @@ const renameItem = async (item: FileItem, newName: string): Promise<boolean> =>
'rename', 'rename',
{ new_name: trimmedName }, { new_name: trimmedName },
(it, _, data) => { (it, _, data) => {
const source = data as { new_path?: string } const source = data as { new_path?: string };
if (source.new_path) { if (source.new_path) {
it.name = source.new_path.split('/').pop() || trimmedName it.name = source.new_path.split('/').pop() || trimmedName;
it.path = source.new_path it.path = source.new_path;
} }
notify.success(`Renamed '${item.name}'.`) notify.success(`Renamed '${item.name}'.`);
}, },
true true,
) );
} };
const deleteItem = async (item: FileItem): Promise<boolean> => { const deleteItem = async (item: FileItem): Promise<boolean> => {
return await performAction( return await performAction(item, 'delete', {}, () => {
item, items.value = items.value.filter((i) => i.path !== item.path);
'delete', notify.warning(`Deleted '${item.name}'.`);
{}, });
() => { };
items.value = items.value.filter(i => i.path !== item.path)
notify.warning(`Deleted '${item.name}'.`)
}
)
}
const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => { const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
const trimmedPath = sTrim(newPath, '/') || '/' const trimmedPath = sTrim(newPath, '/') || '/';
if (!trimmedPath || trimmedPath === item.path) { if (!trimmedPath || trimmedPath === item.path) {
return false return false;
} }
return await performAction( return await performAction(
@ -286,73 +290,73 @@ const moveItem = async (item: FileItem, newPath: string): Promise<boolean> => {
'move', 'move',
{ new_path: trimmedPath }, { new_path: trimmedPath },
(it, _, data) => { (it, _, data) => {
const source = data as { new_path?: string; path?: string } const source = data as { new_path?: string; path?: string };
if (source.path) { if (source.path) {
items.value = items.value.filter(i => i.path !== source.path) items.value = items.value.filter((i) => i.path !== source.path);
} }
notify.success(`Moved '${item.name}' to '${trimmedPath}'.`) notify.success(`Moved '${item.name}' to '${trimmedPath}'.`);
}, },
true true,
) );
} };
const deleteSelected = async (): Promise<boolean> => { const deleteSelected = async (): Promise<boolean> => {
if (selectedElms.value.length < 1) { if (selectedElms.value.length < 1) {
notify.error('No items selected.') notify.error('No items selected.');
return false return false;
} }
const actions = selectedElms.value.map(p => { const actions = selectedElms.value.map((p) => {
return { path: p, action: 'delete' } return { path: p, action: 'delete' };
}) });
const success = await performMassAction(actions, (result) => { const success = await performMassAction(actions, (result) => {
const item = items.value.find(it => it.path === result.path) const item = items.value.find((it) => it.path === result.path);
if (item) { if (item) {
items.value = items.value.filter(it => it.path !== result.path) items.value = items.value.filter((it) => it.path !== result.path);
notify.warning(`Deleted '${item.name}'.`) notify.warning(`Deleted '${item.name}'.`);
} }
}) });
if (success) { if (success) {
selectedElms.value = [] selectedElms.value = [];
} }
return success return success;
} };
const moveSelected = async (newPath: string): Promise<boolean> => { const moveSelected = async (newPath: string): Promise<boolean> => {
if (selectedElms.value.length < 1) { if (selectedElms.value.length < 1) {
notify.error('No items selected.') notify.error('No items selected.');
return false return false;
} }
const trimmedPath = sTrim(newPath, '/') || '/' const trimmedPath = sTrim(newPath, '/') || '/';
const actions = selectedElms.value.map(p => ({ const actions = selectedElms.value.map((p) => ({
path: p, path: p,
action: 'move', action: 'move',
new_path: trimmedPath, new_path: trimmedPath,
})) }));
const success = await performMassAction(actions, (result) => { const success = await performMassAction(actions, (result) => {
items.value = items.value.filter(it => it.path !== result.path) items.value = items.value.filter((it) => it.path !== result.path);
notify.success(`Moved '${result.path}' to '${trimmedPath}'.`) notify.success(`Moved '${result.path}' to '${trimmedPath}'.`);
}) });
if (success) { if (success) {
selectedElms.value = [] selectedElms.value = [];
} }
return success return success;
} };
const clearError = (): void => { const clearError = (): void => {
lastError.value = null lastError.value = null;
} };
const reset = (): void => { const reset = (): void => {
items.value = [] items.value = [];
path.value = '/' path.value = '/';
pagination.value = { pagination.value = {
page: 1, page: 1,
per_page: 50, per_page: 50,
@ -360,16 +364,16 @@ const reset = (): void => {
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
} };
selectedElms.value = [] selectedElms.value = [];
masterSelectAll.value = false masterSelectAll.value = false;
search.value = '' search.value = '';
lastError.value = null lastError.value = null;
} };
const filteredItems = computed<FileItem[]>(() => { const filteredItems = computed<FileItem[]>(() => {
return items.value return items.value;
}) });
export const useBrowser = () => ({ export const useBrowser = () => ({
items: readonly(items), items: readonly(items),
@ -403,4 +407,4 @@ export const useBrowser = () => ({
clearError, clearError,
reset, reset,
throwInstead, throwInstead,
}) });

View file

@ -1,19 +1,19 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { import type {
Condition, Condition,
ConditionTestRequest, ConditionTestRequest,
ConditionTestResponse, ConditionTestResponse,
Pagination, Pagination,
} from '~/types/conditions' } from '~/types/conditions';
import type { APIResponse } from '~/types/responses' import type { APIResponse } from '~/types/responses';
/** /**
* List of all conditions in memory. * List of all conditions in memory.
*/ */
const conditions = ref<Array<Condition>>([]) const conditions = ref<Array<Condition>>([]);
/** /**
* Pagination state for conditions list. * Pagination state for conditions list.
*/ */
@ -24,32 +24,32 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
* Used separately from isLoading for finer control. * Used separately from isLoading for finer control.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false);
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts conditions by priority (descending - higher number first), then name (A-Z). * Sorts conditions by priority (descending - higher number first), then name (A-Z).
@ -59,12 +59,12 @@ const notify = useNotification()
const sortConditions = (items: Array<Condition>): Array<Condition> => { const sortConditions = (items: Array<Condition>): Array<Condition> => {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
if (a.priority === b.priority) { if (a.priority === b.priority) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name);
} }
return b.priority - a.priority return b.priority - a.priority;
}) });
} };
/** /**
* Safely reads JSON from a Response, returns null on error. * Safely reads JSON from a Response, returns null on error.
@ -73,13 +73,12 @@ const sortConditions = (items: Array<Condition>): Array<Condition> => {
*/ */
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -88,23 +87,23 @@ const readJson = async (response: Response): Promise<unknown> => {
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a condition in the conditions list, keeping sort order. * Updates or adds a condition in the conditions list, keeping sort order.
@ -112,15 +111,15 @@ const handleError = (error: unknown): void => {
* @param condition Condition to update/add * @param condition Condition to update/add
*/ */
const updateConditions = (condition: Condition): void => { const updateConditions = (condition: Condition): void => {
const isNew = !conditions.value.some(item => item.id === condition.id) const isNew = !conditions.value.some((item) => item.id === condition.id);
conditions.value = sortConditions([ conditions.value = sortConditions([
...conditions.value.filter(item => item.id !== condition.id), ...conditions.value.filter((item) => item.id !== condition.id),
condition, condition,
]) ]);
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a condition from the conditions list by ID. * Removes a condition from the conditions list by ID.
@ -128,12 +127,12 @@ const updateConditions = (condition: Condition): void => {
* @param id Condition ID * @param id Condition ID
*/ */
const removeCondition = (id: number) => { const removeCondition = (id: number) => {
const initialLength = conditions.value.length const initialLength = conditions.value.length;
conditions.value = conditions.value.filter(item => item.id !== id) conditions.value = conditions.value.filter((item) => item.id !== id);
if (conditions.value.length < initialLength) { if (conditions.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads all conditions from the API with pagination support. * Loads all conditions from the API with pagination support.
@ -141,31 +140,32 @@ const removeCondition = (id: number) => {
* @param page Page number * @param page Page number
* @param perPage Items per page * @param perPage Items per page
*/ */
const loadConditions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadConditions = async (
isLoading.value = true page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
isLoading.value = true;
try { try {
let url = `/api/conditions/?page=${page}` let url = `/api/conditions/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<Condition>(json) const { items, pagination: paginationData } = await parse_list_response<Condition>(json);
conditions.value = sortConditions(items) conditions.value = sortConditions(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Fetches a single condition by ID from the API. * Fetches a single condition by ID from the API.
@ -174,21 +174,20 @@ const loadConditions = async (page: number = 1, perPage: number | undefined = un
*/ */
const getCondition = async (id: number): Promise<Condition | null> => { const getCondition = async (id: number): Promise<Condition | null> => {
try { try {
const response = await request(`/api/conditions/${id}`) const response = await request(`/api/conditions/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const condition = await parse_api_response<Condition>(json) const condition = await parse_api_response<Condition>(json);
lastError.value = null lastError.value = null;
return condition return condition;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new condition via API. * Creates a new condition via API.
@ -200,43 +199,41 @@ const createCondition = async (
condition: Omit<Condition, 'id'>, condition: Omit<Condition, 'id'>,
callback?: (response: APIResponse<Condition>) => void, callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => { ): Promise<Condition | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request('/api/conditions/', { const response = await request('/api/conditions/', {
method: 'POST', method: 'POST',
body: JSON.stringify(condition), body: JSON.stringify(condition),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const created = await parse_api_response<Condition>(json) const created = await parse_api_response<Condition>(json);
updateConditions(created) updateConditions(created);
notify.success('Condition created.') notify.success('Condition created.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Updates an existing condition via API (PUT - full update). * Updates an existing condition via API (PUT - full update).
@ -250,46 +247,44 @@ const updateCondition = async (
condition: Condition, condition: Condition,
callback?: (response: APIResponse<Condition>) => void, callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => { ): Promise<Condition | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (condition.id) { if (condition.id) {
condition.id = undefined condition.id = undefined;
} }
const response = await request(`/api/conditions/${id}`, { const response = await request(`/api/conditions/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(condition), body: JSON.stringify(condition),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Condition>(json) const updated = await parse_api_response<Condition>(json);
updateConditions(updated) updateConditions(updated);
notify.success(`Condition '${updated.name}' updated.`) notify.success(`Condition '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Partially updates an existing condition via API (PATCH). * Partially updates an existing condition via API (PATCH).
@ -303,46 +298,44 @@ const patchCondition = async (
patch: Partial<Condition>, patch: Partial<Condition>,
callback?: (response: APIResponse<Condition>) => void, callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => { ): Promise<Condition | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (patch.id) { if (patch.id) {
patch.id = undefined patch.id = undefined;
} }
const response = await request(`/api/conditions/${id}`, { const response = await request(`/api/conditions/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Condition>(json) const updated = await parse_api_response<Condition>(json);
updateConditions(updated) updateConditions(updated);
notify.success(`Condition '${updated.name}' updated.`) notify.success(`Condition '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Deletes a condition by ID via API. * Deletes a condition by ID via API.
@ -355,63 +348,63 @@ const deleteCondition = async (
callback?: (response: APIResponse<boolean>) => void, callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' }) const response = await request(`/api/conditions/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removeCondition(id) removeCondition(id);
notify.success('Condition deleted.') notify.success('Condition deleted.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: true }) callback({ success: true, error: null, detail: null, data: true });
} }
return true return true;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false }) callback({ success: false, error: errorMessage, detail: error, data: false });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return false return false;
} }
} };
/** /**
* Tests a condition against a URL. * Tests a condition against a URL.
* @param testRequest Test request parameters * @param testRequest Test request parameters
* @returns Test result or null on error * @returns Test result or null on error
*/ */
const testCondition = async (testRequest: ConditionTestRequest): Promise<ConditionTestResponse | null> => { const testCondition = async (
testRequest: ConditionTestRequest,
): Promise<ConditionTestResponse | null> => {
try { try {
const response = await request('/api/conditions/test/', { const response = await request('/api/conditions/test/', {
method: 'POST', method: 'POST',
body: JSON.stringify(testRequest), body: JSON.stringify(testRequest),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const result = await parse_api_response<ConditionTestResponse>(json) const result = await parse_api_response<ConditionTestResponse>(json);
lastError.value = null lastError.value = null;
return result return result;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* useConditions composable * useConditions composable
@ -434,4 +427,4 @@ export const useConditions = () => ({
testCondition, testCondition,
clearError, clearError,
throwInstead, throwInstead,
}) });

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 confirm = async (msg: string, opts: ConfirmOptions = {}) => {
const { status } = await dialog.confirmDialog(Object.assign({ const { status } = await dialog.confirmDialog(
title: 'Please Confirm', Object.assign(
message: msg, {
cancelText: 'Cancel', title: 'Please Confirm',
confirmText: 'OK', message: msg,
} as ConfirmOptions, opts || {})) cancelText: 'Cancel',
confirmText: 'OK',
} as ConfirmOptions,
opts || {},
),
);
return status return status;
} };
const alert = async (msg: string, opts: AlertOptions = {}) => { const alert = async (msg: string, opts: AlertOptions = {}) => {
const { status } = await dialog.alertDialog(Object.assign({ const { status } = await dialog.alertDialog(
title: 'Alert', Object.assign(
message: msg, {
confirmText: 'OK', title: 'Alert',
} as AlertOptions, opts || {})) message: msg,
return status confirmText: 'OK',
} } as AlertOptions,
opts || {},
),
);
return status;
};
const prompt = async (msg: string, opts: PromptOptions = {}) => { const prompt = async (msg: string, opts: PromptOptions = {}) => {
const { status, value } = await dialog.promptDialog(Object.assign({ message: msg } as PromptOptions, opts || {})) const { status, value } = await dialog.promptDialog(
Object.assign({ message: msg } as PromptOptions, opts || {}),
);
if (status) { if (status) {
return value return value;
} }
return null return null;
} };
export const useConfirm = () => ({ confirm, alert, prompt }) export const useConfirm = () => ({ confirm, alert, prompt });

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 = { type BaseOptions = {
/** /**
* Title of the dialog * Title of the dialog
*/ */
title?: string title?: string;
/** /**
* Message to display in the dialog * Message to display in the dialog
*/ */
message?: string message?: string;
/** /**
* Text for the confirm button * Text for the confirm button
*/ */
confirmText?: string confirmText?: string;
/** /**
* Color class for the confirm button (e.g., 'is-primary', 'is-danger') * Color class for the confirm button (e.g., 'is-primary', 'is-danger')
*/ */
confirmColor?: string confirmColor?: string;
} };
export type PromptOptions = BaseOptions & { export type PromptOptions = BaseOptions & {
/** /**
* Text for the input field * Text for the input field
*/ */
initial?: string initial?: string;
/** /**
* Placeholder text for the input field * Placeholder text for the input field
*/ */
placeholder?: string placeholder?: string;
/** /**
* Text for the cancel button * Text for the cancel button
*/ */
cancelText?: string cancelText?: string;
/** /**
* Function to validate the input value * Function to validate the input value
* @returns true if valid, or an error message string if invalid * @returns true if valid, or an error message string if invalid
*/ */
validate?: (v: string) => true | string validate?: (v: string) => true | string;
} };
export type ConfirmOptions = BaseOptions & { export type ConfirmOptions = BaseOptions & {
/** /**
* Text for the confirm button * Text for the confirm button
*/ */
cancelText?: string cancelText?: string;
/** /**
* Raw HTML content to include in the dialog message. * Raw HTML content to include in the dialog message.
*/ */
rawHTML?: string rawHTML?: string;
} };
export type AlertOptions = BaseOptions & {} export type AlertOptions = BaseOptions & {};
type QueueItem = { type QueueItem = {
type: 'prompt' | 'confirm' | 'alert' type: 'prompt' | 'confirm' | 'alert';
opts: PromptOptions | ConfirmOptions | AlertOptions opts: PromptOptions | ConfirmOptions | AlertOptions;
resolve: (r: DialogResult<any>) => void resolve: (r: DialogResult<any>) => void;
} };
type DialogState = { type DialogState = {
current: QueueItem | null current: QueueItem | null;
queue: QueueItem[] queue: QueueItem[];
errorMsg: string | null errorMsg: string | null;
input: string input: string;
} };
export const useDialog = () => { export const useDialog = () => {
const raw = useState<DialogState>('dialog:state', () => reactive({ const raw = useState<DialogState>('dialog:state', () =>
current: null, reactive({
queue: [], current: null,
errorMsg: null, queue: [],
input: '', errorMsg: null,
} as DialogState)) input: '',
} as DialogState),
);
const state = raw.value const state = raw.value;
const _dequeue = () => { const _dequeue = () => {
if (!state.current && state.queue.length) { if (!state.current && state.queue.length) {
state.current = state.queue.shift()! state.current = state.queue.shift()!;
state.errorMsg = null state.errorMsg = null;
state.input = state.current.type === 'prompt' ? (state.current.opts as PromptOptions).initial ?? '' : '' state.input =
state.current.type === 'prompt'
? ((state.current.opts as PromptOptions).initial ?? '')
: '';
} }
} };
const promptDialog = (opts: PromptOptions) => new Promise<DialogResult<string>>((resolve) => { const promptDialog = (opts: PromptOptions) =>
state.queue.push({ type: 'prompt', opts, resolve }) new Promise<DialogResult<string>>((resolve) => {
_dequeue() state.queue.push({ type: 'prompt', opts, resolve });
}) _dequeue();
});
const confirmDialog = (opts: ConfirmOptions) => new Promise<DialogResult<null>>((resolve) => { const confirmDialog = (opts: ConfirmOptions) =>
state.queue.push({ type: 'confirm', opts, resolve }) new Promise<DialogResult<null>>((resolve) => {
_dequeue() state.queue.push({ type: 'confirm', opts, resolve });
}) _dequeue();
});
const alertDialog = (opts: AlertOptions) => new Promise<DialogResult<null>>((resolve) => { const alertDialog = (opts: AlertOptions) =>
state.queue.push({ type: 'alert', opts, resolve }) new Promise<DialogResult<null>>((resolve) => {
_dequeue() state.queue.push({ type: 'alert', opts, resolve });
}) _dequeue();
});
const confirm = (value?: string) => { const confirm = (value?: string) => {
if (!state.current) return if (!state.current) return;
if (state.current.type === 'prompt') { if (state.current.type === 'prompt') {
const val = value ?? state.input const val = value ?? state.input;
const v = (state.current.opts as PromptOptions).validate?.(val) const v = (state.current.opts as PromptOptions).validate?.(val);
if (v && v !== true) { if (v && v !== true) {
state.errorMsg = v state.errorMsg = v;
return return;
} }
state.current.resolve({ status: true, value: val }) state.current.resolve({ status: true, value: val });
} else { } else {
state.current.resolve({ status: true, value: null }) state.current.resolve({ status: true, value: null });
} }
state.current = null state.current = null;
_dequeue() _dequeue();
} };
const cancel = () => { const cancel = () => {
if (!state.current) return if (!state.current) return;
state.current.resolve({ status: false, value: null }) state.current.resolve({ status: false, value: null });
state.current = null state.current = null;
_dequeue() _dequeue();
} };
return { return {
promptDialog, promptDialog,
@ -131,5 +139,5 @@ export const useDialog = () => {
confirm, confirm,
cancel, cancel,
state: readonly(state) as Readonly<DialogState>, state: readonly(state) as Readonly<DialogState>,
} };
} };

View file

@ -1,14 +1,14 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { DLField, DLFieldRequest } from '~/types/dl_fields' import type { DLField, DLFieldRequest } from '~/types/dl_fields';
import type { APIResponse, Pagination } from '~/types/responses' import type { APIResponse, Pagination } from '~/types/responses';
/** /**
* List of all dl fields in memory. * List of all dl fields in memory.
*/ */
const dlFields = ref<Array<DLField>>([]) const dlFields = ref<Array<DLField>>([]);
/** /**
* Pagination state for dl fields list. * Pagination state for dl fields list.
*/ */
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false);
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts dl fields by order (ascending), then name (A-Z). * Sorts dl fields by order (ascending), then name (A-Z).
@ -49,12 +49,12 @@ const notify = useNotification()
const sortDlFields = (items: Array<DLField>): Array<DLField> => { const sortDlFields = (items: Array<DLField>): Array<DLField> => {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
if (a.order === b.order) { if (a.order === b.order) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name);
} }
return a.order - b.order return a.order - b.order;
}) });
} };
/** /**
* Safely reads JSON from a Response, returns null on error. * Safely reads JSON from a Response, returns null on error.
@ -63,13 +63,12 @@ const sortDlFields = (items: Array<DLField>): Array<DLField> => {
*/ */
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a dl field in the dlFields list, keeping sort order. * Updates or adds a dl field in the dlFields list, keeping sort order.
@ -102,15 +101,12 @@ const handleError = (error: unknown): void => {
* @param field DLField to update/add * @param field DLField to update/add
*/ */
const updateDlFields = (field: DLField): void => { const updateDlFields = (field: DLField): void => {
const isNew = !dlFields.value.some(item => item.id === field.id) const isNew = !dlFields.value.some((item) => item.id === field.id);
dlFields.value = sortDlFields([ dlFields.value = sortDlFields([...dlFields.value.filter((item) => item.id !== field.id), field]);
...dlFields.value.filter(item => item.id !== field.id),
field,
])
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a dl field from the dlFields list by ID. * Removes a dl field from the dlFields list by ID.
@ -118,12 +114,12 @@ const updateDlFields = (field: DLField): void => {
* @param id DLField ID * @param id DLField ID
*/ */
const removeDlField = (id: number) => { const removeDlField = (id: number) => {
const initialLength = dlFields.value.length const initialLength = dlFields.value.length;
dlFields.value = dlFields.value.filter(item => item.id !== id) dlFields.value = dlFields.value.filter((item) => item.id !== id);
if (dlFields.value.length < initialLength) { if (dlFields.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads all dl fields from the API with pagination support. * Loads all dl fields from the API with pagination support.
@ -131,31 +127,32 @@ const removeDlField = (id: number) => {
* @param page Page number * @param page Page number
* @param perPage Items per page * @param perPage Items per page
*/ */
const loadDlFields = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadDlFields = async (
isLoading.value = true page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
isLoading.value = true;
try { try {
let url = `/api/dl_fields/?page=${page}` let url = `/api/dl_fields/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<DLField>(json) const { items, pagination: paginationData } = await parse_list_response<DLField>(json);
dlFields.value = sortDlFields(items) dlFields.value = sortDlFields(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Fetches a single dl field by ID from the API. * Fetches a single dl field by ID from the API.
@ -164,21 +161,20 @@ const loadDlFields = async (page: number = 1, perPage: number | undefined = unde
*/ */
const getDlField = async (id: number): Promise<DLField | null> => { const getDlField = async (id: number): Promise<DLField | null> => {
try { try {
const response = await request(`/api/dl_fields/${id}`) const response = await request(`/api/dl_fields/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const field = await parse_api_response<DLField>(json) const field = await parse_api_response<DLField>(json);
lastError.value = null lastError.value = null;
return field return field;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new dl field via API. * Creates a new dl field via API.
@ -190,43 +186,41 @@ const createDlField = async (
field: DLFieldRequest, field: DLFieldRequest,
callback?: (response: APIResponse<DLField>) => void, callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => { ): Promise<DLField | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request('/api/dl_fields/', { const response = await request('/api/dl_fields/', {
method: 'POST', method: 'POST',
body: JSON.stringify(field), body: JSON.stringify(field),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const created = await parse_api_response<DLField>(json) const created = await parse_api_response<DLField>(json);
updateDlFields(created) updateDlFields(created);
notify.success('DL field created.') notify.success('DL field created.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Updates an existing dl field via API (PUT - full update). * Updates an existing dl field via API (PUT - full update).
@ -240,46 +234,44 @@ const updateDlField = async (
field: DLField, field: DLField,
callback?: (response: APIResponse<DLField>) => void, callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => { ): Promise<DLField | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (field.id) { if (field.id) {
field.id = undefined field.id = undefined;
} }
const response = await request(`/api/dl_fields/${id}`, { const response = await request(`/api/dl_fields/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(field), body: JSON.stringify(field),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<DLField>(json) const updated = await parse_api_response<DLField>(json);
updateDlFields(updated) updateDlFields(updated);
notify.success(`DL field '${updated.name}' updated.`) notify.success(`DL field '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Partially updates an existing dl field via API (PATCH). * Partially updates an existing dl field via API (PATCH).
@ -293,46 +285,44 @@ const patchDlField = async (
patch: Partial<DLField>, patch: Partial<DLField>,
callback?: (response: APIResponse<DLField>) => void, callback?: (response: APIResponse<DLField>) => void,
): Promise<DLField | null> => { ): Promise<DLField | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (patch.id) { if (patch.id) {
patch.id = undefined patch.id = undefined;
} }
const response = await request(`/api/dl_fields/${id}`, { const response = await request(`/api/dl_fields/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<DLField>(json) const updated = await parse_api_response<DLField>(json);
updateDlFields(updated) updateDlFields(updated);
notify.success(`DL field '${updated.name}' updated.`) notify.success(`DL field '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Deletes a dl field by ID via API. * Deletes a dl field by ID via API.
@ -345,36 +335,35 @@ const deleteDlField = async (
callback?: (response: APIResponse<boolean>) => void, callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' }) const response = await request(`/api/dl_fields/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removeDlField(id) removeDlField(id);
notify.success('DL field deleted.') notify.success('DL field deleted.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: true }) callback({ success: true, error: null, detail: null, data: true });
} }
return true return true;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false }) callback({ success: false, error: errorMessage, detail: error, data: false });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return false return false;
} }
} };
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* useDlFields composable * useDlFields composable
@ -396,4 +385,4 @@ export const useDlFields = () => ({
deleteDlField, deleteDlField,
clearError, clearError,
throwInstead, throwInstead,
}) });

View file

@ -2,8 +2,8 @@
* Vue composable for handling YouTube-style keyboard shortcuts in video player * Vue composable for handling YouTube-style keyboard shortcuts in video player
*/ */
import { ref } from 'vue' import { ref } from 'vue';
import type { Ref } from 'vue' import type { Ref } from 'vue';
import { import {
handlePlayPause, handlePlayPause,
handleRewind, handleRewind,
@ -20,136 +20,135 @@ import {
handleToggleCaptions, handleToggleCaptions,
shouldHandleKeyboardShortcut, shouldHandleKeyboardShortcut,
isModifierKey, isModifierKey,
} from '~/utils/keyboard' } from '~/utils/keyboard';
import type { KeyboardShortcutContext } from '~/types/video' import type { KeyboardShortcutContext } from '~/types/video';
export interface UseKeyboardShortcutsOptions { export interface UseKeyboardShortcutsOptions {
videoElement: Ref<HTMLVideoElement | null | undefined> videoElement: Ref<HTMLVideoElement | null | undefined>;
enabled?: Ref<boolean> enabled?: Ref<boolean>;
closePlayer?: () => void closePlayer?: () => void;
onHelpToggle?: () => void onHelpToggle?: () => void;
} }
export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => { export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
const { videoElement, enabled, closePlayer, onHelpToggle } = options const { videoElement, enabled, closePlayer, onHelpToggle } = options;
const showHelp = ref(false) const showHelp = ref(false);
const handleKeyDown = async (event: KeyboardEvent) => { const handleKeyDown = async (event: KeyboardEvent) => {
// Don't handle if composable is disabled // Don't handle if composable is disabled
if (enabled && !enabled.value) { if (enabled && !enabled.value) {
return return;
} }
// Don't handle if user is typing in an input // Don't handle if user is typing in an input
if (!shouldHandleKeyboardShortcut(event)) { if (!shouldHandleKeyboardShortcut(event)) {
return return;
} }
const video = videoElement.value const video = videoElement.value;
if (!video) { if (!video) {
return return;
} }
// Skip if modifier keys are pressed (except for shortcuts that need them) // Skip if modifier keys are pressed (except for shortcuts that need them)
if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) { if (isModifierKey(event) && !['f', 'p', '?'].includes(event.key.toLowerCase())) {
return return;
} }
const key = event.key.toLowerCase() const key = event.key.toLowerCase();
const ctx: KeyboardShortcutContext = { video } const ctx: KeyboardShortcutContext = { video };
try { try {
switch (key) { switch (key) {
// Play/Pause // Play/Pause
case ' ': case ' ':
case 'k': case 'k':
event.preventDefault() event.preventDefault();
handlePlayPause(ctx) handlePlayPause(ctx);
break break;
// Rewind 10 seconds (J key) // Rewind 10 seconds (J key)
case 'j': case 'j':
event.preventDefault() event.preventDefault();
handleRewind(ctx, 10) handleRewind(ctx, 10);
break break;
// Forward 10 seconds (L key) // Forward 10 seconds (L key)
case 'l': case 'l':
event.preventDefault() event.preventDefault();
handleForward(ctx, 10) handleForward(ctx, 10);
break break;
// Seek backward 5 seconds (left arrow) // Seek backward 5 seconds (left arrow)
case 'arrowleft': case 'arrowleft':
event.preventDefault() event.preventDefault();
handleSeekBackward(ctx, 5) handleSeekBackward(ctx, 5);
break break;
// Seek forward 5 seconds (right arrow) // Seek forward 5 seconds (right arrow)
case 'arrowright': case 'arrowright':
event.preventDefault() event.preventDefault();
handleSeekForward(ctx, 5) handleSeekForward(ctx, 5);
break break;
// Increase volume (up arrow) // Increase volume (up arrow)
case 'arrowup': case 'arrowup':
event.preventDefault() event.preventDefault();
handleVolumeChange(ctx, 0.1) handleVolumeChange(ctx, 0.1);
break break;
// Decrease volume (down arrow) // Decrease volume (down arrow)
case 'arrowdown': case 'arrowdown':
event.preventDefault() event.preventDefault();
handleVolumeChange(ctx, -0.1) handleVolumeChange(ctx, -0.1);
break break;
// Mute/Unmute // Mute/Unmute
case 'm': case 'm':
event.preventDefault() event.preventDefault();
handleMute(ctx) handleMute(ctx);
break break;
// Toggle fullscreen // Toggle fullscreen
case 'f': case 'f':
event.preventDefault() event.preventDefault();
handleFullscreen(video) handleFullscreen(video);
break break;
// Picture-in-Picture // Picture-in-Picture
case 'p': case 'p':
event.preventDefault() event.preventDefault();
await handlePictureInPicture(video) await handlePictureInPicture(video);
break break;
// Toggle captions // Toggle captions
case 'c': case 'c':
event.preventDefault() event.preventDefault();
handleToggleCaptions(video) handleToggleCaptions(video);
break break;
// Frame advance (period key) / Increase playback speed (> or ') // Frame advance (period key) / Increase playback speed (> or ')
case '.': case '.':
case "'": { case "'": {
event.preventDefault() event.preventDefault();
if ('.' === key) { if ('.' === key) {
handleFrameStep(ctx, 'forward') handleFrameStep(ctx, 'forward');
} else { } else {
handlePlaybackSpeedChange(ctx, 0.25) handlePlaybackSpeedChange(ctx, 0.25);
} }
break break;
} }
// Frame rewind (comma key) / Decrease playback speed (< or ;) // Frame rewind (comma key) / Decrease playback speed (< or ;)
case ',': case ',':
case ';': { case ';': {
event.preventDefault() event.preventDefault();
if (',' === key) { if (',' === key) {
handleFrameStep(ctx, 'backward') handleFrameStep(ctx, 'backward');
} else { } else {
handlePlaybackSpeedChange(ctx, -0.25) handlePlaybackSpeedChange(ctx, -0.25);
} }
break break;
} }
// Jump to percentage (0-9 keys) // Jump to percentage (0-9 keys)
@ -163,55 +162,55 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions) => {
case '7': case '7':
case '8': case '8':
case '9': { case '9': {
event.preventDefault() event.preventDefault();
const percent = parseInt(key) * 10 const percent = parseInt(key) * 10;
handleSeekToPercent(ctx, percent) handleSeekToPercent(ctx, percent);
break break;
} }
// Jump to start (Home) // Jump to start (Home)
case 'home': case 'home':
event.preventDefault() event.preventDefault();
video.currentTime = 0 video.currentTime = 0;
break break;
// Jump to end (End) // Jump to end (End)
case 'end': case 'end':
event.preventDefault() event.preventDefault();
video.currentTime = video.duration video.currentTime = video.duration;
break break;
// Show/hide help (Shift+/) // Show/hide help (Shift+/)
case '?': case '?':
case '/': { case '/': {
event.preventDefault() event.preventDefault();
showHelp.value = !showHelp.value showHelp.value = !showHelp.value;
onHelpToggle?.() onHelpToggle?.();
break break;
} }
// Close player (Escape - already handled elsewhere but kept for completeness) // Close player (Escape - already handled elsewhere but kept for completeness)
case 'escape': case 'escape':
event.preventDefault() event.preventDefault();
closePlayer?.() closePlayer?.();
break break;
default: default:
// No action for unrecognized keys // No action for unrecognized keys
break break;
} }
} catch (error) { } catch (error) {
console.error('Error handling keyboard shortcut:', error) console.error('Error handling keyboard shortcut:', error);
} }
} };
const attach = () => { const attach = () => {
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
} };
return { return {
showHelp, showHelp,
attach, attach,
} };
} };

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 { export interface MediaQueryOptions {
/** /**
@ -6,56 +6,58 @@ export interface MediaQueryOptions {
* Example: "(max-width: 640px) or (hover: none)" * Example: "(max-width: 640px) or (hover: none)"
* If provided, takes precedence over maxWidth. * If provided, takes precedence over maxWidth.
*/ */
query?: string query?: string;
/** /**
* Max width in px considered true. * Max width in px considered true.
* Ignored if `query` is provided. Default: 768. * Ignored if `query` is provided. Default: 768.
*/ */
maxWidth?: number maxWidth?: number;
} }
/** /**
* Reactive state of a CSS media query. * Reactive state of a CSS media query.
*/ */
export function useMediaQuery(options: MediaQueryOptions = {}): Readonly<Ref<boolean>> { export function useMediaQuery(options: MediaQueryOptions = {}): Readonly<Ref<boolean>> {
const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)` const query = options.query ?? `(max-width: ${options.maxWidth ?? 1024}px)`;
const matches = ref(false) const matches = ref(false);
let mql: MediaQueryList | null = null let mql: MediaQueryList | null = null;
let onChange: ((ev: MediaQueryListEvent) => void) | null = null let onChange: ((ev: MediaQueryListEvent) => void) | null = null;
const setup = () => { const setup = () => {
if ('undefined' === typeof window || !window.matchMedia) { if ('undefined' === typeof window || !window.matchMedia) {
return return;
} }
mql = window.matchMedia(query) mql = window.matchMedia(query);
matches.value = mql.matches matches.value = mql.matches;
onChange = ev => { matches.value = ev.matches } onChange = (ev) => {
matches.value = ev.matches;
};
if ('addEventListener' in mql) { if ('addEventListener' in mql) {
mql.addEventListener('change', onChange as EventListener) mql.addEventListener('change', onChange as EventListener);
return return;
} }
// @ts-expect-error legacy Safari // @ts-expect-error legacy Safari
mql.addListener(onChange) mql.addListener(onChange);
} };
const teardown = () => { const teardown = () => {
if (!mql || !onChange) return if (!mql || !onChange) return;
if ('removeEventListener' in mql) { if ('removeEventListener' in mql) {
mql.removeEventListener('change', onChange as EventListener) mql.removeEventListener('change', onChange as EventListener);
} else { } else {
// @ts-expect-error legacy Safari // @ts-expect-error legacy Safari
mql.removeListener(onChange) mql.removeListener(onChange);
} }
mql = null mql = null;
onChange = null onChange = null;
} };
onMounted(setup) onMounted(setup);
onBeforeUnmount(teardown) onBeforeUnmount(teardown);
return shallowReadonly(matches) return shallowReadonly(matches);
} }

View file

@ -1,75 +1,83 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { POSITION, useToast } from "vue-toastification" import { POSITION, useToast } from 'vue-toastification';
export type notificationType = 'info' | 'success' | 'warning' | 'error' export type notificationType = 'info' | 'success' | 'warning' | 'error';
export type notificationTarget = 'toast' | 'browser' export type notificationTarget = 'toast' | 'browser';
export interface notification { export interface notification {
id: string; id: string;
message: string message: string;
level: notificationType level: notificationType;
seen: boolean seen: boolean;
created: Date created: Date;
};
export interface notificationOptions {
timeout?: number,
force?: boolean,
closeOnClick?: boolean,
position?: POSITION
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
onClick?: (closeToast: Function) => void
store?: boolean,
lowPriority?: boolean
} }
const allowToast = useStorage<boolean>('allow_toasts', true) export interface notificationOptions {
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT) timeout?: number;
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true) force?: boolean;
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast') closeOnClick?: boolean;
const toast = useToast() position?: POSITION;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
onClick?: (closeToast: Function) => void;
store?: boolean;
lowPriority?: boolean;
}
const allowToast = useStorage<boolean>('allow_toasts', true);
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT);
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true);
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast');
const toast = useToast();
const requestBrowserPermission = async (): Promise<NotificationPermission> => { const requestBrowserPermission = async (): Promise<NotificationPermission> => {
if (!('Notification' in window)) { if (!('Notification' in window)) {
return 'denied' return 'denied';
} }
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
return 'granted' return 'granted';
} }
if (Notification.permission !== 'denied') { if (Notification.permission !== 'denied') {
return await Notification.requestPermission() return await Notification.requestPermission();
} }
return Notification.permission return Notification.permission;
} };
const sendMessage = (type: notificationType, id: string, message: string, opts?: notificationOptions): void => { const sendMessage = (
const notificationStore = useNotificationStore() type: notificationType,
id: string,
message: string,
opts?: notificationOptions,
): void => {
const notificationStore = useNotificationStore();
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value || const useToastNotification =
!('Notification' in window) || 'granted' !== Notification.permission; !window.isSecureContext ||
'toast' === toastTarget.value ||
!('Notification' in window) ||
'granted' !== Notification.permission;
if (useToastNotification) { if (useToastNotification) {
switch (type) { switch (type) {
case 'info': case 'info':
toast.info(message, opts) toast.info(message, opts);
break; break;
case 'success': case 'success':
toast.success(message, opts) toast.success(message, opts);
break; break;
case 'warning': case 'warning':
toast.warning(message, opts) toast.warning(message, opts);
break; break;
case 'error': case 'error':
toast.error(message, opts) toast.error(message, opts);
break; break;
default: default:
toast.error(`Unknown notification type: ${type}. ${message}`, opts) toast.error(`Unknown notification type: ${type}. ${message}`, opts);
break; break;
} }
return return;
} }
const notification = new Notification('YTPTube', { const notification = new Notification('YTPTube', {
@ -77,32 +85,32 @@ const sendMessage = (type: notificationType, id: string, message: string, opts?:
icon: '/favicon.ico', icon: '/favicon.ico',
badge: '/favicon.ico', badge: '/favicon.ico',
tag: `ytptube-${type}`, tag: `ytptube-${type}`,
silent: false silent: false,
}) });
setTimeout(() => notification.close(), 5000) setTimeout(() => notification.close(), 5000);
notification.onclick = () => { notification.onclick = () => {
notificationStore.markRead(id) notificationStore.markRead(id);
window.focus() window.focus();
notification.close() notification.close();
} };
} };
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => { const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore();
if (!opts) { if (!opts) {
opts = {} opts = {};
} }
let id: string = '' let id: string = '';
const force = opts?.force || false; const force = opts?.force || false;
const store = opts?.store || true; const store = opts?.store || true;
const lowPriority = opts?.lowPriority || false; const lowPriority = opts?.lowPriority || false;
if (notificationStore && (store || true === lowPriority)) { if (notificationStore && (store || true === lowPriority)) {
id = notificationStore.add(type, message, false) id = notificationStore.add(type, message, false);
} }
if (true === lowPriority || (false === allowToast.value && false === force)) { if (true === lowPriority || (false === allowToast.value && false === force)) {
@ -113,20 +121,20 @@ const notify = (type: notificationType, message: string, opts?: notificationOpti
return; return;
} }
opts.closeOnClick = toastDismissOnClick.value opts.closeOnClick = toastDismissOnClick.value;
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT opts.position = toastPosition.value ?? POSITION.TOP_RIGHT;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
opts.onClick = (closeToast: Function) => { opts.onClick = (closeToast: Function) => {
if (opts?.closeOnClick !== false) { if (opts?.closeOnClick !== false) {
closeToast() closeToast();
} }
if (notificationStore) { if (notificationStore) {
notificationStore.markRead(id) notificationStore.markRead(id);
} }
} };
sendMessage(type, id, message, opts) sendMessage(type, id, message, opts);
} };
export const useNotification = () => { export const useNotification = () => {
return { return {
@ -136,5 +144,5 @@ export const useNotification = () => {
error: (message: string, opts?: notificationOptions) => notify('error', message, opts), error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
notify, notify,
requestBrowserPermission, requestBrowserPermission,
} };
} };

View file

@ -1,14 +1,14 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { notification } from '~/types/notification' import type { notification } from '~/types/notification';
import type { APIResponse, Pagination } from '~/types/responses' import type { APIResponse, Pagination } from '~/types/responses';
/** /**
* List of all notifications in memory. * List of all notifications in memory.
*/ */
const notifications = ref<Array<notification>>([]) const notifications = ref<Array<notification>>([]);
/** /**
* Pagination state for notifications list. * Pagination state for notifications list.
*/ */
@ -19,31 +19,31 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* List of allowed notification events. * List of allowed notification events.
*/ */
const events = ref<Array<string>>([]) const events = ref<Array<string>>([]);
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false);
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts notifications by name (A-Z). * Sorts notifications by name (A-Z).
@ -51,8 +51,8 @@ const notify = useNotification()
* @returns Sorted array of notifications * @returns Sorted array of notifications
*/ */
const sortNotifications = (items: Array<notification>): Array<notification> => { const sortNotifications = (items: Array<notification>): Array<notification> => {
return [...items].sort((a, b) => a.name.localeCompare(b.name)) return [...items].sort((a, b) => a.name.localeCompare(b.name));
} };
/** /**
* Safely reads JSON from a Response, returns null on error. * Safely reads JSON from a Response, returns null on error.
@ -61,13 +61,12 @@ const sortNotifications = (items: Array<notification>): Array<notification> => {
*/ */
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -76,23 +75,23 @@ const readJson = async (response: Response): Promise<unknown> => {
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a notification in the list, keeping sort order. * Updates or adds a notification in the list, keeping sort order.
@ -100,15 +99,15 @@ const handleError = (error: unknown): void => {
* @param item Notification to update/add * @param item Notification to update/add
*/ */
const updateNotifications = (item: notification): void => { const updateNotifications = (item: notification): void => {
const isNew = !notifications.value.some(existing => existing.id === item.id) const isNew = !notifications.value.some((existing) => existing.id === item.id);
notifications.value = sortNotifications([ notifications.value = sortNotifications([
...notifications.value.filter(existing => existing.id !== item.id), ...notifications.value.filter((existing) => existing.id !== item.id),
item, item,
]) ]);
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a notification from the list by ID. * Removes a notification from the list by ID.
@ -116,67 +115,67 @@ const updateNotifications = (item: notification): void => {
* @param id Notification ID * @param id Notification ID
*/ */
const removeNotification = (id: number) => { const removeNotification = (id: number) => {
const initialLength = notifications.value.length const initialLength = notifications.value.length;
notifications.value = notifications.value.filter(item => item.id !== id) notifications.value = notifications.value.filter((item) => item.id !== id);
if (notifications.value.length < initialLength) { if (notifications.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads notification targets from the API with pagination support. * Loads notification targets from the API with pagination support.
* @param page Page number * @param page Page number
* @param perPage Items per page * @param perPage Items per page
*/ */
const loadNotifications = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadNotifications = async (
await loadNotificationEvents() page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
await loadNotificationEvents();
isLoading.value = true isLoading.value = true;
try { try {
let url = `/api/notifications/?page=${page}` let url = `/api/notifications/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<notification>(json) const { items, pagination: paginationData } = await parse_list_response<notification>(json);
notifications.value = sortNotifications(items) notifications.value = sortNotifications(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Loads notification events from the API. * Loads notification events from the API.
*/ */
const loadNotificationEvents = async (): Promise<void> => { const loadNotificationEvents = async (): Promise<void> => {
if (events.value.length > 0) { if (events.value.length > 0) {
return return;
} }
try { try {
const response = await request('/api/notifications/events/') const response = await request('/api/notifications/events/');
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
events.value = Array.isArray(json?.events) ? json.events : [] events.value = Array.isArray(json?.events) ? json.events : [];
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
}
/** /**
* Fetches a single notification by ID from the API. * Fetches a single notification by ID from the API.
@ -185,21 +184,20 @@ const loadNotificationEvents = async (): Promise<void> => {
*/ */
const getNotification = async (id: number): Promise<notification | null> => { const getNotification = async (id: number): Promise<notification | null> => {
try { try {
const response = await request(`/api/notifications/${id}`) const response = await request(`/api/notifications/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const item = await parse_api_response<notification>(json) const item = await parse_api_response<notification>(json);
lastError.value = null lastError.value = null;
return item return item;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new notification via API. * Creates a new notification via API.
@ -211,42 +209,40 @@ const createNotification = async (
item: Omit<notification, 'id'>, item: Omit<notification, 'id'>,
callback?: (response: APIResponse<notification>) => void, callback?: (response: APIResponse<notification>) => void,
): Promise<notification | null> => { ): Promise<notification | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request('/api/notifications/', { const response = await request('/api/notifications/', {
method: 'POST', method: 'POST',
body: JSON.stringify(item), body: JSON.stringify(item),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const created = await parse_api_response<notification>(json) const created = await parse_api_response<notification>(json);
updateNotifications(created) updateNotifications(created);
notify.success('Notification target created.') notify.success('Notification target created.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Updates an existing notification via API (PUT - full update). * Updates an existing notification via API (PUT - full update).
@ -260,45 +256,43 @@ const updateNotification = async (
item: notification, item: notification,
callback?: (response: APIResponse<notification>) => void, callback?: (response: APIResponse<notification>) => void,
): Promise<notification | null> => { ): Promise<notification | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (item.id) { if (item.id) {
item.id = undefined item.id = undefined;
} }
const response = await request(`/api/notifications/${id}`, { const response = await request(`/api/notifications/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(item), body: JSON.stringify(item),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<notification>(json) const updated = await parse_api_response<notification>(json);
updateNotifications(updated) updateNotifications(updated);
notify.success(`Notification target '${updated.name}' updated.`) notify.success(`Notification target '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Partially updates an existing notification via API (PATCH). * Partially updates an existing notification via API (PATCH).
@ -312,45 +306,43 @@ const patchNotification = async (
patch: Partial<notification>, patch: Partial<notification>,
callback?: (response: APIResponse<notification>) => void, callback?: (response: APIResponse<notification>) => void,
): Promise<notification | null> => { ): Promise<notification | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
if (patch.id) { if (patch.id) {
patch.id = undefined patch.id = undefined;
} }
const response = await request(`/api/notifications/${id}`, { const response = await request(`/api/notifications/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<notification>(json) const updated = await parse_api_response<notification>(json);
updateNotifications(updated) updateNotifications(updated);
notify.success(`Notification target '${updated.name}' updated.`) notify.success(`Notification target '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Deletes a notification by ID via API. * Deletes a notification by ID via API.
@ -363,43 +355,42 @@ const deleteNotification = async (
callback?: (response: APIResponse<boolean>) => void, callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request(`/api/notifications/${id}`, { method: 'DELETE' }) const response = await request(`/api/notifications/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removeNotification(id) removeNotification(id);
notify.success('Notification target deleted.') notify.success('Notification target deleted.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: true }) callback({ success: true, error: null, detail: null, data: true });
} }
return true return true;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false }) callback({ success: false, error: errorMessage, detail: error, data: false });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return false return false;
} }
} };
/** /**
* Determines if a notification URL is for Apprise (non-HTTP). * Determines if a notification URL is for Apprise (non-HTTP).
* @param url Notification URL * @param url Notification URL
* @returns true if Apprise URL, false otherwise * @returns true if Apprise URL, false otherwise
*/ */
const isApprise = (url: string) => !url.startsWith('http') const isApprise = (url: string) => !url.startsWith('http');
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* useNotifications composable * useNotifications composable
@ -423,5 +414,5 @@ export const useNotifications = () => ({
deleteNotification, deleteNotification,
clearError, clearError,
throwInstead, throwInstead,
isApprise isApprise,
}) });

View file

@ -1,14 +1,14 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { Preset, PresetRequest } from '~/types/presets' import type { Preset, PresetRequest } from '~/types/presets';
import type { APIResponse, Pagination } from '~/types/responses' import type { APIResponse, Pagination } from '~/types/responses';
/** /**
* List of all presets in memory. * List of all presets in memory.
*/ */
const presets = ref<Array<Preset>>([]) const presets = ref<Array<Preset>>([]);
/** /**
* Pagination state for presets list. * Pagination state for presets list.
*/ */
@ -19,27 +19,27 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false);
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts presets by priority (descending), then name (A-Z). * Sorts presets by priority (descending), then name (A-Z).
@ -49,12 +49,12 @@ const notify = useNotification()
const sortPresets = (items: Array<Preset>): Array<Preset> => { const sortPresets = (items: Array<Preset>): Array<Preset> => {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
if (a.priority === b.priority) { if (a.priority === b.priority) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name);
} }
return b.priority - a.priority return b.priority - a.priority;
}) });
} };
/** /**
* Safely reads JSON from a Response, returns null on error. * Safely reads JSON from a Response, returns null on error.
@ -63,13 +63,12 @@ const sortPresets = (items: Array<Preset>): Array<Preset> => {
*/ */
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -78,23 +77,23 @@ const readJson = async (response: Response): Promise<unknown> => {
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a preset in the presets list, keeping sort order. * Updates or adds a preset in the presets list, keeping sort order.
@ -102,15 +101,12 @@ const handleError = (error: unknown): void => {
* @param preset Preset to update/add * @param preset Preset to update/add
*/ */
const updatePresets = (preset: Preset): void => { const updatePresets = (preset: Preset): void => {
const isNew = !presets.value.some(item => item.id === preset.id) const isNew = !presets.value.some((item) => item.id === preset.id);
presets.value = sortPresets([ presets.value = sortPresets([...presets.value.filter((item) => item.id !== preset.id), preset]);
...presets.value.filter(item => item.id !== preset.id),
preset,
])
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a preset from the presets list by ID. * Removes a preset from the presets list by ID.
@ -118,12 +114,12 @@ const updatePresets = (preset: Preset): void => {
* @param id Preset ID * @param id Preset ID
*/ */
const removePreset = (id: number) => { const removePreset = (id: number) => {
const initialLength = presets.value.length const initialLength = presets.value.length;
presets.value = presets.value.filter(item => item.id !== id) presets.value = presets.value.filter((item) => item.id !== id);
if (presets.value.length < initialLength) { if (presets.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads all presets from the API with pagination support. * Loads all presets from the API with pagination support.
@ -131,31 +127,32 @@ const removePreset = (id: number) => {
* @param page Page number * @param page Page number
* @param perPage Items per page * @param perPage Items per page
*/ */
const loadPresets = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadPresets = async (
isLoading.value = true page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
isLoading.value = true;
try { try {
let url = `/api/presets/?page=${page}` let url = `/api/presets/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<Preset>(json) const { items, pagination: paginationData } = await parse_list_response<Preset>(json);
presets.value = sortPresets(items) presets.value = sortPresets(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Fetches a single preset by ID from the API. * Fetches a single preset by ID from the API.
@ -164,21 +161,20 @@ const loadPresets = async (page: number = 1, perPage: number | undefined = undef
*/ */
const getPreset = async (id: number): Promise<Preset | null> => { const getPreset = async (id: number): Promise<Preset | null> => {
try { try {
const response = await request(`/api/presets/${id}`) const response = await request(`/api/presets/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const preset = await parse_api_response<Preset>(json) const preset = await parse_api_response<Preset>(json);
lastError.value = null lastError.value = null;
return preset return preset;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new preset via API. * Creates a new preset via API.
@ -190,43 +186,41 @@ const createPreset = async (
preset: PresetRequest, preset: PresetRequest,
callback?: (response: APIResponse<Preset>) => void, callback?: (response: APIResponse<Preset>) => void,
): Promise<Preset | null> => { ): Promise<Preset | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request('/api/presets/', { const response = await request('/api/presets/', {
method: 'POST', method: 'POST',
body: JSON.stringify(preset), body: JSON.stringify(preset),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const created = await parse_api_response<Preset>(json) const created = await parse_api_response<Preset>(json);
updatePresets(created) updatePresets(created);
notify.success('Preset created.') notify.success('Preset created.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Updates an existing preset via API (PUT - full update). * Updates an existing preset via API (PUT - full update).
@ -240,50 +234,48 @@ const updatePreset = async (
preset: Preset, preset: Preset,
callback?: (response: APIResponse<Preset>) => void, callback?: (response: APIResponse<Preset>) => void,
): Promise<Preset | null> => { ): Promise<Preset | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const payload = { ...preset } const payload = { ...preset };
if (payload.id) { if (payload.id) {
payload.id = undefined payload.id = undefined;
} }
if ('default' in payload) { if ('default' in payload) {
payload.default = false payload.default = false;
} }
const response = await request(`/api/presets/${id}`, { const response = await request(`/api/presets/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Preset>(json) const updated = await parse_api_response<Preset>(json);
updatePresets(updated) updatePresets(updated);
notify.success(`Preset '${updated.name}' updated.`) notify.success(`Preset '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Partially updates an existing preset via API (PATCH). * Partially updates an existing preset via API (PATCH).
@ -297,50 +289,48 @@ const patchPreset = async (
patch: Partial<Preset>, patch: Partial<Preset>,
callback?: (response: APIResponse<Preset>) => void, callback?: (response: APIResponse<Preset>) => void,
): Promise<Preset | null> => { ): Promise<Preset | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const payload = { ...patch } const payload = { ...patch };
if (payload.id) { if (payload.id) {
payload.id = undefined payload.id = undefined;
} }
if ('default' in payload) { if ('default' in payload) {
payload.default = false payload.default = false;
} }
const response = await request(`/api/presets/${id}`, { const response = await request(`/api/presets/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Preset>(json) const updated = await parse_api_response<Preset>(json);
updatePresets(updated) updatePresets(updated);
notify.success(`Preset '${updated.name}' updated.`) notify.success(`Preset '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Deletes a preset by ID via API. * Deletes a preset by ID via API.
@ -353,36 +343,35 @@ const deletePreset = async (
callback?: (response: APIResponse<boolean>) => void, callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request(`/api/presets/${id}`, { method: 'DELETE' }) const response = await request(`/api/presets/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removePreset(id) removePreset(id);
notify.success('Preset deleted.') notify.success('Preset deleted.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: true }) callback({ success: true, error: null, detail: null, data: true });
} }
return true return true;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false }) callback({ success: false, error: errorMessage, detail: error, data: false });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return false return false;
} }
} };
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* usePresets composable * usePresets composable
@ -404,4 +393,4 @@ export const usePresets = () => ({
deletePreset, deletePreset,
clearError, clearError,
throwInstead, throwInstead,
}) });

View file

@ -1,18 +1,18 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { import type {
TaskDefinitionDetailed, TaskDefinitionDetailed,
TaskDefinitionDocument, TaskDefinitionDocument,
TaskDefinitionSummary, TaskDefinitionSummary,
} from '~/types/task_definitions' } from '~/types/task_definitions';
import type { Pagination } from '~/types/responses' import type { Pagination } from '~/types/responses';
/** /**
* Reactive list of all task definition summaries, sorted by priority and name. * Reactive list of all task definition summaries, sorted by priority and name.
*/ */
const definitions = ref<Array<TaskDefinitionSummary>>([]) const definitions = ref<Array<TaskDefinitionSummary>>([]);
/** /**
* Pagination state for task definitions list. * Pagination state for task definitions list.
*/ */
@ -23,25 +23,25 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts task definition summaries by priority (ascending), then name (A-Z). * Sorts task definition summaries by priority (ascending), then name (A-Z).
@ -51,12 +51,12 @@ const notify = useNotification()
const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => { const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
if (a.priority === b.priority) { if (a.priority === b.priority) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name);
} }
return a.priority - b.priority return a.priority - b.priority;
}) });
} };
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -65,80 +65,85 @@ const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitio
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await response.clone().json().catch(() => null) const payload = await response
const message = await parse_api_error(payload) .clone()
throw new Error(message) .json()
} .catch(() => null);
const message = await parse_api_error(payload);
throw new Error(message);
};
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a summary in the definitions list, keeping sort order. * Updates or adds a summary in the definitions list, keeping sort order.
* @param summary TaskDefinitionSummary to update/add * @param summary TaskDefinitionSummary to update/add
*/ */
const updateSummaries = (summary: TaskDefinitionSummary): void => { const updateSummaries = (summary: TaskDefinitionSummary): void => {
const isNew = !definitions.value.some(item => item.id === summary.id) const isNew = !definitions.value.some((item) => item.id === summary.id);
definitions.value = sortSummaries([ definitions.value = sortSummaries([
...definitions.value.filter(item => item.id !== summary.id), ...definitions.value.filter((item) => item.id !== summary.id),
summary, summary,
]) ]);
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a summary from the definitions list by ID. * Removes a summary from the definitions list by ID.
* @param id Task definition ID * @param id Task definition ID
*/ */
const removeSummary = (id: number) => { const removeSummary = (id: number) => {
const initialLength = definitions.value.length const initialLength = definitions.value.length;
definitions.value = definitions.value.filter(item => item.id !== id) definitions.value = definitions.value.filter((item) => item.id !== id);
if (definitions.value.length < initialLength) { if (definitions.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads all task definition summaries from the API. * Loads all task definition summaries from the API.
* Updates definitions and lastError. * Updates definitions and lastError.
*/ */
const loadDefinitions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadDefinitions = async (
isLoading.value = true page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
isLoading.value = true;
try { try {
let url = `/api/tasks/definitions/?page=${page}` let url = `/api/tasks/definitions/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<TaskDefinitionSummary>(json) const { items, pagination: paginationData } =
await parse_list_response<TaskDefinitionSummary>(json);
definitions.value = sortSummaries(items) definitions.value = sortSummaries(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Fetches a detailed task definition by ID from the API. * Fetches a detailed task definition by ID from the API.
@ -147,36 +152,37 @@ const loadDefinitions = async (page: number = 1, perPage: number | undefined = u
*/ */
const getDefinition = async (id: number): Promise<TaskDefinitionDetailed | null> => { const getDefinition = async (id: number): Promise<TaskDefinitionDetailed | null> => {
try { try {
const response = await request(`/api/tasks/definitions/${id}`) const response = await request(`/api/tasks/definitions/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const payload = await response.json() const payload = await response.json();
const detailed = await parse_api_response<TaskDefinitionDetailed>(payload) const detailed = await parse_api_response<TaskDefinitionDetailed>(payload);
lastError.value = null lastError.value = null;
return detailed return detailed;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new task definition via API. * Creates a new task definition via API.
* @param definition TaskDefinitionDocument to create * @param definition TaskDefinitionDocument to create
* @returns Created TaskDefinitionDetailed or null on error * @returns Created TaskDefinitionDetailed or null on error
*/ */
const createDefinition = async (definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => { const createDefinition = async (
definition: TaskDefinitionDocument,
): Promise<TaskDefinitionDetailed | null> => {
try { try {
const response = await request('/api/tasks/definitions/', { const response = await request('/api/tasks/definitions/', {
method: 'POST', method: 'POST',
body: JSON.stringify(definition), body: JSON.stringify(definition),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json()) const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
updateSummaries({ updateSummaries({
id: payload.id, id: payload.id,
@ -186,18 +192,17 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
enabled: payload.enabled, enabled: payload.enabled,
created_at: payload.created_at, created_at: payload.created_at,
updated_at: payload.updated_at, updated_at: payload.updated_at,
}) });
notify.success('Task definition created.') notify.success('Task definition created.');
lastError.value = null lastError.value = null;
return payload return payload;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Updates an existing task definition via API. * Updates an existing task definition via API.
@ -205,16 +210,19 @@ const createDefinition = async (definition: TaskDefinitionDocument): Promise<Tas
* @param definition Updated TaskDefinitionDocument * @param definition Updated TaskDefinitionDocument
* @returns Updated TaskDefinitionDetailed or null on error * @returns Updated TaskDefinitionDetailed or null on error
*/ */
const updateDefinition = async (id: number, definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => { const updateDefinition = async (
id: number,
definition: TaskDefinitionDocument,
): Promise<TaskDefinitionDetailed | null> => {
try { try {
const response = await request(`/api/tasks/definitions/${id}`, { const response = await request(`/api/tasks/definitions/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(definition), body: JSON.stringify(definition),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json()) const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
updateSummaries({ updateSummaries({
id: payload.id, id: payload.id,
@ -224,18 +232,17 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
enabled: payload.enabled, enabled: payload.enabled,
created_at: payload.created_at, created_at: payload.created_at,
updated_at: payload.updated_at, updated_at: payload.updated_at,
}) });
notify.success('Task definition updated.') notify.success('Task definition updated.');
lastError.value = null lastError.value = null;
return payload return payload;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Deletes a task definition by ID via API. * Deletes a task definition by ID via API.
@ -244,20 +251,19 @@ const updateDefinition = async (id: number, definition: TaskDefinitionDocument):
*/ */
const deleteDefinition = async (id: number): Promise<boolean> => { const deleteDefinition = async (id: number): Promise<boolean> => {
try { try {
const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' }) const response = await request(`/api/tasks/definitions/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removeSummary(id) removeSummary(id);
notify.success('Task definition deleted.') notify.success('Task definition deleted.');
lastError.value = null lastError.value = null;
return true return true;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return false
}
}
/** /**
* Toggles the enabled status of a task definition. * Toggles the enabled status of a task definition.
@ -265,16 +271,19 @@ const deleteDefinition = async (id: number): Promise<boolean> => {
* @param enabled New enabled status * @param enabled New enabled status
* @returns Updated TaskDefinitionDetailed or null on error * @returns Updated TaskDefinitionDetailed or null on error
*/ */
const toggleEnabled = async (id: number, enabled: boolean): Promise<TaskDefinitionDetailed | null> => { const toggleEnabled = async (
id: number,
enabled: boolean,
): Promise<TaskDefinitionDetailed | null> => {
try { try {
const response = await request(`/api/tasks/definitions/${id}`, { const response = await request(`/api/tasks/definitions/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const payload = await parse_api_response<TaskDefinitionDetailed>(response.json()) const payload = await parse_api_response<TaskDefinitionDetailed>(response.json());
updateSummaries({ updateSummaries({
id: payload.id, id: payload.id,
@ -284,23 +293,22 @@ const toggleEnabled = async (id: number, enabled: boolean): Promise<TaskDefiniti
enabled: payload.enabled, enabled: payload.enabled,
created_at: payload.created_at, created_at: payload.created_at,
updated_at: payload.updated_at, updated_at: payload.updated_at,
}) });
notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`) notify.success(`Task definition ${enabled ? 'enabled' : 'disabled'}.`);
lastError.value = null lastError.value = null;
return payload return payload;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* useTaskDefinitions composable * useTaskDefinitions composable
@ -321,6 +329,6 @@ export const useTaskDefinitions = () => ({
toggleEnabled, toggleEnabled,
clearError, clearError,
throwInstead, throwInstead,
}) });
export default useTaskDefinitions export default useTaskDefinitions;

View file

@ -1,19 +1,19 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils';
import type { import type {
Task, Task,
TaskPatch, TaskPatch,
TaskInspectRequest, TaskInspectRequest,
TaskInspectResponse, TaskInspectResponse,
TaskMetadataResponse, TaskMetadataResponse,
} from '~/types/tasks' } from '~/types/tasks';
import type { APIResponse, Pagination } from '~/types/responses' import type { APIResponse, Pagination } from '~/types/responses';
/** /**
* List of all tasks in memory. * List of all tasks in memory.
*/ */
const tasks = ref<Array<Task>>([]) const tasks = ref<Array<Task>>([]);
/** /**
* Pagination state for tasks list. * Pagination state for tasks list.
*/ */
@ -24,31 +24,31 @@ const pagination = ref<Pagination>({
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
}) });
/** /**
* Indicates if a request is in progress. * Indicates if a request is in progress.
*/ */
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
/** /**
* Indicates if an add/update operation is in progress. * Indicates if an add/update operation is in progress.
*/ */
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false);
/** /**
* Set of task IDs that are currently in progress. * Set of task IDs that are currently in progress.
*/ */
const inProgressIds = ref<Set<number>>(new Set()) const inProgressIds = ref<Set<number>>(new Set());
/** /**
* Stores the last error message, if any. * Stores the last error message, if any.
*/ */
const lastError = ref<string | null>(null) const lastError = ref<string | null>(null);
/** /**
* If true, methods will throw errors instead of returning null/false (for testing) * If true, methods will throw errors instead of returning null/false (for testing)
*/ */
const throwInstead = ref(false) const throwInstead = ref(false);
/** /**
* Notification composable for showing success/error messages. * Notification composable for showing success/error messages.
*/ */
const notify = useNotification() const notify = useNotification();
/** /**
* Sorts tasks by name (A-Z). * Sorts tasks by name (A-Z).
@ -56,8 +56,8 @@ const notify = useNotification()
* @returns Sorted array of tasks * @returns Sorted array of tasks
*/ */
const sortTasks = (items: Array<Task>): Array<Task> => { const sortTasks = (items: Array<Task>): Array<Task> => {
return [...items].sort((a, b) => a.name.localeCompare(b.name)) return [...items].sort((a, b) => a.name.localeCompare(b.name));
} };
/** /**
* Safely reads JSON from a Response, returns null on error. * Safely reads JSON from a Response, returns null on error.
@ -66,13 +66,12 @@ const sortTasks = (items: Array<Task>): Array<Task> => {
*/ */
const readJson = async (response: Response): Promise<unknown> => { const readJson = async (response: Response): Promise<unknown> => {
try { try {
const clone = response.clone() const clone = response.clone();
return await clone.json() return await clone.json();
} catch {
return null;
} }
catch { };
return null
}
}
/** /**
* Throws an error if the response is not OK, using API error message if available. * Throws an error if the response is not OK, using API error message if available.
@ -81,23 +80,23 @@ const readJson = async (response: Response): Promise<unknown> => {
*/ */
const ensureSuccess = async (response: Response): Promise<void> => { const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) { if (response.ok) {
return return;
} }
const payload = await readJson(response) const payload = await readJson(response);
const message = await parse_api_error(payload) const message = await parse_api_error(payload);
throw new Error(message) throw new Error(message);
} };
/** /**
* Handles errors by updating lastError and showing a notification. * Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown * @param error Error object or unknown
*/ */
const handleError = (error: unknown): void => { const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.' const message = error instanceof Error ? error.message : 'Unexpected error occurred.';
lastError.value = message lastError.value = message;
notify.error(message) notify.error(message);
} };
/** /**
* Updates or adds a task in the list, keeping sort order. * Updates or adds a task in the list, keeping sort order.
@ -105,15 +104,12 @@ const handleError = (error: unknown): void => {
* @param item Task to update/add * @param item Task to update/add
*/ */
const updateTasksList = (item: Task): void => { const updateTasksList = (item: Task): void => {
const isNew = !tasks.value.some(existing => existing.id === item.id) const isNew = !tasks.value.some((existing) => existing.id === item.id);
tasks.value = sortTasks([ tasks.value = sortTasks([...tasks.value.filter((existing) => existing.id !== item.id), item]);
...tasks.value.filter(existing => existing.id !== item.id),
item,
])
if (isNew) { if (isNew) {
pagination.value.total++ pagination.value.total++;
} }
} };
/** /**
* Removes a task from the list by ID. * Removes a task from the list by ID.
@ -121,43 +117,44 @@ const updateTasksList = (item: Task): void => {
* @param id Task ID * @param id Task ID
*/ */
const removeTask = (id: number) => { const removeTask = (id: number) => {
const initialLength = tasks.value.length const initialLength = tasks.value.length;
tasks.value = tasks.value.filter(item => item.id !== id) tasks.value = tasks.value.filter((item) => item.id !== id);
if (tasks.value.length < initialLength) { if (tasks.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1) pagination.value.total = Math.max(0, pagination.value.total - 1);
} }
} };
/** /**
* Loads tasks from the API with pagination support. * Loads tasks from the API with pagination support.
* @param page Page number * @param page Page number
* @param perPage Items per page * @param perPage Items per page
*/ */
const loadTasks = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => { const loadTasks = async (
isLoading.value = true page: number = 1,
perPage: number | undefined = undefined,
): Promise<void> => {
isLoading.value = true;
try { try {
let url = `/api/tasks/?page=${page}` let url = `/api/tasks/?page=${page}`;
if (perPage !== undefined) { if (perPage !== undefined) {
url += `&per_page=${perPage}` url += `&per_page=${perPage}`;
} }
const response = await request(url) const response = await request(url);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const { items, pagination: paginationData } = await parse_list_response<Task>(json) const { items, pagination: paginationData } = await parse_list_response<Task>(json);
tasks.value = sortTasks(items) tasks.value = sortTasks(items);
pagination.value = paginationData pagination.value = paginationData;
lastError.value = null lastError.value = null;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
} finally {
isLoading.value = false;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/** /**
* Fetches a single task by ID from the API. * Fetches a single task by ID from the API.
@ -166,21 +163,20 @@ const loadTasks = async (page: number = 1, perPage: number | undefined = undefin
*/ */
const getTask = async (id: number): Promise<Task | null> => { const getTask = async (id: number): Promise<Task | null> => {
try { try {
const response = await request(`/api/tasks/${id}`) const response = await request(`/api/tasks/${id}`);
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const task = await parse_api_response<Task>(json) const task = await parse_api_response<Task>(json);
lastError.value = null lastError.value = null;
return task return task;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Creates a new task via API. * Creates a new task via API.
@ -189,57 +185,57 @@ const getTask = async (id: number): Promise<Task | null> => {
* @returns Created task(s) or null on error * @returns Created task(s) or null on error
*/ */
const createTask = async ( const createTask = async (
task: Omit<Task, 'id' | 'created_at' | 'updated_at'> | Omit<Task, 'id' | 'created_at' | 'updated_at'>[], task:
| Omit<Task, 'id' | 'created_at' | 'updated_at'>
| Omit<Task, 'id' | 'created_at' | 'updated_at'>[],
callback?: (response: APIResponse<Task | Task[]>) => void, callback?: (response: APIResponse<Task | Task[]>) => void,
): Promise<Task | Task[] | null> => { ): Promise<Task | Task[] | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request('/api/tasks/', { const response = await request('/api/tasks/', {
method: 'POST', method: 'POST',
body: JSON.stringify(task), body: JSON.stringify(task),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const created = await parse_api_response<Task | Array<Task>>(json) const created = await parse_api_response<Task | Array<Task>>(json);
if (Array.isArray(created)) { if (Array.isArray(created)) {
notify.success(`${created.length} tasks created.`) notify.success(`${created.length} tasks created.`);
created.forEach(t => updateTasksList(t)) created.forEach((t) => updateTasksList(t));
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} }
updateTasksList(created) updateTasksList(created);
notify.success('Task created.') notify.success('Task created.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: created }) callback({ success: true, error: null, detail: null, data: created });
} }
return created return created;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Updates an existing task via API (PUT - full update). * Updates an existing task via API (PUT - full update).
@ -253,45 +249,43 @@ const updateTask = async (
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>, task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
callback?: (response: APIResponse<Task>) => void, callback?: (response: APIResponse<Task>) => void,
): Promise<Task | null> => { ): Promise<Task | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
// Explicitly remove id, created_at, updated_at fields if present // Explicitly remove id, created_at, updated_at fields if present
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task;
const response = await request(`/api/tasks/${id}`, { const response = await request(`/api/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(taskData), body: JSON.stringify(taskData),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Task>(json) const updated = await parse_api_response<Task>(json);
updateTasksList(updated) updateTasksList(updated);
notify.success(`Task '${updated.name}' updated.`) notify.success(`Task '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Partially updates an existing task via API (PATCH). * Partially updates an existing task via API (PATCH).
@ -305,42 +299,40 @@ const patchTask = async (
patch: TaskPatch, patch: TaskPatch,
callback?: (response: APIResponse<Task>) => void, callback?: (response: APIResponse<Task>) => void,
): Promise<Task | null> => { ): Promise<Task | null> => {
addInProgress.value = true addInProgress.value = true;
try { try {
const response = await request(`/api/tasks/${id}`, { const response = await request(`/api/tasks/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const updated = await parse_api_response<Task>(json) const updated = await parse_api_response<Task>(json);
updateTasksList(updated) updateTasksList(updated);
notify.success(`Task '${updated.name}' updated.`) notify.success(`Task '${updated.name}' updated.`);
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: updated }) callback({ success: true, error: null, detail: null, data: updated });
} }
return updated return updated;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined }) callback({ success: false, error: errorMessage, detail: error, data: undefined });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return null return null;
} finally {
addInProgress.value = false;
} }
finally { };
addInProgress.value = false
}
}
/** /**
* Deletes a task by ID via API. * Deletes a task by ID via API.
@ -353,55 +345,55 @@ const deleteTask = async (
callback?: (response: APIResponse<boolean>) => void, callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' }) const response = await request(`/api/tasks/${id}`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
removeTask(id) removeTask(id);
notify.success('Task deleted.') notify.success('Task deleted.');
lastError.value = null lastError.value = null;
if (callback) { if (callback) {
callback({ success: true, error: null, detail: null, data: true }) callback({ success: true, error: null, detail: null, data: true });
} }
return true return true;
} } catch (error) {
catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.';
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' handleError(error);
handleError(error)
if (callback) { if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false }) callback({ success: false, error: errorMessage, detail: error, data: false });
} }
if (throwInstead.value) throw error if (throwInstead.value) throw error;
return false return false;
} }
} };
/** /**
* Inspects a task handler to check if it can process the given URL. * Inspects a task handler to check if it can process the given URL.
* @param request Task inspect request parameters * @param request Task inspect request parameters
* @returns Inspect result or null on error * @returns Inspect result or null on error
*/ */
const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInspectResponse | null> => { const inspectTaskHandler = async (
request: TaskInspectRequest,
): Promise<TaskInspectResponse | null> => {
try { try {
const response = await fetch('/api/tasks/inspect', { const response = await fetch('/api/tasks/inspect', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request), body: JSON.stringify(request),
}) });
const json = await response.json() const json = await response.json();
lastError.value = null lastError.value = null;
return json as TaskInspectResponse return json as TaskInspectResponse;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Marks all items from a task as downloaded in the download archive. * Marks all items from a task as downloaded in the download archive.
@ -410,22 +402,21 @@ const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInsp
*/ */
const markTaskItems = async (id: number): Promise<string | null> => { const markTaskItems = async (id: number): Promise<string | null> => {
try { try {
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' }) const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const message = json.message || 'All items marked as downloaded.' const message = json.message || 'All items marked as downloaded.';
notify.success(message) notify.success(message);
lastError.value = null lastError.value = null;
return message return message;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Removes all items from a task from the download archive. * Removes all items from a task from the download archive.
@ -434,22 +425,21 @@ const markTaskItems = async (id: number): Promise<string | null> => {
*/ */
const unmarkTaskItems = async (id: number): Promise<string | null> => { const unmarkTaskItems = async (id: number): Promise<string | null> => {
try { try {
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' }) const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const message = json.message || 'All items removed from archive.' const message = json.message || 'All items removed from archive.';
notify.success(message) notify.success(message);
lastError.value = null lastError.value = null;
return message return message;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Generates metadata for a task (tvshow.nfo, info.json, thumbnails). * Generates metadata for a task (tvshow.nfo, info.json, thumbnails).
@ -458,56 +448,55 @@ const unmarkTaskItems = async (id: number): Promise<string | null> => {
*/ */
const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => { const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => {
try { try {
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' }) const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' });
await ensureSuccess(response) await ensureSuccess(response);
const json = await response.json() const json = await response.json();
const metadata = await parse_api_response<TaskMetadataResponse>(json) const metadata = await parse_api_response<TaskMetadataResponse>(json);
notify.success('Metadata generation completed.') notify.success('Metadata generation completed.');
lastError.value = null lastError.value = null;
return metadata return metadata;
} catch (error) {
handleError(error);
if (throwInstead.value) throw error;
return null;
} }
catch (error) { };
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/** /**
* Clears the last error message. * Clears the last error message.
*/ */
const clearError = () => lastError.value = null const clearError = () => (lastError.value = null);
/** /**
* Checks if a task is currently in progress. * Checks if a task is currently in progress.
* @param id Task ID * @param id Task ID
* @returns true if the task is in progress * @returns true if the task is in progress
*/ */
const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id) const isTaskInProgress = (id: number): boolean => inProgressIds.value.has(id);
/** /**
* Sets a task as in progress. * Sets a task as in progress.
* @param id Task ID * @param id Task ID
*/ */
const setTaskInProgress = (id: number): void => { const setTaskInProgress = (id: number): void => {
inProgressIds.value.add(id) inProgressIds.value.add(id);
} };
/** /**
* Clears the in progress state for a task. * Clears the in progress state for a task.
* @param id Task ID * @param id Task ID
*/ */
const clearTaskInProgress = (id: number): void => { const clearTaskInProgress = (id: number): void => {
inProgressIds.value.delete(id) inProgressIds.value.delete(id);
} };
/** /**
* Resets all state to initial values (for testing only). * Resets all state to initial values (for testing only).
*/ */
const __resetForTesting = () => { const __resetForTesting = () => {
tasks.value = [] tasks.value = [];
pagination.value = { pagination.value = {
page: 1, page: 1,
per_page: 50, per_page: 50,
@ -515,13 +504,13 @@ const __resetForTesting = () => {
total_pages: 0, total_pages: 0,
has_next: false, has_next: false,
has_prev: false, has_prev: false,
} };
isLoading.value = false isLoading.value = false;
addInProgress.value = false addInProgress.value = false;
lastError.value = null lastError.value = null;
throwInstead.value = false throwInstead.value = false;
inProgressIds.value = new Set() inProgressIds.value = new Set();
} };
/** /**
* useTasks composable * useTasks composable
@ -552,4 +541,4 @@ export const useTasks = () => ({
clearError, clearError,
throwInstead, throwInstead,
__resetForTesting, __resetForTesting,
}) });

View file

@ -4,7 +4,8 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-12"> <div class="column is-12">
<h1 class="title is-4"> <h1 class="title is-4">
{{ error.statusCode }}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span> {{ error.statusCode
}}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span>
</h1> </h1>
</div> </div>
</div> </div>
@ -46,10 +47,10 @@
const props = defineProps({ const props = defineProps({
error: { error: {
type: Object, type: Object,
required: true required: true,
} },
}) });
const showStacks = ref(false) const showStacks = ref(false);
onMounted(() => console.error(props.error)) onMounted(() => console.error(props.error));
</script> </script>

View file

@ -1,12 +1,19 @@
<template> <template>
<template v-if="simpleMode"> <template v-if="simpleMode">
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" /> <Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
<Simple @show_settings="() => show_settings = true" :class="{ 'settings-open': show_settings }" /> <Simple
@show_settings="() => (show_settings = true)"
:class="{ 'settings-open': show_settings }"
/>
</template> </template>
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()" <SettingsPanel
@reload_bg="() => loadImage(true)" direction="right" /> :isOpen="show_settings"
:isLoading="loadingImage"
@close="closeSettings()"
@reload_bg="() => loadImage(true)"
direction="right"
/>
<template v-if="!simpleMode"> <template v-if="!simpleMode">
<Shutdown v-if="app_shutdown" /> <Shutdown v-if="app_shutdown" />
@ -14,17 +21,25 @@
<NewVersion v-if="newVersionIsAvailable" /> <NewVersion v-if="newVersionIsAvailable" />
<nav class="navbar is-mobile is-dark"> <nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5"> <div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)" <NuxtLink
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'" id="top"> class="navbar-item is-text-overflow"
to="/"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'"
id="top"
>
<span class="is-text-overflow"> <span class="is-text-overflow">
<span class="icon"> <span class="icon">
<i v-if="'connecting' === socket.connectionStatus" class="fas fa-arrows-rotate fa-spin" /> <i
v-if="'connecting' === socket.connectionStatus"
class="fas fa-arrows-rotate fa-spin"
/>
<i v-else class="fas fa-home" /> <i v-else class="fas fa-home" />
</span> </span>
<span class="has-text-bold" :class="connectionStatusColor"> <span class="has-text-bold" :class="connectionStatusColor"> YTPTube </span>
YTPTube <span class="has-text-bold" v-if="config?.app?.instance_title"
</span> >: {{ config.app.instance_title }}</span
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span> >
</span> </span>
</NuxtLink> </NuxtLink>
@ -37,12 +52,20 @@
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }"> <div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start"> <div class="navbar-start">
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/browser"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-folder-tree" /></span> <span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span> <span>Files</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/presets"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span> <span>Presets</span>
</NuxtLink> </NuxtLink>
@ -53,30 +76,45 @@
<span>Tasks</span> <span>Tasks</span>
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/tasks"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>List</span> <span>List</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/task_definitions"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-diagram-project" /></span> <span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Definitions</span> <span>Definitions</span>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/notifications"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span> <span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span> <span>Notifications</span>
</span> </span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/conditions"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span> <span>Conditions</span>
</NuxtLink> </NuxtLink>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<div class="navbar-item has-dropdown"> <div class="navbar-item has-dropdown">
@ -86,19 +124,31 @@
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)" <NuxtLink
v-if="config.app?.file_logging"> class="navbar-item"
to="/logs"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app?.file_logging"
>
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span> <span>Logs</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)" <NuxtLink
v-if="config.app.console_enabled"> class="navbar-item"
to="/console"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.console_enabled"
>
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span> <span>Console</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/dl_fields" @click.prevent="(e: MouseEvent) => changeRoute(e)"> <NuxtLink
class="navbar-item"
to="/dl_fields"
@click.prevent="(e: MouseEvent) => changeRoute(e)"
>
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Custom fields</span> <span>Custom fields</span>
</NuxtLink> </NuxtLink>
@ -122,36 +172,47 @@
</div> </div>
<div class="navbar-item"> <div class="navbar-item">
<button class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }" <button
@click="show_settings = !show_settings"> class="button is-dark"
:class="{ 'has-tooltip-bottom mr-4': !isMobile }"
@click="show_settings = !show_settings"
>
<span class="icon"><i class="fas fa-cog" /></span> <span class="icon"><i class="fas fa-cog" /></span>
<span v-if="isMobile">WebUI Settings</span> <span v-if="isMobile">WebUI Settings</span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<div> <div>
<NuxtLoadingIndicator /> <NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" /> <NuxtPage
<Message v-if="!config.is_loaded" class="mt-5" v-if="config.is_loaded"
:isLoading="loadingImage"
@reload_bg="() => loadImage(true)"
/>
<Message
v-if="!config.is_loaded"
class="mt-5"
:class="{ 'is-info': config.is_loading, 'is-danger': !config.is_loading }" :class="{ 'is-info': config.is_loading, 'is-danger': !config.is_loading }"
:title="config.is_loading ? 'Loading configuration...' : 'Failed to load configuration'" :title="config.is_loading ? 'Loading configuration...' : 'Failed to load configuration'"
:icon="config.is_loading ? 'fas fa-spinner fa-spin' : 'fas fa-triangle-exclamation'"> :icon="config.is_loading ? 'fas fa-spinner fa-spin' : 'fas fa-triangle-exclamation'"
>
<p v-if="config.is_loading"> <p v-if="config.is_loading">
This usually takes less than a few seconds. If this is taking too long, This usually takes less than a few seconds. If this is taking too long,
<NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload the <NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload
page. the page.
</p> </p>
<p v-if="!config.is_loading"> <p v-if="!config.is_loading">
Failed to load the application configuration. This likely indicates a problem with the backend. Try to Failed to load the application configuration. This likely indicates a problem with the
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)">reload backend. Try to
configuration</NuxtLink> or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page <NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)"
</NuxtLink>. >reload configuration</NuxtLink
>
or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page </NuxtLink>.
</p> </p>
<template v-if="socket.error"> <template v-if="socket.error">
<hr> <hr />
<p class="has-text-danger"> <p class="has-text-danger">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fas fa-triangle-exclamation" /></span> <span class="icon"><i class="fas fa-triangle-exclamation" /></span>
@ -161,7 +222,7 @@
</p> </p>
</template> </template>
</Message> </Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" /> <Markdown @closeModel="() => (doc.file = '')" :file="doc.file" v-if="doc.file" />
<ClientOnly> <ClientOnly>
<Dialog /> <Dialog />
</ClientOnly> </ClientOnly>
@ -179,15 +240,23 @@
<div class="columns is-multiline is-variable is-8"> <div class="columns is-multiline is-variable is-8">
<div class="column is-12-mobile is-6-tablet"> <div class="column is-12-mobile is-6-tablet">
<div class="mb-3"> <div class="mb-3">
<NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank" <NuxtLink
class="has-text-weight-semibold is-size-6"> href="https://github.com/ArabCoders/ytptube"
target="_blank"
class="has-text-weight-semibold is-size-6"
>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fab fa-github" /></span> <span class="icon"><i class="fab fa-github" /></span>
<span>YTPTube</span> <span>YTPTube</span>
</span> </span>
</NuxtLink> </NuxtLink>
<span class="is-size-7 ml-2 has-tooltip" style="opacity: 0.7" <span
v-tooltip="`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`"> class="is-size-7 ml-2 has-tooltip"
style="opacity: 0.7"
v-tooltip="
`Build: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, SHA: ${config.app?.app_commit_sha}`
"
>
{{ config?.app?.app_version || 'unknown' }} {{ config?.app?.app_version || 'unknown' }}
</span> </span>
</div> </div>
@ -196,20 +265,37 @@
<span class="icon-text"> <span class="icon-text">
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span> <span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
<span>Update available:</span> <span>Update available:</span>
<NuxtLink :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`" <NuxtLink
target="_blank" class="has-text-weight-semibold ml-1"> :href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
target="_blank"
class="has-text-weight-semibold ml-1"
>
{{ config.app.new_version }} {{ config.app.new_version }}
</NuxtLink> </NuxtLink>
</span> </span>
</p> </p>
<p v-else class="is-size-7 mb-2" style="opacity: 0.6"> <p v-else class="is-size-7 mb-2" style="opacity: 0.6">
<button @click="checkForUpdates" :disabled="checkingUpdates" class="is-text is-small p-0 is-size-7" <button
@click="checkForUpdates"
:disabled="checkingUpdates"
class="is-text is-small p-0 is-size-7"
:class="{ 'is-loading': checkingUpdates }" :class="{ 'is-loading': checkingUpdates }"
style="opacity: 0.8; height: auto; vertical-align: baseline; border: none; text-decoration: none; background: none;"> style="
opacity: 0.8;
height: auto;
vertical-align: baseline;
border: none;
text-decoration: none;
background: none;
"
>
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fas" :class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'" /> <i
class="fas"
:class="checkingUpdates ? 'fa-spinner fa-spin' : 'fa-circle-check'"
/>
</span> </span>
<span>{{ updateCheckMessage }}</span> <span>{{ updateCheckMessage }}</span>
</span> </span>
@ -219,8 +305,12 @@
<p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6"> <p v-if="config.app?.started" class="is-size-7 mb-0" style="opacity: 0.6">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fas fa-clock" /></span> <span class="icon"><i class="fas fa-clock" /></span>
<span class="has-tooltip" <span
v-tooltip="'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')"> class="has-tooltip"
v-tooltip="
'Started: ' + moment.unix(config.app?.started).format('YYYY-MM-DD HH:mm Z')
"
>
{{ moment.unix(config.app?.started).fromNow() }} {{ moment.unix(config.app?.started).fromNow() }}
</span> </span>
</span> </span>
@ -228,16 +318,28 @@
</div> </div>
<div class="column is-12-mobile is-3-tablet"> <div class="column is-12-mobile is-3-tablet">
<div class="footer-divider" <div
style="border-left: 1px solid rgba(128,128,128,0.2); border-right: 1px solid rgba(128,128,128,0.2); padding: 0 2rem;"> class="footer-divider"
style="
border-left: 1px solid rgba(128, 128, 128, 0.2);
border-right: 1px solid rgba(128, 128, 128, 0.2);
padding: 0 2rem;
"
>
<p class="is-size-7 mb-2" style="opacity: 0.7">Powered by</p> <p class="is-size-7 mb-2" style="opacity: 0.7">Powered by</p>
<NuxtLink href="https://github.com/yt-dlp/yt-dlp" target="_blank" class="has-text-weight-semibold"> <NuxtLink
href="https://github.com/yt-dlp/yt-dlp"
target="_blank"
class="has-text-weight-semibold"
>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fab fa-github" /></span> <span class="icon"><i class="fab fa-github" /></span>
<span>yt-dlp</span> <span>yt-dlp</span>
</span> </span>
</NuxtLink> </NuxtLink>
<span class="is-size-7 ml-1" style="opacity: 0.6">{{ config?.app?.ytdlp_version || 'unknown' }}</span> <span class="is-size-7 ml-1" style="opacity: 0.6">{{
config?.app?.ytdlp_version || 'unknown'
}}</span>
<p v-if="config.app?.yt_new_version" class="is-size-7 mb-0 mt-2" style="opacity: 0.8"> <p v-if="config.app?.yt_new_version" class="is-size-7 mb-0 mt-2" style="opacity: 0.8">
<span class="icon-text"> <span class="icon-text">
@ -245,8 +347,11 @@
<span class="icon has-text-warning"><i class="fas fa-circle-info" /></span> <span class="icon has-text-warning"><i class="fas fa-circle-info" /></span>
<span>Update available:</span> <span>Update available:</span>
</span> </span>
<NuxtLink :href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`" <NuxtLink
target="_blank" class="has-text-weight-semibold ml-1"> :href="`https://github.com/yt-dlp/yt-dlp/releases/tag/${config.app.yt_new_version}`"
target="_blank"
class="has-text-weight-semibold ml-1"
>
{{ config.app.yt_new_version }} {{ config.app.yt_new_version }}
</NuxtLink> </NuxtLink>
</span> </span>
@ -258,7 +363,8 @@
<p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p> <p class="is-size-7 mb-2" style="opacity: 0.7">Quick Links</p>
<div <div
class="is-flex is-flex-direction-row is-flex-wrap-wrap is-justify-content-flex-start is-justify-content-flex-end-tablet" class="is-flex is-flex-direction-row is-flex-wrap-wrap is-justify-content-flex-start is-justify-content-flex-end-tablet"
style="gap: 0.75rem;"> style="gap: 0.75rem"
>
<NuxtLink to="/changelog" class="is-size-7"> <NuxtLink to="/changelog" class="is-size-7">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fas fa-list" /></span> <span class="icon"><i class="fas fa-list" /></span>
@ -298,370 +404,377 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, readonly } from 'vue' import { ref, onMounted, watch, readonly } from 'vue';
import 'assets/css/bulma.css' import 'assets/css/bulma.css';
import 'assets/css/style.css' import 'assets/css/style.css';
import 'assets/css/all.css' import 'assets/css/all.css';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import moment from 'moment' import moment from 'moment';
import type { YTDLPOption } from '~/types/ytdlp' import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog' import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue' import Dialog from '~/components/Dialog.vue';
import Simple from '~/components/Simple.vue' import Simple from '~/components/Simple.vue';
import Shutdown from '~/components/shutdown.vue' import Shutdown from '~/components/shutdown.vue';
import Markdown from '~/components/Markdown.vue' import Markdown from '~/components/Markdown.vue';
import type { version_check } from '~/types' import type { version_check } from '~/types';
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto');
const socket = useSocketStore() const socket = useSocketStore();
const config = useConfigStore() const config = useConfigStore();
const loadedImage = ref() const loadedImage = ref();
const loadingImage = ref(false) const loadingImage = ref(false);
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true);
const bg_opacity = useStorage('random_bg_opacity', 0.95) const bg_opacity = useStorage('random_bg_opacity', 0.95);
const showMenu = ref(false) const showMenu = ref(false);
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const app_shutdown = ref<boolean>(false) const app_shutdown = ref<boolean>(false);
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false) const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
const show_settings = ref(false) const show_settings = ref(false);
const doc = ref<{ file: string }>({ file: '' }) const doc = ref<{ file: string }>({ file: '' });
const checkingUpdates = ref(false) const checkingUpdates = ref(false);
const updateCheckMessage = ref('Up to date - Click to check') const updateCheckMessage = ref('Up to date - Click to check');
const checkForUpdates = async () => { const checkForUpdates = async () => {
if (checkingUpdates.value) { if (checkingUpdates.value) {
return return;
} }
const msg = 'Up to date - Click to check' const msg = 'Up to date - Click to check';
try { try {
checkingUpdates.value = true checkingUpdates.value = true;
updateCheckMessage.value = 'Checking...' updateCheckMessage.value = 'Checking...';
const response = await fetch('/api/system/check-updates', { method: 'POST' }) const response = await fetch('/api/system/check-updates', { method: 'POST' });
if (!response.ok) { if (!response.ok) {
await response.json() await response.json();
updateCheckMessage.value = 'Check failed' updateCheckMessage.value = 'Check failed';
setTimeout(() => updateCheckMessage.value = msg, 3000) setTimeout(() => (updateCheckMessage.value = msg), 3000);
return return;
} }
const data = await parse_api_response<version_check>(await response.json()) const data = await parse_api_response<version_check>(await response.json());
if ('update_available' === data.app.status) { if ('update_available' === data.app.status) {
config.app.new_version = data.app.new_version config.app.new_version = data.app.new_version;
} }
if (data.ytdlp && 'update_available' === data.ytdlp.status) { if (data.ytdlp && 'update_available' === data.ytdlp.status) {
config.app.yt_new_version = data.ytdlp.new_version config.app.yt_new_version = data.ytdlp.new_version;
} }
// Only show "Update found!" if app has update (button is in app section) // Only show "Update found!" if app has update (button is in app section)
if ('update_available' === data.app.status) { if ('update_available' === data.app.status) {
updateCheckMessage.value = 'Update found!' updateCheckMessage.value = 'Update found!';
} else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) { } else if ('up_to_date' === data.app.status && 'up_to_date' === data.ytdlp?.status) {
updateCheckMessage.value = 'Up to date ✓' updateCheckMessage.value = 'Up to date ✓';
setTimeout(() => updateCheckMessage.value = msg, 3000) setTimeout(() => (updateCheckMessage.value = msg), 3000);
} else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) { } else if ('up_to_date' === data.app.status && 'update_available' === data.ytdlp?.status) {
// App is up to date, but yt-dlp has update (shows in yt-dlp section) // App is up to date, but yt-dlp has update (shows in yt-dlp section)
updateCheckMessage.value = 'Up to date ✓' updateCheckMessage.value = 'Up to date ✓';
setTimeout(() => updateCheckMessage.value = msg, 3000) setTimeout(() => (updateCheckMessage.value = msg), 3000);
} else { } else {
updateCheckMessage.value = 'Check failed' updateCheckMessage.value = 'Check failed';
setTimeout(() => updateCheckMessage.value = msg, 3000) setTimeout(() => (updateCheckMessage.value = msg), 3000);
} }
} catch (e) { } catch (e) {
console.error('Update check failed:', e) console.error('Update check failed:', e);
updateCheckMessage.value = 'Check failed' updateCheckMessage.value = 'Check failed';
setTimeout(() => updateCheckMessage.value = msg, 3000) setTimeout(() => (updateCheckMessage.value = msg), 3000);
} finally { } finally {
checkingUpdates.value = false checkingUpdates.value = false;
} }
} };
const applyPreferredColorScheme = (scheme: string) => { const applyPreferredColorScheme = (scheme: string) => {
if (!scheme || scheme === 'auto') { if (!scheme || scheme === 'auto') {
return return;
} }
for (let s = 0; s < document.styleSheets.length; s++) { for (let s = 0; s < document.styleSheets.length; s++) {
const styleSheet = document.styleSheets[s] const styleSheet = document.styleSheets[s];
if (!styleSheet?.cssRules) { if (!styleSheet?.cssRules) {
continue continue;
} }
let rules: CSSRuleList let rules: CSSRuleList;
try { try {
rules = styleSheet.cssRules rules = styleSheet.cssRules;
} catch (e) { } catch (e) {
// Cross-origin stylesheet // Cross-origin stylesheet
console.debug("Unable to access stylesheet rules:", e) console.debug('Unable to access stylesheet rules:', e);
continue continue;
} }
for (let i = 0; i < rules.length; i++) { for (let i = 0; i < rules.length; i++) {
const rule = rules[i] const rule = rules[i];
if (rule instanceof CSSMediaRule && rule.media.mediaText.includes("prefers-color-scheme")) { if (rule instanceof CSSMediaRule && rule.media.mediaText.includes('prefers-color-scheme')) {
const media = rule.media const media = rule.media;
const safeDelete = (medium: string) => { const safeDelete = (medium: string) => {
if (media.mediaText.includes(medium)) { if (media.mediaText.includes(medium)) {
try { try {
media.deleteMedium(medium) media.deleteMedium(medium);
} catch (e) { } catch (e) {
console.debug(`Failed to delete medium "${medium}"`, e) console.debug(`Failed to delete medium "${medium}"`, e);
} }
} }
} };
try { try {
switch (scheme) { switch (scheme) {
case "light": case 'light':
if (!media.mediaText.includes("original-prefers-color-scheme")) { if (!media.mediaText.includes('original-prefers-color-scheme')) {
media.appendMedium("original-prefers-color-scheme") media.appendMedium('original-prefers-color-scheme');
} }
safeDelete("(prefers-color-scheme: light)") safeDelete('(prefers-color-scheme: light)');
safeDelete("(prefers-color-scheme: dark)") safeDelete('(prefers-color-scheme: dark)');
break break;
case "dark": case 'dark':
if (!media.mediaText.includes("(prefers-color-scheme: light)")) { if (!media.mediaText.includes('(prefers-color-scheme: light)')) {
media.appendMedium("(prefers-color-scheme: light)") media.appendMedium('(prefers-color-scheme: light)');
} }
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) { if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
media.appendMedium("(prefers-color-scheme: dark)") media.appendMedium('(prefers-color-scheme: dark)');
} }
safeDelete("original-prefers-color-scheme") safeDelete('original-prefers-color-scheme');
break break;
default: default:
if (!media.mediaText.includes("(prefers-color-scheme: dark)")) { if (!media.mediaText.includes('(prefers-color-scheme: dark)')) {
media.appendMedium("(prefers-color-scheme: dark)") media.appendMedium('(prefers-color-scheme: dark)');
} }
safeDelete("(prefers-color-scheme: light)") safeDelete('(prefers-color-scheme: light)');
safeDelete("original-prefers-color-scheme") safeDelete('original-prefers-color-scheme');
break break;
} }
} catch (e) { } catch (e) {
console.debug("Error modifying media rule:", e) console.debug('Error modifying media rule:', e);
} }
} }
} }
} }
} };
onMounted(async () => { onMounted(async () => {
try { try {
applyPreferredColorScheme(selectedTheme.value) applyPreferredColorScheme(selectedTheme.value);
} catch { } } catch {}
try { try {
await config.loadConfig() await config.loadConfig();
} catch { } } catch {}
try { try {
const opts = await request('/api/yt-dlp/options') const opts = await request('/api/yt-dlp/options');
if (opts.ok) { if (opts.ok) {
config.ytdlp_options = await opts.json() as Array<YTDLPOption> config.ytdlp_options = (await opts.json()) as Array<YTDLPOption>;
} }
} catch { } } catch {}
try { try {
socket.connect() socket.connect();
} catch { } } catch {}
try { try {
await handleImage(bg_enable.value) await handleImage(bg_enable.value);
} catch { } } catch {}
}) });
watch(selectedTheme, value => { watch(selectedTheme, (value) => {
try { try {
if ('auto' === value) { if ('auto' === value) {
applyPreferredColorScheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') applyPreferredColorScheme(
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
);
return;
} }
applyPreferredColorScheme(value) applyPreferredColorScheme(value);
} catch { } } catch {}
}) });
watch(bg_enable, async v => await handleImage(v)) watch(bg_enable, async (v) => await handleImage(v));
watch(bg_opacity, v => { watch(bg_opacity, (v) => {
if (false === bg_enable.value) { if (false === bg_enable.value) {
return return;
} }
document.querySelector('body')?.setAttribute("style", `opacity: ${v}`) document.querySelector('body')?.setAttribute('style', `opacity: ${v}`);
}) });
watch(loadedImage, _ => { watch(loadedImage, (_) => {
if (false === bg_enable.value) { if (false === bg_enable.value) {
return return;
} }
const html = document.documentElement const html = document.documentElement;
const body = document.querySelector('body') const body = document.querySelector('body');
const style = { const style = {
"background-color": "unset", 'background-color': 'unset',
"display": 'block', display: 'block',
"min-height": '100%', 'min-height': '100%',
"min-width": '100%', 'min-width': '100%',
"background-image": `url(${loadedImage.value})`, 'background-image': `url(${loadedImage.value})`,
} as any } as any;
html.setAttribute("style", Object.keys(style).map(k => `${k}: ${style[k]}`).join('; ').trim()) html.setAttribute(
html.classList.add('bg-fanart') 'style',
body?.setAttribute("style", `opacity: ${bg_opacity.value}`) Object.keys(style)
}) .map((k) => `${k}: ${style[k]}`)
.join('; ')
.trim(),
);
html.classList.add('bg-fanart');
body?.setAttribute('style', `opacity: ${bg_opacity.value}`);
});
const handleImage = async (enabled: boolean) => { const handleImage = async (enabled: boolean) => {
if (false === enabled) { if (false === enabled) {
if (!loadedImage.value) { if (!loadedImage.value) {
return return;
} }
const html = document.documentElement const html = document.documentElement;
const body = document.querySelector('body') const body = document.querySelector('body');
if (html.getAttribute("style")) { if (html.getAttribute('style')) {
html.removeAttribute("style") html.removeAttribute('style');
} }
if (body?.getAttribute("style")) { if (body?.getAttribute('style')) {
body.removeAttribute("style") body.removeAttribute('style');
} }
loadedImage.value = '' loadedImage.value = '';
return return;
} }
if (loadedImage.value) { if (loadedImage.value) {
return return;
} }
await loadImage() await loadImage();
} };
const loadImage = async (force = false) => { const loadImage = async (force = false) => {
if (loadingImage.value) { if (loadingImage.value) {
return return;
} }
try { try {
loadingImage.value = true loadingImage.value = true;
let url = '/api/random/background' let url = '/api/random/background';
if (force) { if (force) {
url += '?force=true' url += '?force=true';
} }
const imgRequest = await request(url) const imgRequest = await request(url);
if (200 !== imgRequest.status) { if (200 !== imgRequest.status) {
return return;
} }
loadedImage.value = URL.createObjectURL(await imgRequest.blob()) loadedImage.value = URL.createObjectURL(await imgRequest.blob());
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} finally { } finally {
loadingImage.value = false loadingImage.value = false;
} }
} };
const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => { const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => {
showMenu.value = false showMenu.value = false;
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active')) document.querySelectorAll('div.has-dropdown').forEach((el) => el.classList.remove('is-active'));
if (callback) { if (callback) {
callback() callback();
} }
} };
const useVersionUpdate = () => { const useVersionUpdate = () => {
const newVersionIsAvailable = ref(false) const newVersionIsAvailable = ref(false);
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp();
nuxtApp.hooks.addHooks({ nuxtApp.hooks.addHooks({
'app:manifest:update': () => { 'app:manifest:update': () => {
newVersionIsAvailable.value = true newVersionIsAvailable.value = true;
} },
}); });
return { return {
newVersionIsAvailable: readonly(newVersionIsAvailable), newVersionIsAvailable: readonly(newVersionIsAvailable),
} };
} };
const { newVersionIsAvailable } = useVersionUpdate() const { newVersionIsAvailable } = useVersionUpdate();
const closeSettings = () => show_settings.value = false const closeSettings = () => (show_settings.value = false);
const shutdownApp = async () => { const shutdownApp = async () => {
const { alertDialog, confirmDialog: cDialog } = useDialog() const { alertDialog, confirmDialog: cDialog } = useDialog();
if (false === config.app.is_native) { if (false === config.app.is_native) {
await alertDialog({ await alertDialog({
title: 'Shutdown Unavailable', title: 'Shutdown Unavailable',
message: 'The shutdown feature is only available when running as native application.', message: 'The shutdown feature is only available when running as native application.',
}) });
return return;
} }
const { status } = await cDialog({ const { status } = await cDialog({
title: 'Shutdown Application', title: 'Shutdown Application',
message: 'Are you sure you want to shutdown the application?', message: 'Are you sure you want to shutdown the application?',
}) });
if (false === status) { if (false === status) {
return return;
} }
try { try {
const resp = await fetch('/api/system/shutdown', { method: 'POST' }) const resp = await fetch('/api/system/shutdown', { method: 'POST' });
if (!resp.ok) { if (!resp.ok) {
const body = await resp.json() const body = await resp.json();
await alertDialog({ await alertDialog({
title: 'Shutdown Failed', title: 'Shutdown Failed',
message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`, message: `Failed to shutdown the application: ${body.error || resp.statusText || resp.status}`,
}) });
return return;
} }
app_shutdown.value = true app_shutdown.value = true;
await nextTick() await nextTick();
} catch (e: any) { } catch (e: any) {
await alertDialog({ await alertDialog({
title: 'Shutdown Failed', title: 'Shutdown Failed',
message: `Failed to shutdown the application: ${e.message || e}`, message: `Failed to shutdown the application: ${e.message || e}`,
}) });
} }
} };
const openMenu = (e: MouseEvent) => { const openMenu = (e: MouseEvent) => {
const elm = (e.target as HTMLElement)?.closest('div.has-dropdown') as HTMLElement | null const elm = (e.target as HTMLElement)?.closest('div.has-dropdown') as HTMLElement | null;
document.querySelectorAll<HTMLElement>('div.has-dropdown').forEach(el => { document.querySelectorAll<HTMLElement>('div.has-dropdown').forEach((el) => {
if (el !== elm) { if (el !== elm) {
el.classList.remove('is-active') el.classList.remove('is-active');
} }
}) });
elm?.classList.toggle('is-active') elm?.classList.toggle('is-active');
} };
const reloadPage = () => window.location.reload() const reloadPage = () => window.location.reload();
const connectionStatusColor = computed(() => { const connectionStatusColor = computed(() => {
switch (socket.connectionStatus) { switch (socket.connectionStatus) {
case 'connected': case 'connected':
return 'has-text-success' return 'has-text-success';
case 'connecting': case 'connecting':
return 'has-text-warning' return 'has-text-warning';
case 'disconnected': case 'disconnected':
default: default:
return 'has-text-danger' return 'has-text-danger';
} }
}) });
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' }); const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
</script> </script>
@ -669,7 +782,9 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
<style> <style>
#main_container, #main_container,
.basic-wrapper { .basic-wrapper {
transition: transform 0.3s ease, margin-right 0.3s ease; transition:
transform 0.3s ease,
margin-right 0.3s ease;
} }
#main_container.settings-open, #main_container.settings-open,
@ -678,7 +793,6 @@ const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behav
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
#main_container.settings-open, #main_container.settings-open,
.basic-wrapper.settings-open { .basic-wrapper.settings-open {
transform: translateX(0); transform: translateX(0);

View file

@ -15,7 +15,7 @@
</template> </template>
<script setup> <script setup>
import 'assets/css/bulma.css' import 'assets/css/bulma.css';
import 'assets/css/style.css' import 'assets/css/style.css';
import 'assets/css/all.css' import 'assets/css/all.css';
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
hr { hr {
background-color: unset; background-color: unset;
border-bottom: 1px solid var(--bulma-grey-light) !important border-bottom: 1px solid var(--bulma-grey-light) !important;
} }
</style> </style>
@ -25,8 +25,13 @@ hr {
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0"> <p class="control has-icons-left" v-if="toggleFilter && logs && logs.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter changelog entries"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter changelog entries"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -40,7 +45,9 @@ hr {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">This page display the latest changes and updates from the project.</span> <span class="subtitle"
>This page display the latest changes and updates from the project.</span
>
</div> </div>
</div> </div>
</div> </div>
@ -60,32 +67,40 @@ hr {
<div class="content p-0 m-0"> <div class="content p-0 m-0">
<h1 class="is-4"> <h1 class="is-4">
<span class="icon"><i class="fas fa-code-branch" /></span> <span class="icon"><i class="fas fa-code-branch" /></span>
{{ log.tag }} <span class="tag has-text-success" v-if="isInstalled(log)">Installed</span> {{ log.tag }}
<span class="tag has-text-success" v-if="isInstalled(log)">Installed</span>
<template v-if="log.date"> <template v-if="log.date">
<span style="font-size:0.5em;"> <span style="font-size: 0.5em">
- <span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`"> -
<span class="has-tooltip" v-tooltip="`Release Date: ${log.date}`">
{{ moment(log.date).fromNow() }} {{ moment(log.date).fromNow() }}
</span></span> </span></span
>
</template> </template>
</h1> </h1>
<hr> <hr />
<ul> <ul>
<li v-for="commit in log.commits" :key="commit.sha"> <li v-for="commit in log.commits" :key="commit.sha">
<strong> <strong> {{ ucFirst(commit.message).replace(/\.$/, '') }}. </strong> -
{{ ucFirst(commit.message).replace(/\.$/, "") }}.
</strong> -
<small> <small>
<NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank"> <NuxtLink :to="`${REPO}/commit/${commit.full_sha}`" target="_blank">
<span class="has-tooltip" v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`"> <span
class="has-tooltip"
v-tooltip="`SHA: ${commit.full_sha} - Date: ${commit.date}`"
>
{{ moment(commit.date).fromNow() }} {{ moment(commit.date).fromNow() }}
</span> </span>
</NuxtLink> </NuxtLink>
<span v-tooltip="'Code is at this commit.'" v-if="commit.full_sha === app_sha" <span
class="icon has-text-success"><i class="fas fa-check" /></span> v-tooltip="'Code is at this commit.'"
v-if="commit.full_sha === app_sha"
class="icon has-text-success"
><i class="fas fa-check"
/></span>
</small> </small>
</li> </li>
</ul> </ul>
<hr v-if="index < logs.length - 1"> <hr v-if="index < logs.length - 1" />
</div> </div>
</div> </div>
</div> </div>
@ -93,9 +108,18 @@ hr {
<div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1"> <div class="columns is-multiline" v-if="!filteredLogs || filteredLogs.length < 1">
<div class="column is-12"> <div class="column is-12">
<Message title="No Results" class="is-warning" icon="fas fa-search" v-if="query" <Message
:useClose="true" @close="query = ''"> title="No Results"
<p>No changelog entries found for the query: <strong>{{ query }}</strong>.</p> class="is-warning"
icon="fas fa-search"
v-if="query"
:useClose="true"
@close="query = ''"
>
<p>
No changelog entries found for the query: <strong>{{ query }}</strong
>.
</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
</div> </div>
@ -105,99 +129,105 @@ hr {
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment';
import type { changelogs, changeset } from '~/types/changelogs' import type { changelogs, changeset } from '~/types/changelogs';
const toast = useNotification() const toast = useNotification();
const config = useConfigStore() const config = useConfigStore();
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
useHead({ title: 'CHANGELOG' }) useHead({ title: 'CHANGELOG' });
const PROJECT = 'ytptube' const PROJECT = 'ytptube';
const REPO = `https://github.com/arabcoders/${PROJECT}` const REPO = `https://github.com/arabcoders/${PROJECT}`;
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}` const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;
const logs = ref<changelogs>([]) const logs = ref<changelogs>([]);
const app_version = ref('') const app_version = ref('');
const app_branch = ref('') const app_branch = ref('');
const app_sha = ref('') const app_sha = ref('');
const isLoading = ref(true) const isLoading = ref(true);
const query = ref<string>('') const query = ref<string>('');
const toggleFilter = ref<boolean>(false) const toggleFilter = ref<boolean>(false);
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = '' query.value = '';
} }
}) });
const filteredLogs = computed<changelogs>(() => { const filteredLogs = computed<changelogs>(() => {
const q = query.value?.toLowerCase() const q = query.value?.toLowerCase();
if (!q) return logs.value if (!q) return logs.value;
return logs.value.map(log => { return logs.value
const tagMatches = log.tag.toLowerCase().includes(q) .map((log) => {
const tagMatches = log.tag.toLowerCase().includes(q);
const filteredCommits = log.commits?.filter(commit => const filteredCommits =
commit.message.toLowerCase().includes(q) || log.commits?.filter(
commit.author.toLowerCase().includes(q) || (commit) =>
commit.full_sha.toLowerCase().includes(q) commit.message.toLowerCase().includes(q) ||
) ?? [] commit.author.toLowerCase().includes(q) ||
commit.full_sha.toLowerCase().includes(q),
) ?? [];
if (tagMatches || filteredCommits.length > 0) { if (tagMatches || filteredCommits.length > 0) {
return { ...log, commits: tagMatches ? log.commits : filteredCommits } return { ...log, commits: tagMatches ? log.commits : filteredCommits };
} }
return null return null;
}).filter((log): log is changeset => log !== null) })
}) .filter((log): log is changeset => log !== null);
});
const loadContent = async () => { const loadContent = async () => {
if ('' === app_version.value || logs.value.length > 0) { if ('' === app_version.value || logs.value.length > 0) {
return return;
} }
try { try {
try { try {
const changes = await fetch(REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value)) const changes = await fetch(
logs.value = await changes.json() REPO_URL.replace('{branch}', app_branch.value).replace('{version}', app_version.value),
);
logs.value = await changes.json();
} catch (e) { } catch (e) {
console.error(e) console.error(e);
logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json() logs.value = await (await request(uri('/CHANGELOG.json'), { method: 'GET' })).json();
} }
await nextTick() await nextTick();
logs.value = logs.value.slice(0, 10) logs.value = logs.value.slice(0, 10);
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e);
toast.error(`Failed to fetch changelog. ${e.message}`) toast.error(`Failed to fetch changelog. ${e.message}`);
} finally { } finally {
isLoading.value = false isLoading.value = false;
} }
} };
const isInstalled = (log: changeset) => { const isInstalled = (log: changeset) => {
const installed = String(app_sha.value) const installed = String(app_sha.value);
if (log.full_sha.startsWith(installed)) { if (log.full_sha.startsWith(installed)) {
return true return true;
} }
for (const commit of log?.commits ?? []) { for (const commit of log?.commits ?? []) {
if (commit.full_sha.startsWith(installed)) { if (commit.full_sha.startsWith(installed)) {
return true return true;
} }
} }
return false return false;
} };
onMounted(async () => { onMounted(async () => {
await awaiter(config.isLoaded) await awaiter(config.isLoaded);
app_branch.value = config.app.app_branch app_branch.value = config.app.app_branch;
app_version.value = config.app.app_version app_version.value = config.app.app_version;
app_sha.value = config.app.app_commit_sha app_sha.value = config.app.app_commit_sha;
loadContent() loadContent();
}) });
</script> </script>

View file

@ -5,7 +5,9 @@
<span class="title is-4"> <span class="title is-4">
<span class="icon-text"> <span class="icon-text">
<template v-if="toggleForm"> <template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span> <span class="icon"
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
/></span>
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span> <span>{{ itemRef ? `Edit - ${item.name}` : 'Add new condition' }}</span>
</template> </template>
<template v-else> <template v-else>
@ -17,8 +19,13 @@
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0"> <p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter displayed content"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -30,25 +37,44 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"> <button
class="button is-primary"
@click="
resetForm(false);
toggleForm = !toggleForm;
"
>
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Condition</span> <span v-if="!isMobile">New Condition</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'"> v-tooltip.bottom="'Change display style'"
class="button has-tooltip-bottom"
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" <i
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="async () => await loadContent(page)" <button
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0"> class="button is-info"
@click="async () => await loadContent(page)"
:class="{ 'is-loading': isLoading }"
:disabled="isLoading"
v-if="items && items.length > 0"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span> <span v-if="!isMobile">Reload</span>
</button> </button>
@ -63,21 +89,40 @@
</div> </div>
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1"> <div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading" <Pager
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" /> :page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="
async (newPage) => {
page = newPage;
await loadContent(newPage);
}
"
/>
</div> </div>
<div class="column is-12" v-if="toggleForm"> <div class="column is-12" v-if="toggleForm">
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="(item as Condition)" <ConditionForm
@cancel="resetForm(true)" @submit="updateItem" /> :addInProgress="conditions.addInProgress.value"
:reference="itemRef"
:item="item as Condition"
@cancel="resetForm(true)"
@submit="updateItem"
/>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)"> <div
class="columns is-multiline"
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
>
<div class="column is-12" v-if="'list' === display_style"> <div class="column is-12" v-if="'list' === display_style">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 850px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="80%"> <th width="80%">
@ -97,11 +142,21 @@
{{ cond.name }} {{ cond.name }}
</div> </div>
<div class="is-unselectable"> <div class="is-unselectable">
<span class="icon-text is-clickable" @click="toggleEnabled(cond)" <span
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'"> class="icon-text is-clickable"
@click="toggleEnabled(cond)"
v-tooltip="
'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'
"
>
<span class="icon"> <span class="icon">
<i class="fa-solid fa-power-off" <i
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" /> class="fa-solid fa-power-off"
:class="{
'has-text-success': cond.enabled !== false,
'has-text-danger': cond.enabled === false,
}"
/>
</span> </span>
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span> <span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
</span> </span>
@ -147,19 +202,28 @@
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(cond)"> <button
class="button is-info is-small is-fullwidth"
@click="exportItem(cond)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span> <span v-if="!isMobile">Export</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(cond)"> <button
class="button is-warning is-small is-fullwidth"
@click="editItem(cond)"
>
<span class="icon"><i class="fa-solid fa-edit" /></span> <span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span> <span v-if="!isMobile">Edit</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(cond)"> <button
class="button is-danger is-small is-fullwidth"
@click="deleteItem(cond)"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span> <span v-if="!isMobile">Delete</span>
</button> </button>
@ -185,13 +249,22 @@
</span> </span>
</div> </div>
<div class="control" @click="toggleEnabled(cond)"> <div class="control" @click="toggleEnabled(cond)">
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'" <span
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`"> class="icon"
:class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="
`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
"
>
<i class="fa-solid fa-power-off" /> <i class="fa-solid fa-power-off" />
</span> </span>
</div> </div>
<div class="control"> <div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)"> <a
class="has-text-info"
v-tooltip="'Export item'"
@click.prevent="exportItem(cond)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
</div> </div>
@ -208,16 +281,25 @@
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ cond.cli }}</span> <span>{{ cond.cli }}</span>
</p> </p>
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0"> <p
class="is-text-overflow"
v-if="cond.extras && Object.keys(cond.extras).length > 0"
>
<span class="icon"><i class="fa-solid fa-list" /></span> <span class="icon"><i class="fa-solid fa-list" /></span>
<span>Extras: <span
>Extras:
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2"> <span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
<b>{{ key }}</b>: {{ value }} <b>{{ key }}</b
>: {{ value }}
</span> </span>
</span> </span>
</p> </p>
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }" <p
v-if="cond.description" @click="toggleExpand(cond.id, 'description')"> class="is-clickable"
:class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
v-if="cond.description"
@click="toggleExpand(cond.id, 'description')"
>
<span class="icon"><i class="fa-solid fa-comment" /></span> <span class="icon"><i class="fa-solid fa-comment" /></span>
<span>{{ cond.description }}</span> <span>{{ cond.description }}</span>
</p> </p>
@ -242,44 +324,65 @@
</template> </template>
</div> </div>
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"> <div
class="columns is-multiline"
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
>
<div class="column is-12"> <div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin"> <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait... Loading data. Please wait...
</Message> </Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true" <Message
@close="query = ''"> title="No Results"
<p>No results found for the query: <code>{{ query }}</code>.</p> class="is-warning"
icon="fas fa-search"
v-else-if="query"
:useClose="true"
@close="query = ''"
>
<p>
No results found for the query: <code>{{ query }}</code
>.
</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle"> <Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
There are no custom defined conditions yet. Click the <span class="icon"><i class="fas fa-add" /></span> There are no custom defined conditions yet. Click the
<strong>New Condition</strong> button to add your first condition. <span class="icon"><i class="fas fa-add" /></span> <strong>New Condition</strong> button
to add your first condition.
</Message> </Message>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="filteredItems && filteredItems.length > 0 && !toggleForm"> <div
class="columns is-multiline"
v-if="filteredItems && filteredItems.length > 0 && !toggleForm"
>
<div class="column is-12"> <div class="column is-12">
<Message class="is-info" :body_class="'pl-0'"> <Message class="is-info" :body_class="'pl-0'">
<ul> <ul>
<li>Filtering is based on yt-dlps <code>--match-filter</code> logic. Any expression that works with <li>
yt-dlp will also work here, including the same boolean operators. We added extended support for the Filtering is based on yt-dlps <code>--match-filter</code> logic. Any expression that
<code>OR</code> ( <code>||</code> ) operator, which yt-dlp does not natively support. This allows you to works with yt-dlp will also work here, including the same boolean operators. We added
combine multiple conditions more flexibly. extended support for the <code>OR</code> ( <code>||</code> ) operator, which yt-dlp
does not natively support. This allows you to combine multiple conditions more
flexibly.
</li> </li>
<li> <li>
The primary use case for this feature is to apply custom cli arguments to specific returned info. The primary use case for this feature is to apply custom cli arguments to specific
returned info.
</li> </li>
<li> <li>
For example, i follow specific channel that sometimes region lock some videos, by using the following For example, i follow specific channel that sometimes region lock some videos, by
filter i am able to bypass it <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>. using the following filter i am able to bypass it
and set proxy for that specific video, while leaving the rest of the videos to be downloaded normally. <code>availability = 'needs_auth' & channel_id = 'channel_id'</code>. and set proxy
for that specific video, while leaving the rest of the videos to be downloaded
normally.
</li> </li>
<li> <li>
The data which the filter is applied on is the same data that yt-dlp returns, simply, click on the The data which the filter is applied on is the same data that yt-dlp returns, simply,
information button, and check the data to craft your filter. You will get instant feedback if the click on the information button, and check the data to craft your filter. You will get
filter matches or not. instant feedback if the filter matches or not.
</li> </li>
</ul> </ul>
</Message> </Message>
@ -289,32 +392,32 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { Condition } from '~/types/conditions' import type { Condition } from '~/types/conditions';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { useConditions } from '~/composables/useConditions' import { useConditions } from '~/composables/useConditions';
import type { APIResponse } from '~/types/responses' import type { APIResponse } from '~/types/responses';
type ConditionItemWithUI = Condition & { raw?: boolean } type ConditionItemWithUI = Condition & { raw?: boolean };
const box = useConfirm() const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid') const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid');
const conditions = useConditions() const conditions = useConditions();
const route = useRoute() const route = useRoute();
const items = conditions.conditions as Ref<ConditionItemWithUI[]> const items = conditions.conditions as Ref<ConditionItemWithUI[]>;
const paging = conditions.pagination const paging = conditions.pagination;
const isLoading = conditions.isLoading const isLoading = conditions.isLoading;
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1) const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
const item = ref<Partial<Condition>>({}) const item = ref<Partial<Condition>>({});
const itemRef = ref<number | null | undefined>(null) const itemRef = ref<number | null | undefined>(null);
const toggleForm = ref(false) const toggleForm = ref(false);
const query = ref<string>('') const query = ref<string>('');
const toggleFilter = ref(false) const toggleFilter = ref(false);
const remove_keys = ['raw', 'toggle_description'] const remove_keys = ['raw', 'toggle_description'];
const expandedItems = reactive<Record<number, Set<string>>>({}) const expandedItems = reactive<Record<number, Set<string>>>({});
const filteredItems = computed<ConditionItemWithUI[]>(() => { const filteredItems = computed<ConditionItemWithUI[]>(() => {
const q = query.value?.toLowerCase(); const q = query.value?.toLowerCase();
@ -323,87 +426,95 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
}); });
const loadContent = async (page: number = 1): Promise<void> => { const loadContent = async (page: number = 1): Promise<void> => {
await conditions.loadConditions(page) await conditions.loadConditions(page);
await nextTick() await nextTick();
if (conditions.pagination.value.total_pages > 1) { if (conditions.pagination.value.total_pages > 1) {
useRouter().replace({ query: { ...route.query, page: page.toString() } }) useRouter().replace({ query: { ...route.query, page: page.toString() } });
} }
} };
const toggleExpand = (itemId: number | undefined, field: string) => { const toggleExpand = (itemId: number | undefined, field: string) => {
if (!itemId) return if (!itemId) return;
if (!expandedItems[itemId]) { if (!expandedItems[itemId]) {
expandedItems[itemId] = new Set() expandedItems[itemId] = new Set();
} }
if (expandedItems[itemId].has(field)) { if (expandedItems[itemId].has(field)) {
expandedItems[itemId].delete(field) expandedItems[itemId].delete(field);
} else { } else {
expandedItems[itemId].add(field) expandedItems[itemId].add(field);
} }
} };
const isExpanded = (itemId: number | undefined, field: string): boolean => { const isExpanded = (itemId: number | undefined, field: string): boolean => {
if (!itemId) return false if (!itemId) return false;
return expandedItems[itemId]?.has(field) ?? false return expandedItems[itemId]?.has(field) ?? false;
} };
watch(toggleFilter, val => { watch(toggleFilter, (val) => {
if (!val) { if (!val) {
query.value = '' query.value = '';
} }
}) });
const resetForm = (closeForm = false): void => { const resetForm = (closeForm = false): void => {
item.value = {} item.value = {};
itemRef.value = null itemRef.value = null;
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false;
} }
} };
const deleteItem = async (cond: Condition): Promise<void> => { const deleteItem = async (cond: Condition): Promise<void> => {
if (true !== (await box.confirm(`Delete '${cond.name}'?`))) { if (true !== (await box.confirm(`Delete '${cond.name}'?`))) {
return return;
} }
await conditions.deleteCondition(cond.id!) await conditions.deleteCondition(cond.id!);
} };
const updateItem = async ({ reference, item: updatedItem }: { const updateItem = async ({
reference: number | null | undefined, reference,
item: Condition item: updatedItem,
}: {
reference: number | null | undefined;
item: Condition;
}): Promise<void> => { }): Promise<void> => {
updatedItem = cleanObject(updatedItem, remove_keys) as Condition updatedItem = cleanObject(updatedItem, remove_keys) as Condition;
const cb = (resp: APIResponse) => { const cb = (resp: APIResponse) => {
if (resp.success) { if (resp.success) {
resetForm(true) resetForm(true);
} }
} };
if (reference) { if (reference) {
await conditions.patchCondition(reference, updatedItem, cb) await conditions.patchCondition(reference, updatedItem, cb);
} else { } else {
await conditions.createCondition(updatedItem, cb) await conditions.createCondition(updatedItem, cb);
} }
} };
const editItem = (_item: Condition): void => { const editItem = (_item: Condition): void => {
item.value = JSON.parse(JSON.stringify(_item)) as Condition item.value = JSON.parse(JSON.stringify(_item)) as Condition;
itemRef.value = _item.id itemRef.value = _item.id;
toggleForm.value = true toggleForm.value = true;
} };
const toggleEnabled = async (cond: Condition): Promise<void> => { const toggleEnabled = async (cond: Condition): Promise<void> => {
const new_state = !cond.enabled const new_state = !cond.enabled;
await conditions.patchCondition(cond.id!, { enabled: new_state }) await conditions.patchCondition(cond.id!, { enabled: new_state });
} };
const exportItem = (cond: Condition): void => copyText(encode({ const exportItem = (cond: Condition): void =>
...Object.fromEntries(Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k))), copyText(
_type: 'condition', encode({
_version: '1.2', ...Object.fromEntries(
})) Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k)),
),
_type: 'condition',
_version: '1.2',
}),
);
onMounted(async () => await loadContent(page.value)) onMounted(async () => await loadContent(page.value));
</script> </script>

View file

@ -23,8 +23,8 @@
</span> </span>
</h1> </h1>
<div class="subtitle is-6 is-unselectable"> <div class="subtitle is-6 is-unselectable">
You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing the web interface and You can use this page to run yt-dlp commands directly in a non-interactive way, bypassing
it's settings. the web interface and it's settings.
</div> </div>
</div> </div>
<div class="column is-12"> <div class="column is-12">
@ -43,22 +43,45 @@
</p> </p>
</header> </header>
<section class="card-content p-0 m-0"> <section class="card-content p-0 m-0">
<div ref="terminal_window" style="min-height: 60vh;max-height:70vh;" /> <div ref="terminal_window" style="min-height: 60vh; max-height: 70vh" />
</section> </section>
<section class="card-content p-1 m-1"> <section class="card-content p-1 m-1">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
<TextareaAutocomplete v-if="isMultiLineInput" ref="commandTextarea" v-model="command" <TextareaAutocomplete
:options="ytDlpOptions" :disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" /> v-if="isMultiLineInput"
<InputAutocomplete v-else v-model="command" ref="commandInput" :options="ytDlpOptions" ref="commandTextarea"
:disabled="isLoading" placeholder="--help" @keydown="handleKeyDown" @paste="handlePaste" v-model="command"
:multiple="true" :allowShortFlags="true" /> :options="ytDlpOptions"
:disabled="isLoading"
placeholder="--help"
@keydown="handleKeyDown"
/>
<InputAutocomplete
v-else
v-model="command"
ref="commandInput"
:options="ytDlpOptions"
:disabled="isLoading"
placeholder="--help"
@keydown="handleKeyDown"
@paste="handlePaste"
:multiple="true"
:allowShortFlags="true"
/>
</div> </div>
<p class="control"> <p class="control">
<button class="button is-primary" type="button" :disabled="isLoading || !hasValidCommand" <button
@click="runCommand"> class="button is-primary"
type="button"
:disabled="isLoading || !hasValidCommand"
@click="runCommand"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" :class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'" /> <i
class="fa-solid"
:class="isLoading ? 'fa-spinner fa-spin' : 'fa-paper-plane'"
/>
</span> </span>
</button> </button>
</p> </p>
@ -70,7 +93,10 @@
<div class="column is-12"> <div class="column is-12">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
<p class="card-header-title is-pointer is-unselectable" @click="isHistoryCollapsed = !isHistoryCollapsed"> <p
class="card-header-title is-pointer is-unselectable"
@click="isHistoryCollapsed = !isHistoryCollapsed"
>
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span> <span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
<span class="ml-2">Commands History</span> <span class="ml-2">Commands History</span>
</p> </p>
@ -78,9 +104,15 @@
<span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()"> <span v-tooltip.top="'Clear command history'" class="icon" @click="clearHistory()">
<i class="fa-solid fa-trash" /> <i class="fa-solid fa-trash" />
</span> </span>
<span v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'" class="icon ml-2" <span
@click="isHistoryCollapsed = !isHistoryCollapsed"> v-tooltip.top="isHistoryCollapsed ? 'Expand' : 'Collapse'"
<i class="fa-solid" :class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'" /> class="icon ml-2"
@click="isHistoryCollapsed = !isHistoryCollapsed"
>
<i
class="fa-solid"
:class="isHistoryCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'"
/>
</span> </span>
</p> </p>
</header> </header>
@ -89,16 +121,21 @@
Commands history is empty. Commands history is empty.
</Message> </Message>
<div class="table-container" v-if="commandHistory.length > 0"> <div class="table-container" v-if="commandHistory.length > 0">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 850px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed"
>
<tbody> <tbody>
<tr v-for="(cmd, index) in commandHistory" :key="index"> <tr v-for="(cmd, index) in commandHistory" :key="index">
<td> <td>
<code class="is-family-monospace is-text-overflow is-pointer is-block" @click="loadCommand(cmd)"> <code
class="is-family-monospace is-text-overflow is-pointer is-block"
@click="loadCommand(cmd)"
>
{{ cmd.replace(/\n/g, ' ') }} {{ cmd.replace(/\n/g, ' ') }}
</code> </code>
</td> </td>
<td style="width: 40px; text-align: center;"> <td style="width: 40px; text-align: center">
<button class="delete" @click="removeFromHistory(index)" /> <button class="delete" @click="removeFromHistory(index)" />
</td> </td>
</tr> </tr>
@ -112,154 +149,166 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { fetchEventSource } from '@microsoft/fetch-event-source' import { fetchEventSource } from '@microsoft/fetch-event-source';
import type { EventSourceMessage } from '@microsoft/fetch-event-source' import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils' import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils';
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue';
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue' import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue';
import { useDialog } from '~/composables/useDialog' import { useDialog } from '~/composables/useDialog';
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete';
const config = useConfigStore() const config = useConfigStore();
const toast = useNotification() const toast = useNotification();
const dialog = useDialog() const dialog = useDialog();
const terminal = ref<Terminal>() const terminal = ref<Terminal>();
const terminalFit = ref<FitAddon>() const terminalFit = ref<FitAddon>();
const command = ref<string>('') const command = ref<string>('');
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window') const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window');
const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null) const commandInput = ref<InstanceType<typeof InputAutocomplete> | null>(null);
const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null) const commandTextarea = ref<InstanceType<typeof TextareaAutocomplete> | null>(null);
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false);
const storedCommand = useStorage<string>('console_command', '') const storedCommand = useStorage<string>('console_command', '');
const commandHistory = useStorage<string[]>('console_command_history', []) const commandHistory = useStorage<string[]>('console_command_history', []);
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false) const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false);
const sseController = ref<AbortController | null>(null) const sseController = ref<AbortController | null>(null);
const MAX_HISTORY_ITEMS = 50 const MAX_HISTORY_ITEMS = 50;
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags const ytDlpOptions = computed<AutoCompleteOptions>(() =>
.map(flag => ({ value: flag, description: opt.description || '' })) config.ytdlp_options.flatMap((opt) =>
)) opt.flags.map((flag) => ({ value: flag, description: opt.description || '' })),
),
);
const hasValidCommand = computed(() => command.value && command.value.trim().length > 0) const hasValidCommand = computed(() => command.value && command.value.trim().length > 0);
const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n')) const isMultiLineInput = computed(() => !!command.value && command.value.includes('\n'));
watch(() => isLoading.value, async value => { watch(
if (value) { () => isLoading.value,
return async (value) => {
} if (value) {
if (command.value.trim()) { return;
addToHistory(command.value.trim()) }
} if (command.value.trim()) {
command.value = '' addToHistory(command.value.trim());
await nextTick(); }
focusInput() command.value = '';
}, { immediate: true }) await nextTick();
focusInput();
},
{ immediate: true },
);
watch(() => config.app.console_enabled, async () => { watch(
if (config.app.console_enabled) { () => config.app.console_enabled,
return async () => {
} if (config.app.console_enabled) {
toast.error('Console is disabled in the configuration. Please enable it to use this feature.') return;
await navigateTo('/') }
}) toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
await navigateTo('/');
},
);
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => { const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const isTextarea = 'TEXTAREA' === target.tagName const isTextarea = 'TEXTAREA' === target.tagName;
if (event.key !== 'Enter') { if (event.key !== 'Enter') {
return return;
} }
if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) { if (((event.ctrlKey && isTextarea) || !isTextarea) && hasValidCommand.value) {
event.preventDefault() event.preventDefault();
runCommand() runCommand();
return return;
} }
if (event.shiftKey && !isTextarea) { if (event.shiftKey && !isTextarea) {
event.preventDefault() event.preventDefault();
const cursorPos = target.selectionStart || command.value.length const cursorPos = target.selectionStart || command.value.length;
command.value = command.value.substring(0, cursorPos) + '\n' + command.value.substring(target.selectionEnd || cursorPos) command.value =
await nextTick() command.value.substring(0, cursorPos) +
'\n' +
command.value.substring(target.selectionEnd || cursorPos);
await nextTick();
if (commandTextarea.value) { if (commandTextarea.value) {
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
if (textarea) { if (textarea) {
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1) textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
textarea.focus() textarea.focus();
} }
} }
} }
} };
const handlePaste = async (event: ClipboardEvent): Promise<void> => { const handlePaste = async (event: ClipboardEvent): Promise<void> => {
const pastedText = event.clipboardData?.getData('text') || '' const pastedText = event.clipboardData?.getData('text') || '';
if (!pastedText.includes('\n')) { if (!pastedText.includes('\n')) {
return return;
} }
event.preventDefault() event.preventDefault();
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement;
const currentValue = command.value || '' const currentValue = command.value || '';
const start = target.selectionStart || currentValue.length const start = target.selectionStart || currentValue.length;
const end = target.selectionEnd || currentValue.length const end = target.selectionEnd || currentValue.length;
command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end) command.value = currentValue.substring(0, start) + pastedText + currentValue.substring(end);
await nextTick() await nextTick();
if (!commandTextarea.value) { if (!commandTextarea.value) {
return return;
} }
const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement const textarea = commandTextarea.value.$el?.querySelector('textarea') as HTMLTextAreaElement;
if (textarea) { if (textarea) {
const newPos = start + pastedText.length const newPos = start + pastedText.length;
textarea.setSelectionRange(newPos, newPos) textarea.setSelectionRange(newPos, newPos);
textarea.focus() textarea.focus();
} }
} };
const handle_event = () => { const handle_event = () => {
if (!terminal.value) { if (!terminal.value) {
return return;
} }
terminalFit.value?.fit() terminalFit.value?.fit();
} };
const handleStreamMessage = (event: EventSourceMessage) => { const handleStreamMessage = (event: EventSourceMessage) => {
if (!terminal.value) { if (!terminal.value) {
return return;
} }
let payload: { type?: string; line?: string; exitcode?: number } | null = null let payload: { type?: string; line?: string; exitcode?: number } | null = null;
if (event.data) { if (event.data) {
try { try {
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number } payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number };
} catch { } catch {
payload = null payload = null;
} }
} }
if ('output' === event.event) { if ('output' === event.event) {
terminal.value.writeln(payload?.line ?? '') terminal.value.writeln(payload?.line ?? '');
return return;
} }
if ('close' === event.event) { if ('close' === event.event) {
isLoading.value = false isLoading.value = false;
sseController.value?.abort() sseController.value?.abort();
} }
} };
const startStream = async (cmd: string) => { const startStream = async (cmd: string) => {
sseController.value?.abort() sseController.value?.abort();
const controller = new AbortController() const controller = new AbortController();
sseController.value = controller sseController.value = controller;
isLoading.value = true isLoading.value = true;
try { try {
await fetchEventSource(uri('/api/system/terminal'), { await fetchEventSource(uri('/api/system/terminal'), {
@ -267,53 +316,53 @@ const startStream = async (cmd: string) => {
body: JSON.stringify({ command: cmd }), body: JSON.stringify({ command: cmd }),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'text/event-stream', Accept: 'text/event-stream',
}, },
credentials: 'same-origin', credentials: 'same-origin',
signal: controller.signal, signal: controller.signal,
onopen: async (response) => { onopen: async (response) => {
if (response.ok) { if (response.ok) {
return return;
} }
let message = response.statusText || 'Failed to start command stream.' let message = response.statusText || 'Failed to start command stream.';
try { try {
message = await parse_api_error(response.clone().json()) message = await parse_api_error(response.clone().json());
} catch { } catch {
try { try {
const text = await response.text() const text = await response.text();
if (text) { if (text) {
message = text message = text;
} }
} catch { } catch {
message = response.statusText || 'Failed to start command stream.' message = response.statusText || 'Failed to start command stream.';
} }
} }
throw new Error(message) throw new Error(message);
}, },
onmessage: handleStreamMessage, onmessage: handleStreamMessage,
onerror: (error) => { onerror: (error) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
return return;
} }
terminal.value?.writeln(`Error: ${error}`) terminal.value?.writeln(`Error: ${error}`);
isLoading.value = false isLoading.value = false;
}, },
}) });
} catch (error) { } catch (error) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
terminal.value?.writeln(`Error: ${error}`) terminal.value?.writeln(`Error: ${error}`);
isLoading.value = false isLoading.value = false;
} }
} finally { } finally {
if (controller === sseController.value) { if (controller === sseController.value) {
sseController.value = null sseController.value = null;
} }
} }
} };
const ensureTerminal = async () => { const ensureTerminal = async () => {
if (terminal.value) { if (terminal.value) {
return return;
} }
terminal.value = new Terminal({ terminal.value = new Terminal({
@ -325,124 +374,128 @@ const ensureTerminal = async () => {
rows: 10, rows: 10,
disableStdin: true, disableStdin: true,
scrollback: 1000, scrollback: 1000,
}) });
terminalFit.value = new FitAddon() terminalFit.value = new FitAddon();
terminal.value.loadAddon(terminalFit.value) terminal.value.loadAddon(terminalFit.value);
await nextTick() await nextTick();
if (terminal_window.value) { if (terminal_window.value) {
terminal.value.open(terminal_window.value) terminal.value.open(terminal_window.value);
} }
terminalFit.value.fit(); terminalFit.value.fit();
} };
const runCommand = async () => { const runCommand = async () => {
if (!hasValidCommand.value) { if (!hasValidCommand.value) {
return return;
} }
if (true !== config.app.console_enabled) { if (true !== config.app.console_enabled) {
await navigateTo('/') await navigateTo('/');
toast.error('Console is disabled in the configuration. Please enable it to use this feature.') toast.error('Console is disabled in the configuration. Please enable it to use this feature.');
return return;
} }
let cmd = command.value.trim().replace(/\n/g, ' ').trim() let cmd = command.value.trim().replace(/\n/g, ' ').trim();
if (cmd.startsWith('yt-dlp')) { if (cmd.startsWith('yt-dlp')) {
cmd = cmd.replace(/^yt-dlp/, '').trim() cmd = cmd.replace(/^yt-dlp/, '').trim();
await nextTick() await nextTick();
if ('' === cmd) { if ('' === cmd) {
return return;
} }
} }
await ensureTerminal() await ensureTerminal();
if ('clear' === cmd) { if ('clear' === cmd) {
clearOutput(true) clearOutput(true);
return return;
} }
await startStream(cmd) await startStream(cmd);
terminal.value?.writeln(`user@YTPTube ~`) terminal.value?.writeln(`user@YTPTube ~`);
terminal.value?.writeln(`$ yt-dlp ${command.value}`) terminal.value?.writeln(`$ yt-dlp ${command.value}`);
storedCommand.value = '' storedCommand.value = '';
} };
const clearOutput = async (withCommand: boolean = false) => { const clearOutput = async (withCommand: boolean = false) => {
if (terminal.value) { if (terminal.value) {
terminal.value.clear() terminal.value.clear();
} }
if (true === withCommand) { if (true === withCommand) {
command.value = '' command.value = '';
} }
focusInput() focusInput();
} };
const focusInput = async () => { const focusInput = async () => {
await nextTick() await nextTick();
let elm; let elm;
if (isMultiLineInput.value) { if (isMultiLineInput.value) {
elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement elm = commandTextarea.value?.$el?.querySelector('textarea') as HTMLTextAreaElement;
} else { } else {
elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement elm = commandInput.value?.$el?.querySelector('input') as HTMLInputElement;
} }
elm?.focus() elm?.focus();
} };
const addToHistory = (cmd: string) => { const addToHistory = (cmd: string) => {
commandHistory.value = [cmd, ...commandHistory.value.filter(h => h !== cmd)].slice(0, MAX_HISTORY_ITEMS) commandHistory.value = [cmd, ...commandHistory.value.filter((h) => h !== cmd)].slice(
} 0,
MAX_HISTORY_ITEMS,
);
};
const loadCommand = async (cmd: string) => { const loadCommand = async (cmd: string) => {
command.value = cmd command.value = cmd;
await nextTick() await nextTick();
focusInput() focusInput();
} };
const clearHistory = async () => { const clearHistory = async () => {
if (commandHistory.value.length === 0) { if (commandHistory.value.length === 0) {
return return;
} }
const { status } = await dialog.confirmDialog({ const { status } = await dialog.confirmDialog({
title: 'Confirm Action', title: 'Confirm Action',
message: `Clear commands history?`, message: `Clear commands history?`,
confirmColor: 'is-danger', confirmColor: 'is-danger',
}) });
if (!status) { if (!status) {
return return;
} }
commandHistory.value = [] commandHistory.value = [];
} };
const removeFromHistory = (index: number) => commandHistory.value = commandHistory.value.filter((_, i) => i !== index) const removeFromHistory = (index: number) =>
(commandHistory.value = commandHistory.value.filter((_, i) => i !== index));
watch(isMultiLineInput, () => focusInput()) watch(isMultiLineInput, () => focusInput());
onMounted(async () => { onMounted(async () => {
document.addEventListener('resize', handle_event); document.addEventListener('resize', handle_event);
focusInput() focusInput();
disableOpacity() disableOpacity();
await ensureTerminal() await ensureTerminal();
if (storedCommand.value) { if (storedCommand.value) {
command.value = storedCommand.value command.value = storedCommand.value;
await nextTick() await nextTick();
} }
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
sseController.value?.abort() sseController.value?.abort();
document.removeEventListener('resize', handle_event) document.removeEventListener('resize', handle_event);
enableOpacity() enableOpacity();
}); });
</script> </script>

View file

@ -5,7 +5,9 @@
<span class="title is-4"> <span class="title is-4">
<span class="icon-text"> <span class="icon-text">
<template v-if="toggleForm"> <template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }" /></span> <span class="icon"
><i class="fa-solid" :class="{ 'fa-edit': itemRef, 'fa-plus': !itemRef }"
/></span>
<span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span> <span>{{ itemRef ? `Edit - ${item.name}` : 'Add new field' }}</span>
</template> </template>
<template v-else> <template v-else>
@ -17,8 +19,13 @@
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0"> <p class="control has-icons-left" v-if="toggleFilter && items && items.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter displayed content"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -30,25 +37,44 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;"> <button
class="button is-primary"
@click="
resetForm(false);
toggleForm = !toggleForm;
"
>
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Field</span> <span v-if="!isMobile">New Field</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'"> v-tooltip.bottom="'Change display style'"
class="button has-tooltip-bottom"
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" <i
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="async () => await loadContent(page)" <button
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0"> class="button is-info"
@click="async () => await loadContent(page)"
:class="{ 'is-loading': isLoading }"
:disabled="isLoading"
v-if="items && items.length > 0"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span> <span v-if="!isMobile">Reload</span>
</button> </button>
@ -63,21 +89,40 @@
</div> </div>
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1"> <div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading" <Pager
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" /> :page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="
async (newPage) => {
page = newPage;
await loadContent(newPage);
}
"
/>
</div> </div>
<div class="column is-12" v-if="toggleForm"> <div class="column is-12" v-if="toggleForm">
<DLFieldForm :addInProgress="dlFields.addInProgress.value" :reference="itemRef" :item="(item as DLField)" <DLFieldForm
@cancel="resetForm(true)" @submit="updateItem" /> :addInProgress="dlFields.addInProgress.value"
:reference="itemRef"
:item="item as DLField"
@cancel="resetForm(true)"
@submit="updateItem"
/>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && (filteredItems && filteredItems.length > 0)"> <div
class="columns is-multiline"
v-if="!isLoading && !toggleForm && filteredItems && filteredItems.length > 0"
>
<div class="column is-12" v-if="'list' === display_style"> <div class="column is-12" v-if="'list' === display_style">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 850px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="80%"> <th width="80%">
@ -111,19 +156,28 @@
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(field)"> <button
class="button is-info is-small is-fullwidth"
@click="exportItem(field)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span> <span v-if="!isMobile">Export</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(field)"> <button
class="button is-warning is-small is-fullwidth"
@click="editItem(field)"
>
<span class="icon"><i class="fa-solid fa-edit" /></span> <span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span> <span v-if="!isMobile">Edit</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(field)"> <button
class="button is-danger is-small is-fullwidth"
@click="deleteItem(field)"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span> <span v-if="!isMobile">Delete</span>
</button> </button>
@ -149,7 +203,11 @@
</span> </span>
</div> </div>
<div class="control"> <div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(field)"> <a
class="has-text-info"
v-tooltip="'Export item'"
@click.prevent="exportItem(field)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
</div> </div>
@ -187,19 +245,32 @@
</template> </template>
</div> </div>
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"> <div
class="columns is-multiline"
v-if="!toggleForm && (isLoading || !filteredItems || filteredItems.length < 1)"
>
<div class="column is-12"> <div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin"> <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait... Loading data. Please wait...
</Message> </Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true" <Message
@close="query = ''"> title="No Results"
<p>No results found for the query: <code>{{ query }}</code>.</p> class="is-warning"
icon="fas fa-search"
v-else-if="query"
:useClose="true"
@close="query = ''"
>
<p>
No results found for the query: <code>{{ query }}</code
>.
</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
<Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle"> <Message v-else title="No items" class="is-warning" icon="fas fa-exclamation-circle">
There are no custom defined fields yet. Click the <span class="icon"><i class="fas fa-add" /></span> There are no custom defined fields yet. Click the
<strong>New Field</strong> button to add your first field. <span class="icon"><i class="fas fa-add" /></span> <strong>New Field</strong> button to
add your first field.
</Message> </Message>
</div> </div>
</div> </div>
@ -207,92 +278,98 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { DLField } from '~/types/dl_fields' import type { DLField } from '~/types/dl_fields';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { useDlFields } from '~/composables/useDlFields' import { useDlFields } from '~/composables/useDlFields';
import type { APIResponse } from '~/types/responses' import type { APIResponse } from '~/types/responses';
import { copyText, encode } from '~/utils' import { copyText, encode } from '~/utils';
const box = useConfirm() const box = useConfirm();
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid') const display_style = useStorage<'list' | 'grid'>('dl_fields_display_style', 'grid');
const dlFields = useDlFields() const dlFields = useDlFields();
const route = useRoute() const route = useRoute();
const items = dlFields.dlFields as Ref<DLField[]> const items = dlFields.dlFields as Ref<DLField[]>;
const paging = dlFields.pagination const paging = dlFields.pagination;
const isLoading = dlFields.isLoading const isLoading = dlFields.isLoading;
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1) const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1);
const item = ref<Partial<DLField>>({}) const item = ref<Partial<DLField>>({});
const itemRef = ref<number | null | undefined>(null) const itemRef = ref<number | null | undefined>(null);
const toggleForm = ref(false) const toggleForm = ref(false);
const query = ref<string>('') const query = ref<string>('');
const toggleFilter = ref(false) const toggleFilter = ref(false);
const filteredItems = computed<DLField[]>(() => { const filteredItems = computed<DLField[]>(() => {
const q = query.value?.toLowerCase() const q = query.value?.toLowerCase();
if (!q) return items.value if (!q) return items.value;
return items.value.filter(entry => deepIncludes(entry, q, new WeakSet())) return items.value.filter((entry) => deepIncludes(entry, q, new WeakSet()));
}) });
const loadContent = async (pageNumber: number = 1): Promise<void> => { const loadContent = async (pageNumber: number = 1): Promise<void> => {
await dlFields.loadDlFields(pageNumber) await dlFields.loadDlFields(pageNumber);
await nextTick() await nextTick();
if (dlFields.pagination.value.total_pages > 1) { if (dlFields.pagination.value.total_pages > 1) {
useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } }) useRouter().replace({ query: { ...route.query, page: pageNumber.toString() } });
} }
} };
watch(toggleFilter, value => { watch(toggleFilter, (value) => {
if (!value) { if (!value) {
query.value = '' query.value = '';
} }
}) });
const resetForm = (closeForm = false): void => { const resetForm = (closeForm = false): void => {
item.value = {} item.value = {};
itemRef.value = null itemRef.value = null;
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false;
} }
} };
const deleteItem = async (field: DLField): Promise<void> => { const deleteItem = async (field: DLField): Promise<void> => {
if (true !== (await box.confirm(`Delete '${field.name}'?`))) { if (true !== (await box.confirm(`Delete '${field.name}'?`))) {
return return;
} }
await dlFields.deleteDlField(field.id!) await dlFields.deleteDlField(field.id!);
} };
const updateItem = async ({ reference, item: updatedItem }: { const updateItem = async ({
reference: number | null | undefined, reference,
item: DLField item: updatedItem,
}: {
reference: number | null | undefined;
item: DLField;
}): Promise<void> => { }): Promise<void> => {
const cb = (resp: APIResponse) => { const cb = (resp: APIResponse) => {
if (resp.success) { if (resp.success) {
resetForm(true) resetForm(true);
} }
} };
if (reference) { if (reference) {
await dlFields.patchDlField(reference, updatedItem, cb) await dlFields.patchDlField(reference, updatedItem, cb);
} else { } else {
await dlFields.createDlField(updatedItem, cb) await dlFields.createDlField(updatedItem, cb);
} }
} };
const editItem = (field: DLField): void => { const editItem = (field: DLField): void => {
item.value = { ...field } item.value = { ...field };
itemRef.value = field.id itemRef.value = field.id;
toggleForm.value = true toggleForm.value = true;
} };
const exportItem = (field: DLField): void => copyText(encode({ const exportItem = (field: DLField): void =>
...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)), copyText(
_type: 'dl_field', encode({
_version: '1.0', ...Object.fromEntries(Object.entries(field).filter(([k, v]) => !!v && 'id' !== k)),
})) _type: 'dl_field',
_version: '1.0',
}),
);
onMounted(async () => await loadContent(page.value)) onMounted(async () => await loadContent(page.value));
</script> </script>

View file

@ -12,8 +12,13 @@
<div class="is-pulled-right"> <div class="is-pulled-right">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter"> <p class="control has-icons-left" v-if="toggleFilter">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter displayed content"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -25,28 +30,49 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused"> <button
class="button is-warning"
@click="pauseDownload"
v-if="false === config.paused"
>
<span class="icon"><i class="fas fa-pause" /></span> <span class="icon"><i class="fas fa-pause" /></span>
<span v-if="!isMobile">Pause</span> <span v-if="!isMobile">Pause</span>
</button> </button>
<button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'"> <button
class="button is-danger"
@click="resumeDownload"
v-else
v-tooltip.bottom="'Resume downloading.'"
>
<span class="icon"><i class="fas fa-play" /></span> <span class="icon"><i class="fas fa-play" /></span>
<span v-if="!isMobile">Resume</span> <span v-if="!isMobile">Resume</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm"> <button
class="button is-primary has-tooltip-bottom"
@click="config.showForm = !config.showForm"
>
<span class="icon"><i class="fa-solid fa-plus" /></span> <span class="icon"><i class="fa-solid fa-plus" /></span>
<span v-if="!isMobile">Add</span> <span v-if="!isMobile">Add</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => changeDisplay()"> v-tooltip.bottom="'Change display style'"
<span class="icon"><i class="fa-solid" class="button has-tooltip-bottom"
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> @click="() => changeDisplay()"
>
<span class="icon"
><i
class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
@ -55,15 +81,19 @@
</div> </div>
</div> </div>
<div v-if="!isMobile"> <div v-if="!isMobile">
<span class="subtitle"> <span class="subtitle"> Queued and completed downloads are displayed here. </span>
Queued and completed downloads are displayed here.
</span>
</div> </div>
</div> </div>
</div> </div>
<NewDownload v-if="config.showForm" :item="item_form" @clear_form="item_form = {}" <NewDownload
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" /> v-if="config.showForm"
:item="item_form"
@clear_form="item_form = {}"
@getInfo="
(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)
"
/>
<div class="tabs is-boxed is-medium"> <div class="tabs is-boxed is-medium">
<ul> <ul>
@ -86,132 +116,170 @@
<div class="tab-content"> <div class="tab-content">
<div v-show="'queue' === activeTab"> <div v-show="'queue' === activeTab">
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" <Queue
:thumbnails="show_thumbnail" :query="query" @getInfo="
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> (url: string, preset: string = '', cli: string = '') =>
view_info(url, false, preset, cli)
"
:thumbnails="show_thumbnail"
:query="query"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''"
/>
</div> </div>
<div v-show="'history' === activeTab"> <div v-show="'history' === activeTab">
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" <History
@add_new="(item: item_request) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail" @getInfo="
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> (url: string, preset: string = '', cli: string = '') =>
view_info(url, false, preset, cli)
"
@add_new="(item: item_request) => toNewDownload(item)"
:query="query"
:thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''"
/>
</div> </div>
</div> </div>
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli" <GetInfo
:useUrl="info_view.useUrl" @closeModel="close_info()" /> v-if="info_view.url"
:link="info_view.url"
:preset="info_view.preset"
:cli="info_view.cli"
:useUrl="info_view.useUrl"
@closeModel="close_info()"
/>
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title" <ConfirmDialog
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm" v-if="dialog_confirm.visible"
@cancel="() => dialog_confirm.visible = false" /> :visible="dialog_confirm.visible"
:title="dialog_confirm.title"
:html_message="dialog_confirm.html_message"
:options="dialog_confirm.options"
@confirm="dialog_confirm.confirm"
@cancel="() => (dialog_confirm.visible = false)"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store';
const config = useConfigStore() const config = useConfigStore();
const stateStore = useStateStore() const stateStore = useStateStore();
const route = useRoute() const route = useRoute();
const router = useRouter() const router = useRouter();
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const display_style = useStorage<string>('display_style', 'grid') const display_style = useStorage<string>('display_style', 'grid');
const show_thumbnail = useStorage<boolean>('show_thumbnail', true) const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const info_view = ref({ const info_view = ref({
url: '', url: '',
preset: '', preset: '',
cli: '', cli: '',
useUrl: false, useUrl: false,
}) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }> }) as Ref<{ url: string; preset: string; cli: string; useUrl: boolean }>;
const item_form = ref<item_request | object>({}) const item_form = ref<item_request | object>({});
const query = ref() const query = ref();
const toggleFilter = ref(false) const toggleFilter = ref(false);
const dialog_confirm = ref({ const dialog_confirm = ref({
visible: false, visible: false,
title: 'Confirm Action', title: 'Confirm Action',
confirm: () => { }, confirm: () => {},
html_message: '', html_message: '',
options: [], options: [],
}) });
// Tab management with URL state // Tab management with URL state
type TabType = 'queue' | 'history' type TabType = 'queue' | 'history';
const activeTab = ref<TabType>('queue') const activeTab = ref<TabType>('queue');
const getInitialTab = (): TabType => { const getInitialTab = (): TabType => {
const tabParam = route.query.tab as string const tabParam = route.query.tab as string;
return (tabParam === 'queue' || tabParam === 'history') ? tabParam : 'queue' return tabParam === 'queue' || tabParam === 'history' ? tabParam : 'queue';
} };
const setActiveTab = async (tab: TabType): Promise<void> => { const setActiveTab = async (tab: TabType): Promise<void> => {
activeTab.value = tab activeTab.value = tab;
await router.push({ query: { ...route.query, tab }, replace: true }) await router.push({ query: { ...route.query, tab }, replace: true });
} };
watch(() => route.query.tab, (newTab) => { watch(
if (!['queue', 'history'].includes(newTab as string)) { () => route.query.tab,
return (newTab) => {
} if (!['queue', 'history'].includes(newTab as string)) {
activeTab.value = newTab as TabType return;
}) }
activeTab.value = newTab as TabType;
},
);
onMounted(async () => { onMounted(async () => {
const route = useRoute() const route = useRoute();
if (route.query?.simple !== undefined) { if (route.query?.simple !== undefined) {
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false) const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string) simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
await nextTick() await nextTick();
const url = new URL(window.location.href) const url = new URL(window.location.href);
url.searchParams.delete('simple') url.searchParams.delete('simple');
window.history.replaceState({}, '', url.toString()) window.history.replaceState({}, '', url.toString());
} }
activeTab.value = getInitialTab() activeTab.value = getInitialTab();
useHead({ title: getTitle() }) useHead({ title: getTitle() });
}) });
const queueCount = computed(() => stateStore.count('queue')) const queueCount = computed(() => stateStore.count('queue'));
const historyCount = computed(() => stateStore.count('history')) const historyCount = computed(() => stateStore.count('history'));
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = '' query.value = '';
} }
}); });
const getTitle = (): string => { const getTitle = (): string => {
if (!config.app.ui_update_title) { if (!config.app.ui_update_title) {
return 'YTPTube' return 'YTPTube';
} }
return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )` return `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers}:${config.app.max_workers_per_extractor} | ${Object.keys(stateStore.history).length || 0} )`;
} };
watch(() => stateStore.history, () => { watch(
if (!config.app.ui_update_title) { () => stateStore.history,
return () => {
} if (!config.app.ui_update_title) {
useHead({ title: getTitle() }) return;
}, { deep: true }) }
useHead({ title: getTitle() });
},
{ deep: true },
);
watch(() => stateStore.queue, () => { watch(
if (!config.app.ui_update_title) { () => stateStore.queue,
return () => {
} if (!config.app.ui_update_title) {
useHead({ title: getTitle() }) return;
}, { deep: true }) }
useHead({ title: getTitle() });
},
{ deep: true },
);
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' }) const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' });
const pauseDownload = () => { const pauseDownload = () => {
dialog_confirm.value.visible = true dialog_confirm.value.visible = true;
dialog_confirm.value.html_message = ` dialog_confirm.value.html_message = `
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span> <span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
@ -223,49 +291,53 @@ const pauseDownload = () => {
<li>This will not stop downloads that are currently in progress.</li> <li>This will not stop downloads that are currently in progress.</li>
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li> <li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
</ul> </ul>
</span>` </span>`;
dialog_confirm.value.confirm = async () => { dialog_confirm.value.confirm = async () => {
await request('/api/system/pause', { method: 'POST' }) await request('/api/system/pause', { method: 'POST' });
dialog_confirm.value.visible = false dialog_confirm.value.visible = false;
} };
} };
const close_info = () => { const close_info = () => {
info_view.value.url = '' info_view.value.url = '';
info_view.value.preset = '' info_view.value.preset = '';
info_view.value.useUrl = false info_view.value.useUrl = false;
} };
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => { const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
info_view.value.url = url info_view.value.url = url;
info_view.value.useUrl = useUrl info_view.value.useUrl = useUrl;
info_view.value.preset = preset info_view.value.preset = preset;
info_view.value.cli = cli info_view.value.cli = cli;
} };
watch(() => info_view.value.url, v => { watch(
if (!bg_enable.value) { () => info_view.value.url,
return (v) => {
} if (!bg_enable.value) {
return;
}
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
}) },
);
const changeDisplay = () => display_style.value = display_style.value === 'grid' ? 'list' : 'grid' const changeDisplay = () =>
(display_style.value = display_style.value === 'grid' ? 'list' : 'grid');
const toNewDownload = async (item: item_request | Partial<StoreItem>) => { const toNewDownload = async (item: item_request | Partial<StoreItem>) => {
if (!item) { if (!item) {
return return;
} }
if (config.showForm) { if (config.showForm) {
config.showForm = false config.showForm = false;
await nextTick() await nextTick();
} }
item_form.value = item item_form.value = item;
await nextTick() await nextTick();
config.showForm = true config.showForm = true;
} };
</script> </script>

View file

@ -5,17 +5,19 @@
max-width: 100%; max-width: 100%;
} }
#logView>span:nth-child(even) { #logView > span:nth-child(even) {
color: #ffc9d4; color: #ffc9d4;
} }
#logView>span:nth-child(odd) { #logView > span:nth-child(odd) {
color: #e3c981; color: #e3c981;
} }
.logbox { .logbox {
background-color: #1f2229 !important; background-color: #1f2229 !important;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); box-shadow:
0 4px 8px 0 rgba(0, 0, 0, 0.2),
0 6px 20px 0 rgba(0, 0, 0, 0.19);
min-width: 100%; min-width: 100%;
max-height: 73vh; max-height: 73vh;
overflow-y: scroll; overflow-y: scroll;
@ -52,8 +54,13 @@ code {
</div> </div>
<div class="control has-icons-left" v-if="toggleFilter || query"> <div class="control has-icons-left" v-if="toggleFilter || query">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter displayed content"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</div> </div>
@ -63,9 +70,12 @@ code {
</button> </button>
</div> </div>
<p class="control"> <p class="control">
<button class="button is-purple" @click="textWrap = !textWrap" v-tooltip.bottom="'Toggle text wrap'"> <button
class="button is-purple"
@click="textWrap = !textWrap"
v-tooltip.bottom="'Toggle text wrap'"
>
<span class="icon"><i class="fas fa-text-width"></i></span> <span class="icon"><i class="fas fa-text-width"></i></span>
</button> </button>
</p> </p>
@ -73,35 +83,50 @@ code {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle">The logs are being streamed in real-time. You can scroll up to view older logs.</span> <span class="subtitle"
>The logs are being streamed in real-time. You can scroll up to view older logs.</span
>
</div> </div>
</div> </div>
<div class="column is-12"> <div class="column is-12">
<div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll"> <div class="logbox is-grid" ref="logContainer" @scroll.passive="handleScroll">
<code id="logView" class="p-2 logline is-block" <code
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"> id="logView"
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query"> class="p-2 logline is-block"
:class="{ 'is-pre-wrap': true === textWrap, 'is-pre': false === textWrap }"
>
<span
class="is-block m-0 notification is-info is-dark has-text-centered"
v-if="reachedEnd && !query"
>
<span class="notification-title"> <span class="notification-title">
<span class="icon"><i class="fas fa-exclamation-triangle"/></span> <span class="icon"><i class="fas fa-exclamation-triangle" /></span>
No more logs available for this file. No more logs available for this file.
</span> </span>
</span> </span>
<span v-for="log in filteredItems" :key="log.id" class="is-block"> <span v-for="log in filteredItems" :key="log.id" class="is-block">
<template v-if="log?.datetime">[<span class="has-tooltip" :title="log.datetime">{{ moment(log.datetime).format('HH:mm:ss') }}</span>]</template> <template v-if="log?.datetime"
{{ log.line }} >[<span class="has-tooltip" :title="log.datetime">{{
</span> moment(log.datetime).format('HH:mm:ss')
<span class="is-block" v-if="filteredItems.length < 1"> }}</span
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query"> >]</template
<span class="notification-title is-danger"> >
<span class="icon"><i class="fas fa-filter" /></span> {{ log.line }}
No logs match this query: <u>{{ query }}</u> </span>
</span> <span class="is-block" v-if="filteredItems.length < 1">
</span> <span
<span v-else> class="is-block m-0 notification is-warning is-dark has-text-centered"
<span class="has-text-danger">No logs available</span></span> v-if="query"
</span> >
</code> <span class="notification-title is-danger">
<span class="icon"><i class="fas fa-filter" /></span>
No logs match this query: <u>{{ query }}</u>
</span>
</span>
<span v-else> <span class="has-text-danger">No logs available</span></span>
</span>
</code>
<div ref="bottomMarker"></div> <div ref="bottomMarker"></div>
</div> </div>
</div> </div>
@ -110,263 +135,274 @@ code {
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { fetchEventSource } from '@microsoft/fetch-event-source' import { fetchEventSource } from '@microsoft/fetch-event-source';
import type { EventSourceMessage } from '@microsoft/fetch-event-source' import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import moment from 'moment' import moment from 'moment';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs' import type { log_line } from '~/types/logs';
import { parse_api_error, uri } from '~/utils' import { parse_api_error, uri } from '~/utils';
let scrollTimeout: NodeJS.Timeout | null = null let scrollTimeout: NodeJS.Timeout | null = null;
const toast = useNotification() const toast = useNotification();
const config = useConfigStore() const config = useConfigStore();
const route = useRoute() const route = useRoute();
const logContainer = useTemplateRef<HTMLDivElement>('logContainer') const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker') const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
const textWrap = useStorage<boolean>('logs_wrap', true) const textWrap = useStorage<boolean>('logs_wrap', true);
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true);
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
const sseController = ref<AbortController | null>(null) const sseController = ref<AbortController | null>(null);
const logs = ref<Array<log_line>>([]) const logs = ref<Array<log_line>>([]);
const offset = ref<number>(0) const offset = ref<number>(0);
const loading = ref<boolean>(false) const loading = ref<boolean>(false);
const autoScroll = ref<boolean>(true) const autoScroll = ref<boolean>(true);
const reachedEnd = ref<boolean>(false) const reachedEnd = ref<boolean>(false);
const query = ref<string>((() => { const query = ref<string>(
const filter = route.query.filter ?? '' (() => {
if (!filter) { const filter = route.query.filter ?? '';
return '' if (!filter) {
} return '';
if (typeof filter === 'string') { }
return filter.trim() if (typeof filter === 'string') {
} return filter.trim();
if (Array.isArray(filter) && filter.length > 0) { }
return filter[0]?.trim() ?? '' if (Array.isArray(filter) && filter.length > 0) {
} return filter[0]?.trim() ?? '';
return '' }
})()) return '';
})(),
);
const toggleFilter = ref(false) const toggleFilter = ref(false);
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
query.value = '' query.value = '';
scrollToBottom(true) scrollToBottom(true);
} }
}); });
watch(() => config.app.file_logging, async v => { watch(
if (v) { () => config.app.file_logging,
return async (v) => {
} if (v) {
await navigateTo('/') return;
}) }
await navigateTo('/');
},
);
const filteredItems = computed(() => { const filteredItems = computed(() => {
const raw = query.value.trim().toLowerCase() const raw = query.value.trim().toLowerCase();
const contextMatch = raw.match(/context:(\d+)/) const contextMatch = raw.match(/context:(\d+)/);
const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0 const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0;
const searchTerm = raw.replace(/context:\d+/, '').trim() const searchTerm = raw.replace(/context:\d+/, '').trim();
if (!searchTerm) { if (!searchTerm) {
return logs.value return logs.value;
} }
const result: Array<log_line> = [] const result: Array<log_line> = [];
const matchedIndexes = new Set() const matchedIndexes = new Set();
logs.value.forEach((log, i) => { logs.value.forEach((log, i) => {
if (log.line.toLowerCase().includes(searchTerm)) { if (log.line.toLowerCase().includes(searchTerm)) {
for (let j = Math.max(0, i - context); j <= Math.min(logs.value.length - 1, i + context); j++) { for (
matchedIndexes.add(j) let j = Math.max(0, i - context);
j <= Math.min(logs.value.length - 1, i + context);
j++
) {
matchedIndexes.add(j);
} }
} }
}) });
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => { Array.from(matchedIndexes)
result.push(logs.value[index] as log_line) .sort((a: any, b: any) => a - b)
}) .forEach((index: any) => {
result.push(logs.value[index] as log_line);
});
return result return result;
}) });
const fetchLogs = async () => { const fetchLogs = async () => {
loading.value = true loading.value = true;
if (reachedEnd.value || query.value) { if (reachedEnd.value || query.value) {
return return;
} }
try { try {
const req = await request(`/api/logs?offset=${offset.value}`) const req = await request(`/api/logs?offset=${offset.value}`);
if (!req.ok) { if (!req.ok) {
toast.error('Failed to fetch logs'); toast.error('Failed to fetch logs');
return return;
} }
const response = await req.json() const response = await req.json();
if (response.error) { if (response.error) {
toast.error(response.error) toast.error(response.error);
return return;
} }
const lines = response.logs ?? []; const lines = response.logs ?? [];
if (lines.length) { if (lines.length) {
logs.value.unshift(...response.logs) logs.value.unshift(...response.logs);
} }
if (response?.next_offset) { if (response?.next_offset) {
offset.value = response.next_offset offset.value = response.next_offset;
} }
if (response?.end_is_reached) { if (response?.end_is_reached) {
reachedEnd.value = true reachedEnd.value = true;
} }
nextTick(() => { nextTick(() => {
if (autoScroll.value && bottomMarker.value) { if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'auto' }) bottomMarker.value.scrollIntoView({ behavior: 'auto' });
} }
}) });
} catch (err) { } catch (err) {
console.error('Failed to fetch logs:', err) console.error('Failed to fetch logs:', err);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
const handleScroll = () => { const handleScroll = () => {
if (!logContainer.value || query.value) { if (!logContainer.value || query.value) {
return return;
} }
const container = logContainer.value const container = logContainer.value;
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50 const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
const nearTop = container.scrollTop < 50 const nearTop = container.scrollTop < 50;
autoScroll.value = nearBottom autoScroll.value = nearBottom;
if (nearTop && !loading.value && !scrollTimeout) { if (nearTop && !loading.value && !scrollTimeout) {
scrollTimeout = setTimeout(async () => { scrollTimeout = setTimeout(async () => {
const previousHeight = container.scrollHeight const previousHeight = container.scrollHeight;
await fetchLogs() await fetchLogs();
nextTick(() => { nextTick(() => {
const newHeight = container.scrollHeight const newHeight = container.scrollHeight;
container.scrollTop += newHeight - previousHeight container.scrollTop += newHeight - previousHeight;
}) });
scrollTimeout = null scrollTimeout = null;
}, 300) }, 300);
} }
} };
const scrollToBottom = (fast = false) => { const scrollToBottom = (fast = false) => {
autoScroll.value = true autoScroll.value = true;
nextTick(() => { nextTick(() => {
if (bottomMarker.value) { if (bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
} }
}) });
} };
const handleStreamMessage = (event: EventSourceMessage) => { const handleStreamMessage = (event: EventSourceMessage) => {
if ('log_lines' !== event.event) { if ('log_lines' !== event.event) {
return return;
} }
if (!event.data) { if (!event.data) {
return return;
} }
let payload: log_line | null = null let payload: log_line | null = null;
try { try {
payload = JSON.parse(event.data) as log_line payload = JSON.parse(event.data) as log_line;
} catch { } catch {
payload = null payload = null;
} }
if (!payload) { if (!payload) {
return return;
} }
logs.value.push(payload) logs.value.push(payload);
nextTick(() => { nextTick(() => {
if (autoScroll.value && bottomMarker.value) { if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' }) bottomMarker.value.scrollIntoView({ behavior: 'smooth' });
} }
}) });
} };
const startLogStream = async () => { const startLogStream = async () => {
sseController.value?.abort() sseController.value?.abort();
const controller = new AbortController() const controller = new AbortController();
sseController.value = controller sseController.value = controller;
try { try {
await fetchEventSource(uri('/api/logs/stream'), { await fetchEventSource(uri('/api/logs/stream'), {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'text/event-stream', Accept: 'text/event-stream',
}, },
credentials: 'same-origin', credentials: 'same-origin',
signal: controller.signal, signal: controller.signal,
onopen: async (response) => { onopen: async (response) => {
if (response.ok) { if (response.ok) {
return return;
} }
let message = response.statusText || 'Failed to start log stream.' let message = response.statusText || 'Failed to start log stream.';
try { try {
message = await parse_api_error(response.clone().json()) message = await parse_api_error(response.clone().json());
} catch { } catch {
try { try {
const text = await response.text() const text = await response.text();
if (text) { if (text) {
message = text message = text;
} }
} catch { } catch {
message = response.statusText || 'Failed to start log stream.' message = response.statusText || 'Failed to start log stream.';
} }
} }
throw new Error(message) throw new Error(message);
}, },
onmessage: handleStreamMessage, onmessage: handleStreamMessage,
onerror: (error) => { onerror: (error) => {
if (controller.signal.aborted) { if (controller.signal.aborted) {
return return;
} }
console.error('Log stream error:', error) console.error('Log stream error:', error);
}, },
}) });
} catch (error) { } catch (error) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
console.error('Log stream error:', error) console.error('Log stream error:', error);
} }
} finally { } finally {
if (controller === sseController.value) { if (controller === sseController.value) {
sseController.value = null sseController.value = null;
} }
} }
} };
onMounted(async () => { onMounted(async () => {
await fetchLogs() await fetchLogs();
await startLogStream() await startLogStream();
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`) document.querySelector('body')?.setAttribute('style', `opacity: 1.0`);
} }
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
sseController.value?.abort() sseController.value?.abort();
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute('style', `opacity: ${bg_opacity.value}`);
} }
if (scrollTimeout) clearTimeout(scrollTimeout) if (scrollTimeout) clearTimeout(scrollTimeout);
}) });
useHead({ title: 'Logs' }) useHead({ title: 'Logs' });
</script> </script>

View file

@ -5,7 +5,9 @@
<span class="title is-4"> <span class="title is-4">
<span class="icon-text"> <span class="icon-text">
<template v-if="toggleForm"> <template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }" /></span> <span class="icon"
><i class="fa-solid" :class="{ 'fa-edit': targetRef, 'fa-plus': !targetRef }"
/></span>
<span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span> <span>{{ targetRef ? `Edit - ${target.name}` : 'Add new notification target' }}</span>
</template> </template>
<template v-else> <template v-else>
@ -16,9 +18,17 @@
</span> </span>
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && notifications && notifications.length > 0"> <p
<input type="search" v-model.lazy="query" class="input" id="filter" class="control has-icons-left"
placeholder="Filter displayed content"> v-if="toggleFilter && notifications && notifications.length > 0"
>
<input
type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -30,25 +40,44 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = true" <button
v-tooltip="'Add new notification target.'"> class="button is-primary"
@click="
resetForm(false);
toggleForm = true;
"
v-tooltip="'Add new notification target.'"
>
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Notification</span> <span v-if="!isMobile">New Notification</span>
</button> </button>
</p> </p>
<p class="control" v-if="notifications.length > 0"> <p class="control" v-if="notifications.length > 0">
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'" <button
:class="{ 'is-loading': sendingTest }" :disabled="!notifications.length || sendingTest"> class="button is-warning"
@click="sendTest"
v-tooltip="'Send test notification.'"
:class="{ 'is-loading': sendingTest }"
:disabled="!notifications.length || sendingTest"
>
<span class="icon"><i class="fas fa-paper-plane" /></span> <span class="icon"><i class="fas fa-paper-plane" /></span>
<span v-if="!isMobile">Send Test</span> <span v-if="!isMobile">Send Test</span>
</button> </button>
</p> </p>
<p class="control" v-if="notifications.length > 0"> <p class="control" v-if="notifications.length > 0">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'"> v-tooltip.bottom="'Change display style'"
class="button has-tooltip-bottom"
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" <i
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
@ -56,8 +85,12 @@
</p> </p>
<p class="control" v-if="notifications.length > 0"> <p class="control" v-if="notifications.length > 0">
<button class="button is-info" @click="loadContent(page)" :class="{ 'is-loading': isLoading }" <button
:disabled="isLoading || notifications.length < 1"> class="button is-info"
@click="loadContent(page)"
:class="{ 'is-loading': isLoading }"
:disabled="isLoading || notifications.length < 1"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span> <span v-if="!isMobile">Reload</span>
</button> </button>
@ -72,22 +105,42 @@
</div> </div>
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1"> <div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading" <Pager
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" /> :page="paging.page"
:last_page="paging.total_pages"
:isLoading="isLoading"
@navigate="
async (newPage) => {
page = newPage;
await loadContent(newPage);
}
"
/>
</div> </div>
<div class="column is-12" v-if="toggleForm"> <div class="column is-12" v-if="toggleForm">
<NotificationForm :addInProgress="addInProgress" :reference="targetRef" :item="target" <NotificationForm
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" /> :addInProgress="addInProgress"
:reference="targetRef"
:item="target"
@cancel="resetForm(true)"
@submit="updateItem"
:allowedEvents="allowedEvents"
/>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0"> <div
class="columns is-multiline"
v-if="!isLoading && !toggleForm && filteredTargets && filteredTargets.length > 0"
>
<template v-if="'list' === display_style"> <template v-if="'list' === display_style">
<div class="column is-12"> <div class="column is-12">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 850px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="80%"> <th width="80%">
@ -119,8 +172,13 @@
</span> </span>
&nbsp; &nbsp;
<span class="icon-text is-clickable" @click="toggleEnabled(item)"> <span class="icon-text is-clickable" @click="toggleEnabled(item)">
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'" <span
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`"> class="icon"
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
"
>
<i class="fa-solid fa-power-off" /> <i class="fa-solid fa-power-off" />
</span> </span>
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span> <span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
@ -130,19 +188,28 @@
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)"> <button
class="button is-info is-small is-fullwidth"
@click="exportItem(item)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span> <span v-if="!isMobile">Export</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)"> <button
class="button is-warning is-small is-fullwidth"
@click="editItem(item)"
>
<span class="icon"><i class="fa-solid fa-edit" /></span> <span class="icon"><i class="fa-solid fa-edit" /></span>
<span v-if="!isMobile">Edit</span> <span v-if="!isMobile">Edit</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)"> <button
class="button is-danger is-small is-fullwidth"
@click="deleteItem(item)"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span> <span v-if="!isMobile">Delete</span>
</button> </button>
@ -166,13 +233,22 @@
<div class="card-header-icon"> <div class="card-header-icon">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control" @click="toggleEnabled(item)"> <div class="control" @click="toggleEnabled(item)">
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'" <span
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`"> class="icon"
:class="item.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="
`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`
"
>
<i class="fa-solid fa-power-off" /> <i class="fa-solid fa-power-off" />
</span> </span>
</div> </div>
<div class="control"> <div class="control">
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)"> <a
class="has-text-info"
v-tooltip="'Export target.'"
@click.prevent="exportItem(item)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
</div> </div>
@ -191,13 +267,13 @@
</p> </p>
<p v-if="item.request?.headers && item.request.headers.length > 0"> <p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span> <span class="icon"><i class="fa-solid fa-heading" /></span>
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span> <span>Headers: {{ item.request.headers.map((h) => h.key).join(', ') }}</span>
</p> </p>
</div> </div>
</div> </div>
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);"> <button class="button is-warning is-fullwidth" @click="editItem(item)">
<span class="icon"><i class="fa-solid fa-edit" /></span> <span class="icon"><i class="fa-solid fa-edit" /></span>
<span>Edit</span> <span>Edit</span>
</button> </button>
@ -214,47 +290,65 @@
</template> </template>
</div> </div>
<div class="columns is-multiline" <div
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"> class="columns is-multiline"
v-if="!toggleForm && (isLoading || !filteredTargets || filteredTargets.length < 1)"
>
<div class="column is-12"> <div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin"> <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait... Loading data. Please wait...
</Message> </Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true" <Message
@close="query = ''"> title="No Results"
<p>No results found for the query: <code>{{ query }}</code>.</p> class="is-warning"
icon="fas fa-search"
v-else-if="query"
:useClose="true"
@close="query = ''"
>
<p>
No results found for the query: <code>{{ query }}</code
>.
</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
<Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle"> <Message v-else title="No targets" class="is-warning" icon="fas fa-exclamation-circle">
No notification targets found. Click on the <span class="icon"><i class="fas fa-add" /></span> <strong>New No notification targets found. Click on the
Notification</strong> button to add your first notification target. <span class="icon"><i class="fas fa-add" /></span>
<strong>New Notification</strong> button to add your first notification target.
</Message> </Message>
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="!toggleForm && filteredTargets && filteredTargets.length > 0"> <div
class="columns is-multiline"
v-if="!toggleForm && filteredTargets && filteredTargets.length > 0"
>
<div class="column is-12"> <div class="column is-12">
<Message class="is-info" :body_class="'pl-0'"> <Message class="is-info" :body_class="'pl-0'">
<ul> <ul>
<li> <li>
When you export notification target, We remove <code>Authorization</code> header key by default, When you export notification target, We remove <code>Authorization</code> header key
However this might not be enough to remove credentials from the exported data. it's your responsibility by default, However this might not be enough to remove credentials from the exported
to ensure that the exported data does not contain any sensitive information for sharing. data. it's your responsibility to ensure that the exported data does not contain any
sensitive information for sharing.
</li> </li>
<li> <li>
When you set the request type as <code>Form</code>, the event data will be JSON encoded and sent as When you set the request type as <code>Form</code>, the event data will be JSON
<code>...&data_key=json_string</code>, only the <code>data</code> field will be JSON encoded. encoded and sent as <code>...&data_key=json_string</code>, only the
The other keys <code>id</code>, <code>event</code> and <code>created_at</code> will be sent as they are. <code>data</code> field will be JSON encoded. The other keys <code>id</code>,
</li> <code>event</code> and <code>created_at</code> will be sent as they are.
<li>We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with the request.
</li> </li>
<li> <li>
If you have selected specific presets or events, this will take priority, For example, if you limited We also send two special headers <code>X-Event-ID</code> and <code>X-Event</code> with
the the request.
target to <code>default</code> preset and selected <code>ALL</code> events, only events that reference </li>
the <li>
<code>default</code> preset will be sent to that target. Like wise, if you have limited both events and If you have selected specific presets or events, this will take priority, For example,
presets, then ONLY events that satisfy both conditions will be sent to that target. Only the if you limited the target to <code>default</code> preset and selected
<code>ALL</code> events, only events that reference the <code>default</code> preset
will be sent to that target. Like wise, if you have limited both events and presets,
then ONLY events that satisfy both conditions will be sent to that target. Only the
<code>test</code> events can bypass these conditions. <code>test</code> events can bypass these conditions.
</li> </li>
</ul> </ul>
@ -265,29 +359,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { notification } from '~/types/notification' import type { notification } from '~/types/notification';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { useNotifications } from '~/composables/useNotifications' import { useNotifications } from '~/composables/useNotifications';
import type { ImportedItem } from '~/types' import type { ImportedItem } from '~/types';
const toast = useNotification() const toast = useNotification();
const box = useConfirm() const box = useConfirm();
const display_style = useStorage<string>('notification_display_style', 'cards') const display_style = useStorage<string>('notification_display_style', 'cards');
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const notificationsStore = useNotifications() const notificationsStore = useNotifications();
const notifications = notificationsStore.notifications const notifications = notificationsStore.notifications;
const paging = notificationsStore.pagination const paging = notificationsStore.pagination;
const allowedEvents = notificationsStore.events const allowedEvents = notificationsStore.events;
const isLoading = notificationsStore.isLoading const isLoading = notificationsStore.isLoading;
const addInProgress = notificationsStore.addInProgress const addInProgress = notificationsStore.addInProgress;
const lastError = notificationsStore.lastError const lastError = notificationsStore.lastError;
const page = ref(1) const page = ref(1);
const targetRef = ref<number | undefined>(undefined) const targetRef = ref<number | undefined>(undefined);
const toggleForm = ref(false) const toggleForm = ref(false);
const sendingTest = ref(false) const sendingTest = ref(false);
const defaultState = (): notification => ({ const defaultState = (): notification => ({
name: '', name: '',
@ -295,127 +389,137 @@ const defaultState = (): notification => ({
presets: [], presets: [],
enabled: true, enabled: true,
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' }, request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
}) });
const target = ref<notification>(defaultState()) const target = ref<notification>(defaultState());
const query = ref<string>('') const query = ref<string>('');
const toggleFilter = ref(false) const toggleFilter = ref(false);
const filteredTargets = computed<notification[]>(() => { const filteredTargets = computed<notification[]>(() => {
const q = query.value?.toLowerCase() const q = query.value?.toLowerCase();
const items = notifications.value.map(item => ({ ...item })) as notification[] const items = notifications.value.map((item) => ({ ...item })) as notification[];
if (!q) return items if (!q) return items;
return items.filter((item: notification) => deepIncludes(item, q, new WeakSet())) return items.filter((item: notification) => deepIncludes(item, q, new WeakSet()));
}) });
watch(toggleFilter, (val) => { watch(toggleFilter, (val) => {
if (!val) { if (!val) {
query.value = '' query.value = '';
} }
}) });
const loadContent = async (pageNumber = page.value) => { const loadContent = async (pageNumber = page.value) => {
await notificationsStore.loadNotifications(pageNumber) await notificationsStore.loadNotifications(pageNumber);
} };
const resetForm = (closeForm = false) => { const resetForm = (closeForm = false) => {
target.value = defaultState() target.value = defaultState();
targetRef.value = undefined targetRef.value = undefined;
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false;
} }
} };
const deleteItem = async (item: notification) => { const deleteItem = async (item: notification) => {
if (true !== (await box.confirm(`Delete '${item.name}'?`))) { if (true !== (await box.confirm(`Delete '${item.name}'?`))) {
return return;
} }
if (!item.id) { if (!item.id) {
toast.error('Notification target not found.') toast.error('Notification target not found.');
return return;
} }
await notificationsStore.deleteNotification(item.id) await notificationsStore.deleteNotification(item.id);
} };
const toggleEnabled = async (item: notification) => { const toggleEnabled = async (item: notification) => {
if (!item.id) { if (!item.id) {
toast.error('Notification target not found.') toast.error('Notification target not found.');
return return;
} }
await notificationsStore.patchNotification(item.id, { enabled: !item.enabled }) await notificationsStore.patchNotification(item.id, { enabled: !item.enabled });
} };
const updateItem = async ({ reference, item }: { reference: number | undefined, item: notification }) => { const updateItem = async ({
reference,
item,
}: {
reference: number | undefined;
item: notification;
}) => {
if (reference) { if (reference) {
await notificationsStore.updateNotification(reference, item) await notificationsStore.updateNotification(reference, item);
} else { } else {
await notificationsStore.createNotification(item) await notificationsStore.createNotification(item);
} }
if (!lastError.value) { if (!lastError.value) {
resetForm(true) resetForm(true);
} }
} };
const editItem = (item: notification) => { const editItem = (item: notification) => {
target.value = JSON.parse(JSON.stringify(item)) as notification target.value = JSON.parse(JSON.stringify(item)) as notification;
targetRef.value = item.id ?? undefined targetRef.value = item.id ?? undefined;
toggleForm.value = true toggleForm.value = true;
} };
const join_events = (events: Array<string>) => !events || events.length < 1 ? 'ALL' : events.map(e => ucFirst(e)).join(', ') const join_events = (events: Array<string>) =>
const join_presets = (presets: Array<string>) => !presets || presets.length < 1 ? 'ALL' : presets.map(e => ucFirst(e)).join(', ') !events || events.length < 1 ? 'ALL' : events.map((e) => ucFirst(e)).join(', ');
const join_presets = (presets: Array<string>) =>
!presets || presets.length < 1 ? 'ALL' : presets.map((e) => ucFirst(e)).join(', ');
const sendTest = async () => { const sendTest = async () => {
if (true !== (await box.confirm('Send test notification?'))) { if (true !== (await box.confirm('Send test notification?'))) {
return return;
} }
try { try {
sendingTest.value = true sendingTest.value = true;
const response = await request('/api/notifications/test', { method: 'POST' }) const response = await request('/api/notifications/test', { method: 'POST' });
if (!response.ok) { if (!response.ok) {
const data = await response.json() const data = await response.json();
const message = await parse_api_error(data) const message = await parse_api_error(data);
toast.error(`Failed to send test notification. ${message}`) toast.error(`Failed to send test notification. ${message}`);
return return;
} }
toast.success('Test notification sent.') toast.success('Test notification sent.');
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error);
const message = error?.message || 'Unknown error' const message = error?.message || 'Unknown error';
toast.error(`Failed to send test notification. ${message}`) toast.error(`Failed to send test notification. ${message}`);
} finally { } finally {
sendingTest.value = false sendingTest.value = false;
} }
} };
onMounted(async () => await notificationsStore.loadNotifications(page.value)) onMounted(async () => await notificationsStore.loadNotifications(page.value));
const exportItem = async (item: notification) => { const exportItem = async (item: notification) => {
const data: notification & ImportedItem = { const data: notification & ImportedItem = {
...JSON.parse(JSON.stringify(item)), ...JSON.parse(JSON.stringify(item)),
_type: 'notification', _type: 'notification',
_version: '1.0', _version: '1.0',
} };
const keys = ['id', 'raw'] const keys = ['id', 'raw'];
keys.forEach(k => { keys.forEach((k) => {
if (Object.prototype.hasOwnProperty.call(data, k)) { if (Object.prototype.hasOwnProperty.call(data, k)) {
const { [k]: _, ...rest } = data as any const { [k]: _, ...rest } = data as any;
Object.assign(data, rest) Object.assign(data, rest);
} }
}) });
if (data.request?.headers?.length) { if (data.request?.headers?.length) {
data.request.headers = data.request.headers.filter(h => 'authorization' !== h.key.toLowerCase()) data.request.headers = data.request.headers.filter(
(h) => 'authorization' !== h.key.toLowerCase(),
);
} }
copyText(encode(data)) copyText(encode(data));
} };
</script> </script>

View file

@ -5,7 +5,9 @@
<span class="title is-4"> <span class="title is-4">
<span class="icon-text"> <span class="icon-text">
<template v-if="toggleForm"> <template v-if="toggleForm">
<span class="icon"><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }" /></span> <span class="icon"
><i class="fa-solid" :class="{ 'fa-edit': presetRef, 'fa-plus': !presetRef }"
/></span>
<span>{{ presetRef ? `Edit - ${prettyName(preset.name || '')}` : 'Add' }}</span> <span>{{ presetRef ? `Edit - ${prettyName(preset.name || '')}` : 'Add' }}</span>
</template> </template>
<template v-else> <template v-else>
@ -17,8 +19,13 @@
<div class="is-pulled-right" v-if="!toggleForm"> <div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0"> <p class="control has-icons-left" v-if="toggleFilter && presets && presets.length > 0">
<input type="search" v-model.lazy="query" class="input" id="filter" <input
placeholder="Filter displayed content"> type="search"
v-model.lazy="query"
class="input"
id="filter"
placeholder="Filter displayed content"
/>
<span class="icon is-left"><i class="fas fa-filter" /></span> <span class="icon is-left"><i class="fas fa-filter" /></span>
</p> </p>
@ -30,19 +37,33 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm;" <button
v-tooltip.bottom="'Toggle add form'"> class="button is-primary"
@click="
resetForm(false);
toggleForm = !toggleForm;
"
v-tooltip.bottom="'Toggle add form'"
>
<span class="icon"><i class="fas fa-add" /></span> <span class="icon"><i class="fas fa-add" /></span>
<span v-if="!isMobile">New Preset</span> <span v-if="!isMobile">New Preset</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'"> v-tooltip.bottom="'Change display style'"
class="button has-tooltip-bottom"
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" <i
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
@ -50,8 +71,13 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }" <button
:disabled="isLoading" v-if="presets && presets.length > 0"> class="button is-info"
@click="reloadContent()"
:class="{ 'is-loading': isLoading }"
:disabled="isLoading"
v-if="presets && presets.length > 0"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span> <span v-if="!isMobile">Reload</span>
</button> </button>
@ -59,26 +85,39 @@
</div> </div>
</div> </div>
<div class="is-hidden-mobile" v-if="!toggleForm"> <div class="is-hidden-mobile" v-if="!toggleForm">
<span class="subtitle">Presets are pre-defined command options for yt-dlp that you want to apply to given <span class="subtitle"
download.</span> >Presets are pre-defined command options for yt-dlp that you want to apply to given
download.</span
>
</div> </div>
</div> </div>
</div> </div>
<div class="columns" v-if="toggleForm"> <div class="columns" v-if="toggleForm">
<div class="column is-12"> <div class="column is-12">
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true)" <PresetForm
@submit="updateItem" :presets="presets" /> :addInProgress="addInProgress"
:reference="presetRef"
:preset="preset"
@cancel="resetForm(true)"
@submit="updateItem"
:presets="presets"
/>
</div> </div>
</div> </div>
<template v-if="!toggleForm"> <template v-if="!toggleForm">
<div class="columns is-multiline" v-if="!isLoading && presetsNoDefault && presetsNoDefault.length > 0"> <div
class="columns is-multiline"
v-if="!isLoading && presetsNoDefault && presetsNoDefault.length > 0"
>
<template v-if="'list' === display_style"> <template v-if="'list' === display_style">
<div class="column is-12"> <div class="column is-12">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 650px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 650px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="80%">Preset</th> <th width="80%">Preset</th>
@ -109,19 +148,28 @@
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)"> <button
class="button is-info is-small is-fullwidth"
@click="exportItem(item)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span> <span v-if="!isMobile">Export</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)"> <button
class="button is-warning is-small is-fullwidth"
@click="editItem(item)"
>
<span class="icon"><i class="fa-solid fa-cog" /></span> <span class="icon"><i class="fa-solid fa-cog" /></span>
<span v-if="!isMobile">Edit</span> <span v-if="!isMobile">Edit</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)"> <button
class="button is-danger is-small is-fullwidth"
@click="deleteItem(item)"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span> <span v-if="!isMobile">Delete</span>
</button> </button>
@ -139,9 +187,13 @@
<div class="column is-6" v-for="item in filteredPresets" :key="item.id"> <div class="column is-6" v-for="item in filteredPresets" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column"> <div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-block is-clickable" <div
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }" @click="toggleExpand(item.id, 'title')" class="card-header-title is-block is-clickable"
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'" v-text="prettyName(item.name)" /> :class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }"
@click="toggleExpand(item.id, 'title')"
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'"
v-text="prettyName(item.name)"
/>
<div class="card-header-icon"> <div class="card-header-icon">
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control" v-if="item.priority > 0"> <div class="control" v-if="item.priority > 0">
@ -154,7 +206,11 @@
<span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span> <span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span>
</div> </div>
<div class="control"> <div class="control">
<button class="has-text-info" v-tooltip="'Export preset'" @click="exportItem(item)"> <button
class="has-text-info"
v-tooltip="'Export preset'"
@click="exportItem(item)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
</div> </div>
@ -169,34 +225,65 @@
<span>Priority: {{ item.priority }}</span> <span>Priority: {{ item.priority }}</span>
</p> </p>
</template> </template>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }" <p
v-if="item.folder" @click="toggleExpand(item.id, 'folder')" :class="{
:title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'"> 'is-text-overflow': !isExpanded(item.id, 'folder'),
'is-clickable': true,
}"
v-if="item.folder"
@click="toggleExpand(item.id, 'folder')"
:title="
!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'
"
>
<span class="icon"><i class="fa-solid fa-save" /></span> <span class="icon"><i class="fa-solid fa-save" /></span>
<span>{{ calcPath(item.folder) }}</span> <span>{{ calcPath(item.folder) }}</span>
</p> </p>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'template'), 'is-clickable': true }" <p
v-if="item.template" @click="toggleExpand(item.id, 'template')" :class="{
:title="!isExpanded(item.id, 'template') ? 'Click to expand' : 'Click to collapse'"> 'is-text-overflow': !isExpanded(item.id, 'template'),
'is-clickable': true,
}"
v-if="item.template"
@click="toggleExpand(item.id, 'template')"
:title="
!isExpanded(item.id, 'template') ? 'Click to expand' : 'Click to collapse'
"
>
<span class="icon"><i class="fa-solid fa-file" /></span> <span class="icon"><i class="fa-solid fa-file" /></span>
<span>{{ item.template }}</span> <span>{{ item.template }}</span>
</p> </p>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'cli'), 'is-clickable': true }" v-if="item.cli" <p
:class="{
'is-text-overflow': !isExpanded(item.id, 'cli'),
'is-clickable': true,
}"
v-if="item.cli"
@click="toggleExpand(item.id, 'cli')" @click="toggleExpand(item.id, 'cli')"
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"> :title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"
>
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ item.cli }}</span> <span>{{ item.cli }}</span>
</p> </p>
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'description'), 'is-clickable': true }" <p
v-if="item.description" @click="toggleExpand(item.id, 'description')" :class="{
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"> 'is-text-overflow': !isExpanded(item.id, 'description'),
'is-clickable': true,
}"
v-if="item.description"
@click="toggleExpand(item.id, 'description')"
:title="!isExpanded(item.id, 'cli') ? 'Click to expand' : 'Click to collapse'"
>
<span class="icon"><i class="fa-solid fa-d" /></span> <span class="icon"><i class="fa-solid fa-d" /></span>
<span>{{ item.description }}</span> <span>{{ item.description }}</span>
</p> </p>
</div> </div>
</div> </div>
<div class="card-content content m-1 p-1 is-overflow-auto" style="max-height: 300px;" <div
v-if="item?.toggle_description"> class="card-content content m-1 p-1 is-overflow-auto"
style="max-height: 300px"
v-if="item?.toggle_description"
>
<div class="is-pre-wrap">{{ item.description }}</div> <div class="is-pre-wrap">{{ item.description }}</div>
</div> </div>
<div class="card-footer mt-auto"> <div class="card-footer mt-auto">
@ -218,15 +305,26 @@
</template> </template>
</div> </div>
<div class="columns is-multiline" <div
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)"> class="columns is-multiline"
v-if="!toggleForm && (isLoading || !filteredPresets || filteredPresets.length < 1)"
>
<div class="column is-12"> <div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin"> <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait... Loading data. Please wait...
</Message> </Message>
<Message title="No Results" class="is-warning" icon="fas fa-search" v-else-if="query" :useClose="true" <Message
@close="query = ''"> title="No Results"
<p>No results found for the query: <code>{{ query }}</code>.</p> class="is-warning"
icon="fas fa-search"
v-else-if="query"
:useClose="true"
@close="query = ''"
>
<p>
No results found for the query: <code>{{ query }}</code
>.
</p>
<p>Please try a different search term.</p> <p>Please try a different search term.</p>
</Message> </Message>
<Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle"> <Message v-else title="No presets" class="is-warning" icon="fas fa-exclamation-circle">
@ -239,8 +337,8 @@
<div class="column is-12"> <div class="column is-12">
<Message class="is-info"> <Message class="is-info">
<span class="icon"><i class="fas fa-info-circle" /></span> <span class="icon"><i class="fas fa-info-circle" /></span>
When you <b>export</b> preset, it doesn't include the <code>cookies</code> field contents for security When you <b>export</b> preset, it doesn't include the <code>cookies</code> field
reasons. contents for security reasons.
</Message> </Message>
</div> </div>
</div> </div>
@ -249,142 +347,145 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { Preset } from '~/types/presets' import type { Preset } from '~/types/presets';
import { useConfirm } from '~/composables/useConfirm' import { useConfirm } from '~/composables/useConfirm';
import { usePresets } from '~/composables/usePresets' import { usePresets } from '~/composables/usePresets';
import { prettyName } from '~/utils' import { prettyName } from '~/utils';
type PresetWithUI = Preset & { raw?: boolean, toggle_description?: boolean } type PresetWithUI = Preset & { raw?: boolean; toggle_description?: boolean };
const presetsStore = usePresets() const presetsStore = usePresets();
const config = useConfigStore() const config = useConfigStore();
const box = useConfirm() const box = useConfirm();
const display_style = useStorage<string>('preset_display_style', 'cards') const display_style = useStorage<string>('preset_display_style', 'cards');
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const query = ref<string>('') const query = ref<string>('');
const toggleFilter = ref(false) const toggleFilter = ref(false);
const presets = presetsStore.presets as Ref<PresetWithUI[]> const presets = presetsStore.presets as Ref<PresetWithUI[]>;
const preset = ref<Partial<Preset>>({}) const preset = ref<Partial<Preset>>({});
const presetRef = ref<number | null>(null) const presetRef = ref<number | null>(null);
const toggleForm = ref(false) const toggleForm = ref(false);
const isLoading = presetsStore.isLoading const isLoading = presetsStore.isLoading;
const addInProgress = presetsStore.addInProgress const addInProgress = presetsStore.addInProgress;
const remove_keys = ['raw', 'toggle_description'] const remove_keys = ['raw', 'toggle_description'];
const expandedItems = ref<Record<string, Set<string>>>({}) const expandedItems = ref<Record<string, Set<string>>>({});
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default)) const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default));
const filteredPresets = computed<PresetWithUI[]>(() => { const filteredPresets = computed<PresetWithUI[]>(() => {
const q = query.value?.toLowerCase(); const q = query.value?.toLowerCase();
if (!q) return presetsNoDefault.value; if (!q) return presetsNoDefault.value;
return presetsNoDefault.value.filter((item: PresetWithUI) => deepIncludes(item, q, new WeakSet())); return presetsNoDefault.value.filter((item: PresetWithUI) =>
deepIncludes(item, q, new WeakSet()),
);
}); });
const toggleExpand = (itemId: number | string | undefined, field: string) => { const toggleExpand = (itemId: number | string | undefined, field: string) => {
if (itemId === undefined || itemId === null) return if (itemId === undefined || itemId === null) return;
const key = String(itemId) const key = String(itemId);
if (!expandedItems.value[key]) { if (!expandedItems.value[key]) {
expandedItems.value[key] = new Set() expandedItems.value[key] = new Set();
} }
if (expandedItems.value[key].has(field)) { if (expandedItems.value[key].has(field)) {
expandedItems.value[key].delete(field) expandedItems.value[key].delete(field);
} else { } else {
expandedItems.value[key].add(field) expandedItems.value[key].add(field);
} }
} };
const isExpanded = (itemId: number | string | undefined, field: string): boolean => { const isExpanded = (itemId: number | string | undefined, field: string): boolean => {
if (itemId === undefined || itemId === null) return false if (itemId === undefined || itemId === null) return false;
const key = String(itemId) const key = String(itemId);
return expandedItems.value[key]?.has(field) ?? false return expandedItems.value[key]?.has(field) ?? false;
} };
watch(toggleFilter, (val) => { watch(toggleFilter, (val) => {
if (!val) { if (!val) {
query.value = '' query.value = '';
} }
}) });
const reloadContent = async () => { const reloadContent = async () => {
await presetsStore.loadPresets(1, 1000) await presetsStore.loadPresets(1, 1000);
} };
const resetForm = (closeForm = false) => { const resetForm = (closeForm = false) => {
preset.value = {} preset.value = {};
presetRef.value = null presetRef.value = null;
if (closeForm) { if (closeForm) {
toggleForm.value = false toggleForm.value = false;
} }
} };
const deleteItem = async (item: Preset) => { const deleteItem = async (item: Preset) => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) { if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return return;
} }
if (item.id) { if (item.id) {
await presetsStore.deletePreset(item.id) await presetsStore.deletePreset(item.id);
} }
} };
const updateItem = async ({ const updateItem = async ({
reference, reference,
preset: item, preset: item,
}: { }: {
reference: number | null reference: number | null;
preset: Preset preset: Preset;
}) => { }) => {
item = cleanObject(item, remove_keys) as Preset item = cleanObject(item, remove_keys) as Preset;
if (reference) { if (reference) {
const updated = await presetsStore.updatePreset(reference, item) const updated = await presetsStore.updatePreset(reference, item);
if (updated) { if (updated) {
resetForm(true) resetForm(true);
} }
} else { } else {
const created = await presetsStore.createPreset(item) const created = await presetsStore.createPreset(item);
if (created) { if (created) {
resetForm(true) resetForm(true);
} }
} }
} };
const filterItem = (item: Preset) => { const filterItem = (item: Preset) => {
const rest = cleanObject(item, remove_keys) const rest = cleanObject(item, remove_keys);
if ('default' in rest) { if ('default' in rest) {
delete rest.default delete rest.default;
} }
return JSON.stringify(rest, null, 2) return JSON.stringify(rest, null, 2);
} };
const editItem = (item: Preset) => { const editItem = (item: Preset) => {
preset.value = JSON.parse(filterItem(item)) preset.value = JSON.parse(filterItem(item));
presetRef.value = item.id ?? null presetRef.value = item.id ?? null;
toggleForm.value = true toggleForm.value = true;
} };
onMounted(async () => await reloadContent()) onMounted(async () => await reloadContent());
const exportItem = (item: Preset) => { const exportItem = (item: Preset) => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'] const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'];
const userData = Object.fromEntries( const userData = Object.fromEntries(
Object.entries(JSON.parse(JSON.stringify(item))).filter(([key, value]) => !excludedKeys.includes(key) && value) Object.entries(JSON.parse(JSON.stringify(item))).filter(
) ([key, value]) => !excludedKeys.includes(key) && value,
),
);
userData['_type'] = 'preset' userData['_type'] = 'preset';
userData['_version'] = '2.6' userData['_version'] = '2.6';
copyText(encode(userData)) copyText(encode(userData));
} };
const calcPath = (path?: string): string => { const calcPath = (path?: string): string => {
const loc = config.app.download_path || '/downloads' const loc = config.app.download_path || '/downloads';
return path ? loc + '/' + sTrim(path, '/') : loc return path ? loc + '/' + sTrim(path, '/') : loc;
} };
</script> </script>

View file

@ -1,12 +1,12 @@
<template></template> <template></template>
<script lang="ts" setup> <script lang="ts" setup>
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
const simpleMode = useStorage<boolean>('simple_mode', true) const simpleMode = useStorage<boolean>('simple_mode', true);
onMounted(async () => { onMounted(async () => {
simpleMode.value = true simpleMode.value = true;
await nextTick() await nextTick();
await navigateTo('/') await navigateTo('/');
}) });
</script> </script>

View file

@ -16,27 +16,37 @@
</span> </span>
<div class="is-pulled-right" v-if="!isEditorOpen"> <div class="is-pulled-right" v-if="!isEditorOpen">
<div class="field is-grouped"> <div class="field is-grouped">
<p class="control"> <p class="control">
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()"> <button
class="button is-primary"
@click="isEditorOpen ? closeEditor() : openCreate()"
>
<span class="icon"><i class="fa-solid fa-add" /></span> <span class="icon"><i class="fa-solid fa-add" /></span>
<span v-if="!isMobile">New Definition</span> <span v-if="!isMobile">New Definition</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button @click="() => inspect = true" class="button is-warning"> <button @click="() => (inspect = true)" class="button is-warning">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span> <span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span v-if="!isMobile">Inspect</span> <span v-if="!isMobile">Inspect</span>
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom" <button
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'"> v-tooltip.bottom="'Change display style'"
class="button has-tooltip-bottom"
@click="() => (display_style = display_style === 'list' ? 'grid' : 'list')"
>
<span class="icon"> <span class="icon">
<i class="fa-solid" <i
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span> class="fa-solid"
:class="{
'fa-table': display_style !== 'list',
'fa-table-list': display_style === 'list',
}"
/></span>
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }} {{ display_style === 'list' ? 'List' : 'Grid' }}
</span> </span>
@ -44,8 +54,11 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="async () => await loadDefinitions()" <button
:class="{ 'is-loading': isLoading }"> class="button is-info"
@click="async () => await loadDefinitions()"
:class="{ 'is-loading': isLoading }"
>
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span> <span v-if="!isMobile">Reload</span>
</button> </button>
@ -63,8 +76,10 @@
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen"> <div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
<div class="column is-12"> <div class="column is-12">
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table
style="min-width: 850px; table-layout: fixed;"> class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed"
>
<thead> <thead>
<tr class="has-text-centered is-unselectable"> <tr class="has-text-centered is-unselectable">
<th width="40%">Name</th> <th width="40%">Name</th>
@ -76,13 +91,25 @@
<tbody> <tbody>
<tr v-for="definition in definitions" :key="definition.id"> <tr v-for="definition in definitions" :key="definition.id">
<td class="is-vcentered"> <td class="is-vcentered">
<div class="is-text-overflow">{{ definition.name || '(Unnamed definition)' }}</div> <div class="is-text-overflow">
{{ definition.name || '(Unnamed definition)' }}
</div>
<div class="is-size-7"> <div class="is-size-7">
<span class="icon-text is-clickable" @click="toggle(definition)" <span
v-tooltip="'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'"> class="icon-text is-clickable"
@click="toggle(definition)"
v-tooltip="
'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'
"
>
<span class="icon"> <span class="icon">
<i class="fa-solid fa-power-off" <i
:class="{ 'has-text-success': definition.enabled, 'has-text-danger': !definition.enabled }" /> class="fa-solid fa-power-off"
:class="{
'has-text-success': definition.enabled,
'has-text-danger': !definition.enabled,
}"
/>
</span> </span>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span> <span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</span> </span>
@ -90,26 +117,41 @@
</td> </td>
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td> <td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
<td class="is-vcentered has-text-centered"> <td class="is-vcentered has-text-centered">
<span class="has-tooltip" :date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')" <span
class="has-tooltip"
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')" v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" /> v-rtime="definition.updated_at"
/>
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-small is-info" type="button" @click="exportDefinition(definition)"> <button
class="button is-small is-info"
type="button"
@click="exportDefinition(definition)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span> <span v-if="!isMobile">Export</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-small is-warning" type="button" @click="openEdit(definition)"> <button
class="button is-small is-warning"
type="button"
@click="openEdit(definition)"
>
<span class="icon"><i class="fa-solid fa-cog" /></span> <span class="icon"><i class="fa-solid fa-cog" /></span>
<span v-if="!isMobile">Edit</span> <span v-if="!isMobile">Edit</span>
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button class="button is-small is-danger" type="button" @click="remove(definition)"> <button
class="button is-small is-danger"
type="button"
@click="remove(definition)"
>
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span> <span v-if="!isMobile">Delete</span>
</button> </button>
@ -123,7 +165,10 @@
</div> </div>
</div> </div>
<div class="columns is-multiline" v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen"> <div
class="columns is-multiline"
v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen"
>
<div class="column is-6" v-for="definition in definitions" :key="definition.id"> <div class="column is-6" v-for="definition in definitions" :key="definition.id">
<div class="card"> <div class="card">
<header class="card-header"> <header class="card-header">
@ -133,13 +178,22 @@
<div class="card-header-icon"> <div class="card-header-icon">
<div class="field has-addons"> <div class="field has-addons">
<div class="control" @click="toggle(definition)"> <div class="control" @click="toggle(definition)">
<span class="icon" :class="definition.enabled ? 'has-text-success' : 'has-text-danger'" <span
v-tooltip="`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`"> class="icon"
:class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="
`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`
"
>
<i class="fa-solid fa-power-off" /> <i class="fa-solid fa-power-off" />
</span> </span>
</div> </div>
<div class="control"> <div class="control">
<button class="has-text-info" v-tooltip="'Export'" @click="exportDefinition(definition)"> <button
class="has-text-info"
v-tooltip="'Export'"
@click="exportDefinition(definition)"
>
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
</div> </div>
@ -157,10 +211,14 @@
<p> <p>
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-clock" /></span> <span class="icon"><i class="fa-solid fa-clock" /></span>
<span>Updated: <span class="has-tooltip" <span
>Updated:
<span
class="has-tooltip"
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')" :date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')" v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" /> v-rtime="definition.updated_at"
/>
</span> </span>
</span> </span>
</p> </p>
@ -193,47 +251,58 @@
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin"> <Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait... Loading data. Please wait...
</Message> </Message>
<Message v-if="!isLoading && !isEditorOpen" title="No definitions" class="is-warning" <Message
icon="fas fa-exclamation-circle"> v-if="!isLoading && !isEditorOpen"
There are no task definitions. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New title="No definitions"
Definition</strong> button to create your first task definition. class="is-warning"
icon="fas fa-exclamation-circle"
>
There are no task definitions. Click the
<span class="icon"><i class="fas fa-add" /></span> <strong>New Definition</strong> button
to create your first task definition.
</Message> </Message>
</div> </div>
</div> </div>
<div class="columns" v-if="isEditorOpen"> <div class="columns" v-if="isEditorOpen">
<div class="column is-12"> <div class="column is-12">
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition" <TaskDefinitionEditor
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading" :title="editorTitle"
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor" :document="workingDefinition"
@import-existing="importExistingDefinition" /> :initial-show-import="'create' === editorMode"
:available-definitions="definitions"
:loading="editorLoading"
:submitting="editorSubmitting"
@submit="submitDefinition"
@cancel="closeEditor"
@import-existing="importExistingDefinition"
/>
</div> </div>
</div> </div>
<Modal v-if="inspect" @close="() => inspect = false" :contentClass="`modal-content-max`"> <Modal v-if="inspect" @close="() => (inspect = false)" :contentClass="`modal-content-max`">
<TaskInspect /> <TaskInspect />
</Modal> </Modal>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment';
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue' import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue';
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions' import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
import { useDialog } from '~/composables/useDialog' import { useDialog } from '~/composables/useDialog';
import { useNotification } from '~/composables/useNotification' import { useNotification } from '~/composables/useNotification';
import { copyText, encode } from '~/utils' import { copyText, encode } from '~/utils';
import { useMediaQuery } from '~/composables/useMediaQuery' import { useMediaQuery } from '~/composables/useMediaQuery';
import type { import type {
TaskDefinitionDetailed, TaskDefinitionDetailed,
TaskDefinitionDocument, TaskDefinitionDocument,
TaskDefinitionSummary, TaskDefinitionSummary,
} from '~/types/task_definitions' } from '~/types/task_definitions';
const DEFAULT_DEFINITION: TaskDefinitionDocument = { const DEFAULT_DEFINITION: TaskDefinitionDocument = {
name: 'New Definition', name: 'New Definition',
@ -252,74 +321,74 @@ const DEFAULT_DEFINITION: TaskDefinitionDocument = {
}, },
}, },
}, },
} };
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 });
const taskDefs = useTaskDefinitionsComposable() const taskDefs = useTaskDefinitionsComposable();
const definitionsRef = taskDefs.definitions const definitionsRef = taskDefs.definitions;
const isLoading = taskDefs.isLoading const isLoading = taskDefs.isLoading;
const loadDefinitions = taskDefs.loadDefinitions const loadDefinitions = taskDefs.loadDefinitions;
const getDefinition = taskDefs.getDefinition const getDefinition = taskDefs.getDefinition;
const createDefinition = taskDefs.createDefinition const createDefinition = taskDefs.createDefinition;
const updateDefinition = taskDefs.updateDefinition const updateDefinition = taskDefs.updateDefinition;
const deleteDefinition = taskDefs.deleteDefinition const deleteDefinition = taskDefs.deleteDefinition;
const toggleEnabled = taskDefs.toggleEnabled const toggleEnabled = taskDefs.toggleEnabled;
const definitions = computed(() => definitionsRef.value) const definitions = computed(() => definitionsRef.value);
const { confirmDialog } = useDialog() const { confirmDialog } = useDialog();
const toast = useNotification() const toast = useNotification();
const isEditorOpen = ref<boolean>(false) const isEditorOpen = ref<boolean>(false);
const editorMode = ref<'create' | 'edit'>('create') const editorMode = ref<'create' | 'edit'>('create');
const editorLoading = ref<boolean>(false) const editorLoading = ref<boolean>(false);
const editorSubmitting = ref<boolean>(false) const editorSubmitting = ref<boolean>(false);
const workingDefinition = ref<TaskDefinitionDocument | null>(null) const workingDefinition = ref<TaskDefinitionDocument | null>(null);
const workingId = ref<number | null>(null) const workingId = ref<number | null>(null);
const inspect = ref<boolean>(false) const inspect = ref<boolean>(false);
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid') const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => { const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if ('edit' !== editorMode.value || !workingId.value) { if ('edit' !== editorMode.value || !workingId.value) {
return undefined return undefined;
} }
return definitions.value.find(item => item.id === workingId.value) return definitions.value.find((item) => item.id === workingId.value);
}) });
const editorTitle = computed<string>(() => { const editorTitle = computed<string>(() => {
return 'create' === editorMode.value return 'create' === editorMode.value
? 'Create Task Definition' ? 'Create Task Definition'
: `Edit - ${currentSummary.value?.name || 'Task Definition'}` : `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
}) });
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => { const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
} };
const openCreate = (): void => { const openCreate = (): void => {
editorMode.value = 'create' editorMode.value = 'create';
workingId.value = null workingId.value = null;
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION) workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
isEditorOpen.value = true isEditorOpen.value = true;
editorLoading.value = false editorLoading.value = false;
editorSubmitting.value = false editorSubmitting.value = false;
} };
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => { const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorMode.value = 'edit' editorMode.value = 'edit';
workingId.value = summary.id workingId.value = summary.id;
workingDefinition.value = null workingDefinition.value = null;
editorLoading.value = true editorLoading.value = true;
editorSubmitting.value = false editorSubmitting.value = false;
isEditorOpen.value = true isEditorOpen.value = true;
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id) const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id);
if (!detailed) { if (!detailed) {
isEditorOpen.value = false isEditorOpen.value = false;
editorLoading.value = false editorLoading.value = false;
return return;
} }
const document: TaskDefinitionDocument = { const document: TaskDefinitionDocument = {
@ -328,17 +397,17 @@ const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
enabled: detailed.enabled, enabled: detailed.enabled,
match_url: [...detailed.match_url], match_url: [...detailed.match_url],
definition: JSON.parse(JSON.stringify(detailed.definition)), definition: JSON.parse(JSON.stringify(detailed.definition)),
} };
workingDefinition.value = document workingDefinition.value = document;
editorLoading.value = false editorLoading.value = false;
} };
const importExistingDefinition = async (id: number): Promise<void> => { const importExistingDefinition = async (id: number): Promise<void> => {
const detailed = await getDefinition(id) const detailed = await getDefinition(id);
if (!detailed) { if (!detailed) {
toast.error('Failed to load task definition for import.') toast.error('Failed to load task definition for import.');
return return;
} }
const document: TaskDefinitionDocument = { const document: TaskDefinitionDocument = {
@ -347,89 +416,89 @@ const importExistingDefinition = async (id: number): Promise<void> => {
enabled: detailed.enabled, enabled: detailed.enabled,
match_url: [...detailed.match_url], match_url: [...detailed.match_url],
definition: JSON.parse(JSON.stringify(detailed.definition)), definition: JSON.parse(JSON.stringify(detailed.definition)),
} };
editorMode.value = 'create' editorMode.value = 'create';
workingId.value = null workingId.value = null;
workingDefinition.value = document workingDefinition.value = document;
isEditorOpen.value = true isEditorOpen.value = true;
editorLoading.value = false editorLoading.value = false;
} };
const closeEditor = (): void => { const closeEditor = (): void => {
if (editorSubmitting.value) { if (editorSubmitting.value) {
return return;
} }
isEditorOpen.value = false isEditorOpen.value = false;
workingDefinition.value = null workingDefinition.value = null;
workingId.value = null workingId.value = null;
editorLoading.value = false editorLoading.value = false;
} };
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => { const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
editorSubmitting.value = true editorSubmitting.value = true;
try { try {
if ('create' === editorMode.value) { if ('create' === editorMode.value) {
const created = await createDefinition(definition) const created = await createDefinition(definition);
if (created) { if (created) {
isEditorOpen.value = false isEditorOpen.value = false;
workingDefinition.value = null workingDefinition.value = null;
workingId.value = null workingId.value = null;
} }
} } else if (workingId.value) {
else if (workingId.value) { const updated = await updateDefinition(workingId.value, definition);
const updated = await updateDefinition(workingId.value, definition)
if (updated) { if (updated) {
isEditorOpen.value = false isEditorOpen.value = false;
workingDefinition.value = null workingDefinition.value = null;
workingId.value = null workingId.value = null;
} }
} }
} finally {
editorSubmitting.value = false;
} }
finally { };
editorSubmitting.value = false
}
}
const remove = async (summary: TaskDefinitionSummary): Promise<void> => { const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
const result = await confirmDialog({ const result = await confirmDialog({
title: 'Delete Task Definition', title: 'Delete Task Definition',
message: `Are you sure you want to delete "${summary.name || summary.id}"?`, message: `Are you sure you want to delete "${summary.name || summary.id}"?`,
confirmColor: 'is-danger', confirmColor: 'is-danger',
}) });
if (!result.status) { if (!result.status) {
return return;
} }
await deleteDefinition(summary.id) await deleteDefinition(summary.id);
} };
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => { const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled) await toggleEnabled(summary.id, !summary.enabled);
} };
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => { const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
const detailed = await getDefinition(summary.id) const detailed = await getDefinition(summary.id);
if (!detailed) { if (!detailed) {
return return;
} }
return copyText(encode({ return copyText(
_type: 'task_definition', encode({
_version: '2.0', _type: 'task_definition',
name: detailed.name, _version: '2.0',
priority: detailed.priority, name: detailed.name,
enabled: detailed.enabled, priority: detailed.priority,
match_url: detailed.match_url, enabled: detailed.enabled,
definition: detailed.definition, match_url: detailed.match_url,
})) definition: detailed.definition,
} }),
);
};
onMounted(async () => { onMounted(async () => {
if (!definitions.value.length) { if (!definitions.value.length) {
await loadDefinitions() await loadDefinitions();
} }
}) });
</script> </script>

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ const on_mounted = (el: HTMLElement) => {
AUTO_SCROLL_EVENTS.forEach((ev, index) => { AUTO_SCROLL_EVENTS.forEach((ev, index) => {
const handler = () => { const handler = () => {
scrolledToBottom = (el.scrollHeight - el.scrollTop) - el.clientHeight <= 80; scrolledToBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 80;
}; };
events_handlers[index] = handler; events_handlers[index] = handler;
el.addEventListener(ev as string, handler, { passive: true } as AddEventListenerOptions); el.addEventListener(ev as string, handler, { passive: true } as AddEventListenerOptions);
@ -19,7 +19,7 @@ const on_mounted = (el: HTMLElement) => {
}); });
observer.observe(el, { childList: true, subtree: true }); observer.observe(el, { childList: true, subtree: true });
} };
const on_unmounted = (el: HTMLElement) => { const on_unmounted = (el: HTMLElement) => {
AUTO_SCROLL_EVENTS.forEach((ev, index) => { AUTO_SCROLL_EVENTS.forEach((ev, index) => {
const handler = events_handlers[index]; const handler = events_handlers[index];
@ -28,9 +28,9 @@ const on_unmounted = (el: HTMLElement) => {
} }
}); });
observer.disconnect(); observer.disconnect();
} };
export default defineNuxtPlugin(nuxtApp => { export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('autoscroll', { nuxtApp.vueApp.directive('autoscroll', {
mounted: on_mounted, mounted: on_mounted,
unmounted: on_unmounted, unmounted: on_unmounted,

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 => { const parseInterval = (arg: string | undefined): number => {
if (!arg) { if (!arg) {
return 60 * 1000 return 60 * 1000;
} }
const match = arg.match(/^(\d+)([smhd])$/) const match = arg.match(/^(\d+)([smhd])$/);
if (!match) { if (!match) {
return 60 * 1000 return 60 * 1000;
} }
const [, numStr, unit] = match const [, numStr, unit] = match;
const num = parseInt(String(numStr), 10) const num = parseInt(String(numStr), 10);
switch (unit) { switch (unit) {
case 'd': return num * 24 * 3600 * 1000 case 'd':
case 'h': return num * 3600 * 1000 return num * 24 * 3600 * 1000;
case 'm': return num * 60 * 1000 case 'h':
case 's': return num * 1000 return num * 3600 * 1000;
default: return 60 * 1000 case 'm':
return num * 60 * 1000;
case 's':
return num * 1000;
default:
return 60 * 1000;
} }
} };
export default defineNuxtPlugin(nuxtApp => { export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('rtime', { nuxtApp.vueApp.directive('rtime', {
mounted(el: RTimeElement, binding) { mounted(el: RTimeElement, binding) {
const intervalMs = parseInterval(binding.arg) const intervalMs = parseInterval(binding.arg);
const update = () => { const update = () => {
const val = binding.value const val = binding.value;
if (Number.isFinite(val)) { if (Number.isFinite(val)) {
el.textContent = moment.unix(val as number).fromNow() el.textContent = moment.unix(val as number).fromNow();
return return;
} }
el.textContent = moment(val).fromNow() el.textContent = moment(val).fromNow();
} };
update() update();
el._next_timer = window.setInterval(update, intervalMs) el._next_timer = window.setInterval(update, intervalMs);
}, },
updated(el: RTimeElement, binding) { updated(el: RTimeElement, binding) {
if (binding.oldValue !== binding.value) { if (binding.oldValue !== binding.value) {
if (null != el._next_timer) clearInterval(el._next_timer) if (null != el._next_timer) clearInterval(el._next_timer);
const intervalMs = parseInterval(binding.arg) const intervalMs = parseInterval(binding.arg);
const update = () => { const update = () => {
const val = binding.value const val = binding.value;
if (Number.isFinite(val)) { if (Number.isFinite(val)) {
el.textContent = moment.unix(val as number).fromNow() el.textContent = moment.unix(val as number).fromNow();
return return;
} }
el.textContent = moment(val).fromNow() el.textContent = moment(val).fromNow();
} };
update() update();
el._next_timer = window.setInterval(update, intervalMs) el._next_timer = window.setInterval(update, intervalMs);
} }
}, },
beforeUnmount(el: RTimeElement) { beforeUnmount(el: RTimeElement) {
if (null != el._next_timer) clearInterval(el._next_timer) if (null != el._next_timer) clearInterval(el._next_timer);
} },
}) });
return {} return {};
}) });

View file

@ -1,12 +1,12 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from '#app';
import Toast, { type PluginOptions } from 'vue-toastification' import Toast, { type PluginOptions } from 'vue-toastification';
import 'vue-toastification/dist/index.css' import 'vue-toastification/dist/index.css';
export default defineNuxtPlugin(nuxtApp => { export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(Toast, { nuxtApp.vueApp.use(Toast, {
transition: "Vue-Toastification__bounce", transition: 'Vue-Toastification__bounce',
//position: "bottom-right", //position: "bottom-right",
maxToasts: 5, maxToasts: 5,
newestOnTop: true, newestOnTop: true,
} as PluginOptions) } as PluginOptions);
}) });

View file

@ -1,126 +1,138 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body=""> <html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YTPTube Loading...</title> <title>YTPTube Loading...</title>
<style> <style>
html, body { html,
margin: 0; body {
padding: 0; margin: 0;
height: 100%; padding: 0;
display: flex; height: 100%;
align-items: center; display: flex;
justify-content: center; align-items: center;
background-color: #fff; justify-content: center;
color: #111; background-color: #fff;
user-select: none; color: #111;
overflow: hidden; user-select: none;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
} transition:
@media (prefers-color-scheme: dark) { background-color 0.3s ease,
html, body { color 0.3s ease;
background-color: #121212; }
color: #eee; @media (prefers-color-scheme: dark) {
html,
body {
background-color: #121212;
color: #eee;
}
} }
}
.loader-container { .loader-container {
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
svg.ytptube-loading {
width: 420px;
height: 105px;
stroke: #FF0000;
stroke-width: 4.5px;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
}
@media (prefers-color-scheme: dark) {
svg.ytptube-loading { svg.ytptube-loading {
stroke: #FF4444; width: 420px;
height: 105px;
stroke: #ff0000;
stroke-width: 4.5px;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
} }
}
@keyframes stroke-move { @media (prefers-color-scheme: dark) {
100% { svg.ytptube-loading {
stroke-dashoffset: -700; stroke: #ff4444;
}
} }
}
.spinner { @keyframes stroke-move {
margin: 20px auto 0; 100% {
width: 64px; stroke-dashoffset: -700;
height: 64px; }
border: 8px solid transparent; }
border-top-color: #FF0000;
border-radius: 50%;
animation: spin 1.2s linear infinite;
filter: drop-shadow(0 0 6px rgba(255,0,0,0.8));
}
@media (prefers-color-scheme: dark) {
.spinner { .spinner {
border-top-color: #FF4444; margin: 20px auto 0;
filter: drop-shadow(0 0 8px #FF4444); width: 64px;
height: 64px;
border: 8px solid transparent;
border-top-color: #ff0000;
border-radius: 50%;
animation: spin 1.2s linear infinite;
filter: drop-shadow(0 0 6px rgba(255, 0, 0, 0.8));
} }
}
@keyframes spin { @media (prefers-color-scheme: dark) {
to { transform: rotate(360deg); } .spinner {
} border-top-color: #ff4444;
filter: drop-shadow(0 0 8px #ff4444);
}
}
.subtitle { @keyframes spin {
margin-top: 14px; to {
font-size: 1rem; transform: rotate(360deg);
color: inherit; }
opacity: 0.8; }
user-select: none;
font-weight: 600; .subtitle {
letter-spacing: 0.1em; margin-top: 14px;
} font-size: 1rem;
</style> color: inherit;
</head> opacity: 0.8;
<body> user-select: none;
<div class="loader-container" role="img" aria-label="Loading YTPTube"> font-weight: 600;
<svg class="ytptube-loading" viewBox="0 0 420 105" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"> letter-spacing: 0.1em;
<!-- Y --> }
<path d="M10 10 L25 37.5 L10 65" /> </style>
<path d="M25 37.5 L40 10" /> </head>
<!-- T --> <body>
<path d="M50 10 L90 10" /> <div class="loader-container" role="img" aria-label="Loading YTPTube">
<path d="M70 10 L70 65" /> <svg
<!-- P --> class="ytptube-loading"
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" /> viewBox="0 0 420 105"
<!-- T --> xmlns="http://www.w3.org/2000/svg"
<path d="M160 10 L200 10" /> aria-hidden="true"
<path d="M180 10 L180 65" /> focusable="false"
<!-- U --> >
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" /> <!-- Y -->
<!-- B --> <path d="M10 10 L25 37.5 L10 65" />
<path d="M270 10 L270 65" /> <path d="M25 37.5 L40 10" />
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" /> <!-- T -->
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" /> <path d="M50 10 L90 10" />
<!-- E --> <path d="M70 10 L70 65" />
<path d="M320 10 L320 65" /> <!-- P -->
<path d="M320 10 L360 10" /> <path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
<path d="M320 37.5 L350 37.5" /> <!-- T -->
<path d="M320 65 L360 65" /> <path d="M160 10 L200 10" />
</svg> <path d="M180 10 L180 65" />
<div class="spinner"></div> <!-- U -->
<div class="subtitle">Loading, please wait...</div> <path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
</div> <!-- B -->
</body> <path d="M270 10 L270 65" />
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
<!-- E -->
<path d="M320 10 L320 65" />
<path d="M320 10 L360 10" />
<path d="M320 37.5 L350 37.5" />
<path d="M320 65 L360 65" />
</svg>
<div class="spinner"></div>
<div class="subtitle">Loading, please wait...</div>
</div>
</body>
</html> </html>

View file

@ -1,4 +1,4 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { ConfigState } from '~/types/config'; import type { ConfigState } from '~/types/config';
import type { DLField } from '~/types/dl_fields'; import type { DLField } from '~/types/dl_fields';
import type { Preset } from '~/types/presets'; import type { Preset } from '~/types/presets';
@ -39,15 +39,15 @@ export const useConfigStore = defineStore('config', () => {
}, },
presets: [ presets: [
{ {
'name': 'default', name: 'default',
'description': 'Default preset', description: 'Default preset',
'folder': '', folder: '',
'template': '', template: '',
'cookies': '', cookies: '',
'cli': '', cli: '',
'default': true, default: true,
'priority': 0 priority: 0,
} },
], ],
dl_fields: [], dl_fields: [],
folders: [], folders: [],
@ -61,12 +61,12 @@ export const useConfigStore = defineStore('config', () => {
if (state.is_loading) { if (state.is_loading) {
return; return;
} }
const now = Date.now() const now = Date.now();
if (state.is_loaded && !force && last_reload > 0) { if (state.is_loaded && !force && last_reload > 0) {
const age = (now - last_reload) / 1000 const age = (now - last_reload) / 1000;
if (age < CONFIG_TTL) { if (age < CONFIG_TTL) {
return return;
} }
} }
@ -95,126 +95,133 @@ export const useConfigStore = defineStore('config', () => {
last_reload = now; last_reload = now;
} catch (e: any) { } catch (e: any) {
console.error(`Failed to load configuration: ${e}`); console.error(`Failed to load configuration: ${e}`);
} } finally {
finally {
state.is_loading = false; state.is_loading = false;
} }
} };
const add = (key: string, value: any) => { const add = (key: string, value: any) => {
if (key.includes('.')) { if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string] const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
state[parentKey][subKey] = value; state[parentKey][subKey] = value;
return; return;
} }
(state as any)[key] = value (state as any)[key] = value;
} };
const get = (key: string, defaultValue: any = null): any => { const get = (key: string, defaultValue: any = null): any => {
if (key.includes('.')) { if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string] const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any const parent = state[parentKey] as any;
return parent?.[subKey] ?? defaultValue return parent?.[subKey] ?? defaultValue;
} }
return (state as any)[key] ?? defaultValue return (state as any)[key] ?? defaultValue;
} };
const isLoaded = () => state.is_loaded const isLoaded = () => state.is_loaded;
const update = add const update = add;
const getAll = (): ConfigState => state const getAll = (): ConfigState => state;
const setAll = (data: Record<string, any>) => { const setAll = (data: Record<string, any>) => {
Object.keys(data).forEach((key) => { Object.keys(data).forEach((key) => {
if (key.includes('.')) { if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string] const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string];
const parent = state[parentKey] as any const parent = state[parentKey] as any;
parent[subKey] = data[key] parent[subKey] = data[key];
return return;
} }
(state as any)[key] = data[key] (state as any)[key] = data[key];
}) });
state.is_loaded = true state.is_loaded = true;
} };
const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => { const patch = (feature: ConfigFeature, action: ConfigUpdateAction, data: unknown): void => {
const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'] const supportedFeatures: ConfigFeature[] = ['dl_fields', 'presets'];
if (!supportedFeatures.includes(feature)) { if (!supportedFeatures.includes(feature)) {
return return;
} }
if ('presets' === feature) { if ('presets' === feature) {
const item = data as Preset const item = data as Preset;
const current = get(feature, []) as Array<Preset> const current = get(feature, []) as Array<Preset>;
if ('create' === action) { if ('create' === action) {
current.push(item) current.push(item);
return return;
} }
if ('delete' === action) { if ('delete' === action) {
const index = current.findIndex(i => i.id === item.id) const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) { if (-1 !== index) {
current.splice(index, 1) current.splice(index, 1);
} }
return return;
} }
if ('update' === action) { if ('update' === action) {
const target = current.find(i => i.id === item.id) const target = current.find((i) => i.id === item.id);
if (target) { if (target) {
Object.assign(target, item) Object.assign(target, item);
} }
return return;
} }
return return;
} }
if ('dl_fields' === feature) { if ('dl_fields' === feature) {
const item = data as DLField const item = data as DLField;
const current = get(feature, []) as Array<DLField> const current = get(feature, []) as Array<DLField>;
if ('create' === action) { if ('create' === action) {
current.push(item) current.push(item);
return return;
} }
if ('delete' === action) { if ('delete' === action) {
const index = current.findIndex(i => i.id === item.id) const index = current.findIndex((i) => i.id === item.id);
if (-1 !== index) { if (-1 !== index) {
current.splice(index, 1) current.splice(index, 1);
} }
return return;
} }
if ('update' === action) { if ('update' === action) {
const target = current.find(i => i.id === item.id) const target = current.find((i) => i.id === item.id);
if (target) { if (target) {
Object.assign(target, item) Object.assign(target, item);
} }
return return;
} }
if ('replace' === action) { if ('replace' === action) {
state.dl_fields = data as Array<DLField> state.dl_fields = data as Array<DLField>;
return return;
} }
return return;
} }
} };
return { return {
...toRefs(state), add, get, update, getAll, setAll, isLoaded, patch, loadConfig ...toRefs(state),
add,
get,
update,
getAll,
setAll,
isLoaded,
patch,
loadConfig,
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & { } as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
add: typeof add add: typeof add;
get: typeof get get: typeof get;
update: typeof update update: typeof update;
getAll: typeof getAll getAll: typeof getAll;
setAll: typeof setAll setAll: typeof setAll;
patch: typeof patch patch: typeof patch;
isLoaded: typeof isLoaded isLoaded: typeof isLoaded;
loadConfig: typeof loadConfig loadConfig: typeof loadConfig;
} };
}); });

View file

@ -1,82 +1,83 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core';
import type { notification, notificationType } from '~/composables/useNotification' import type { notification, notificationType } from '~/composables/useNotification';
const _map: Record<notificationType, { level: number; color: string; icon: string }> = { const _map: Record<notificationType, { level: number; color: string; icon: string }> = {
'error': { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' }, error: { level: 3, color: 'is-danger', icon: 'fas fa-triangle-exclamation' },
'warning': { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' }, warning: { level: 2, color: 'is-warning', icon: 'fas fa-circle-exclamation' },
'success': { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' }, success: { level: 1, color: 'is-primary', icon: 'fas fa-circle-check' },
'info': { level: 0, color: 'is-info', icon: 'fas fa-circle-info' } info: { level: 0, color: 'is-info', icon: 'fas fa-circle-info' },
} };
export const useNotificationStore = defineStore('notifications', () => { export const useNotificationStore = defineStore('notifications', () => {
const notifications = useStorage<notification[]>('notifications', []) const notifications = useStorage<notification[]>('notifications', []);
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length) const unreadCount = computed<number>(() => notifications.value.filter((n) => !n.seen).length);
const severityLevel = computed<notificationType | null>(() => { const severityLevel = computed<notificationType | null>(() => {
const unread = notifications.value.filter(n => !n.seen) const unread = notifications.value.filter((n) => !n.seen);
if (0 === unread.length) { if (0 === unread.length) {
return null return null;
} }
return unread.reduce((h, n) => _map[n.level].level > _map[h.level].level ? n : h).level return unread.reduce((h, n) => (_map[n.level].level > _map[h.level].level ? n : h)).level;
}) });
const severityColor = computed<string>(() => { const severityColor = computed<string>(() => {
const level = severityLevel.value const level = severityLevel.value;
return level ? _map[level].color : '' return level ? _map[level].color : '';
}) });
const severityIcon = computed<string>(() => { const severityIcon = computed<string>(() => {
const level = severityLevel.value const level = severityLevel.value;
return level ? _map[level].icon : '' return level ? _map[level].icon : '';
}) });
const sortedNotifications = computed<notification[]>(() => { const sortedNotifications = computed<notification[]>(() => {
return [...notifications.value].sort((a, b) => { return [...notifications.value].sort((a, b) => {
const severityDiff = _map[b.level].level - _map[a.level].level const severityDiff = _map[b.level].level - _map[a.level].level;
if (0 !== severityDiff) { if (0 !== severityDiff) {
return severityDiff return severityDiff;
} }
return (new Date(b.created).getTime()) - (new Date(a.created).getTime()) return new Date(b.created).getTime() - new Date(a.created).getTime();
}) });
}) });
const add = (level: notificationType, message: string, seen: boolean = false): string => { const add = (level: notificationType, message: string, seen: boolean = false): string => {
const id = Array.from( const id = Array.from(window.crypto.getRandomValues(new Uint8Array(14 / 2)), (dec: any) =>
window.crypto.getRandomValues(new Uint8Array(14 / 2)), dec.toString(16).padStart(2, '0'),
(dec: any) => dec.toString(16).padStart(2, "0") ).join('');
).join('')
notifications.value.unshift({ notifications.value.unshift({
id: id, id: id,
message, message,
level, level,
seen: seen, seen: seen,
created: new Date() created: new Date(),
}) });
if (notifications.value.length > 99) { if (notifications.value.length > 99) {
notifications.value.length = 99 notifications.value.length = 99;
} }
return id return id;
} };
const clear = () => notifications.value = [] const clear = () => (notifications.value = []);
const markAllRead = () => notifications.value.forEach(n => n.seen = true) const markAllRead = () => notifications.value.forEach((n) => (n.seen = true));
const markRead = (id: string) => { const markRead = (id: string) => {
const n = notifications.value.find(n => n.id === id) const n = notifications.value.find((n) => n.id === id);
if (!n) { if (!n) {
return return;
} }
n.seen = true n.seen = true;
} };
const get = (id: string): notification | undefined => notifications.value.find(n => n.id === id) const get = (id: string): notification | undefined =>
notifications.value.find((n) => n.id === id);
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id) const remove = (id: string) =>
(notifications.value = notifications.value.filter((n) => n.id !== id));
return { return {
notifications, notifications,
@ -90,6 +91,6 @@ export const useNotificationStore = defineStore('notifications', () => {
markAllRead, markAllRead,
clear, clear,
markRead, markRead,
remove remove,
} };
}) });

View file

@ -1,383 +1,404 @@
import { ref, readonly } from 'vue' import { ref, readonly } from 'vue';
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import type { StoreItem } from "~/types/store"; import type { StoreItem } from '~/types/store';
import type { import type {
ConfigUpdatePayload, ConfigUpdatePayload,
WebSocketClientEmits, WebSocketClientEmits,
WebSocketEnvelope, WebSocketEnvelope,
WSEP as WSEP WSEP as WSEP,
} from "~/types/sockets"; } from '~/types/sockets';
export type connectionStatus = 'connected' | 'disconnected' | 'connecting'; export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
type SocketHandler = (...args: unknown[]) => void type SocketHandler = (...args: unknown[]) => void;
type HandlerRegistry = Map<SocketHandler, SocketHandler> type HandlerRegistry = Map<SocketHandler, SocketHandler>;
type KnownEvent = keyof WSEP type KnownEvent = keyof WSEP;
export const useSocketStore = defineStore('socket', () => { export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig();
const config = useConfigStore() const config = useConfigStore();
const stateStore = useStateStore() const stateStore = useStateStore();
const toast = useNotification() const toast = useNotification();
const socket = ref<WebSocket | null>(null) const socket = ref<WebSocket | null>(null);
const isConnected = ref<boolean>(false) const isConnected = ref<boolean>(false);
const connectionStatus = ref<connectionStatus>('disconnected') const connectionStatus = ref<connectionStatus>('disconnected');
const error = ref<string | null>(null) const error = ref<string | null>(null);
const error_count = ref<number>(0) const error_count = ref<number>(0);
const wasHidden = ref<boolean>(false) const wasHidden = ref<boolean>(false);
const reconnectTimeout = ref<NodeJS.Timeout | null>(null) const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
const manualDisconnect = ref<boolean>(false) const manualDisconnect = ref<boolean>(false);
const reconnectAttempts = ref<number>(0) const reconnectAttempts = ref<number>(0);
const handlers = new Map<string, HandlerRegistry>() const handlers = new Map<string, HandlerRegistry>();
const emit = <K extends keyof WebSocketClientEmits>(event: K, data: WebSocketClientEmits[K]): void => { const emit = <K extends keyof WebSocketClientEmits>(
event: K,
data: WebSocketClientEmits[K],
): void => {
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) { if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
return return;
} }
socket.value.send(JSON.stringify({ event, data })) socket.value.send(JSON.stringify({ event, data }));
} };
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void;
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void;
function on<K extends KnownEvent>( function on<K extends KnownEvent>(
event: K | K[], event: K | K[],
callback: (event: K, payload: WSEP[K]) => void, callback: (event: K, payload: WSEP[K]) => void,
withEvent: true withEvent: true,
): void ): void;
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void;
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void { function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
const events = Array.isArray(event) ? event : [event] const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => { events.forEach((eventName) => {
if (!handlers.has(eventName)) { if (!handlers.has(eventName)) {
handlers.set(eventName, new Map()) handlers.set(eventName, new Map());
} }
const registry = handlers.get(eventName) as HandlerRegistry const registry = handlers.get(eventName) as HandlerRegistry;
const handler = true === withEvent const handler =
? (payload: unknown) => callback(eventName, payload) true === withEvent
: (payload: unknown) => callback(payload) ? (payload: unknown) => callback(eventName, payload)
: (payload: unknown) => callback(payload);
registry.set(callback, handler) registry.set(callback, handler);
}) });
} }
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void;
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void;
function off(event: string | string[], callback?: SocketHandler): void function off(event: string | string[], callback?: SocketHandler): void;
function off(event: string | string[], callback?: SocketHandler): void { function off(event: string | string[], callback?: SocketHandler): void {
const events = Array.isArray(event) ? event : [event] const events = Array.isArray(event) ? event : [event];
events.forEach((eventName) => { events.forEach((eventName) => {
const registry = handlers.get(eventName) const registry = handlers.get(eventName);
if (!registry) { if (!registry) {
return return;
} }
if (!callback) { if (!callback) {
registry.clear() registry.clear();
handlers.delete(eventName) handlers.delete(eventName);
return return;
} }
registry.delete(callback) registry.delete(callback);
if (0 === registry.size) { if (0 === registry.size) {
handlers.delete(eventName) handlers.delete(eventName);
} }
}) });
} }
const getSessionId = (): string | null => null const getSessionId = (): string | null => null;
const dispatch = (eventName: string, payload: unknown): void => { const dispatch = (eventName: string, payload: unknown): void => {
const registry = handlers.get(eventName) const registry = handlers.get(eventName);
if (!registry) { if (!registry) {
return return;
} }
registry.forEach((handler) => handler(payload)) registry.forEach((handler) => handler(payload));
} };
const handleVisibilityChange = () => { const handleVisibilityChange = () => {
if (document.hidden) { if (document.hidden) {
wasHidden.value = true wasHidden.value = true;
return return;
} }
if (true === wasHidden.value && false === isConnected.value) { if (true === wasHidden.value && false === isConnected.value) {
if (null !== reconnectTimeout.value) { if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value) clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null reconnectTimeout.value = null;
} }
reconnectTimeout.value = setTimeout(() => { reconnectTimeout.value = setTimeout(() => {
if (false === isConnected.value) { if (false === isConnected.value) {
console.debug('[SocketStore] Page visible after background, reconnecting...') console.debug('[SocketStore] Page visible after background, reconnecting...');
reconnect() reconnect();
} }
reconnectTimeout.value = null reconnectTimeout.value = null;
}, 100) }, 100);
} }
wasHidden.value = false wasHidden.value = false;
} };
const setupVisibilityListener = () => { const setupVisibilityListener = () => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange);
} }
} };
const cleanupVisibilityListener = () => { const cleanupVisibilityListener = () => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange);
} }
if (null !== reconnectTimeout.value) { if (null !== reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value) clearTimeout(reconnectTimeout.value);
reconnectTimeout.value = null reconnectTimeout.value = null;
} }
} };
const scheduleReconnect = () => { const scheduleReconnect = () => {
if (true === manualDisconnect.value || true === isConnected.value) { if (true === manualDisconnect.value || true === isConnected.value) {
return return;
} }
if (reconnectAttempts.value >= 50) { if (reconnectAttempts.value >= 50) {
return return;
} }
if (null !== reconnectTimeout.value) { if (null !== reconnectTimeout.value) {
return return;
} }
reconnectTimeout.value = setTimeout(() => { reconnectTimeout.value = setTimeout(() => {
reconnectAttempts.value += 1 reconnectAttempts.value += 1;
reconnectTimeout.value = null reconnectTimeout.value = null;
connect() connect();
}, 5000) }, 5000);
} };
const reconnect = () => { const reconnect = () => {
if (true === isConnected.value) { if (true === isConnected.value) {
return return;
} }
connect() connect();
connectionStatus.value = 'connecting' connectionStatus.value = 'connecting';
} };
const disconnect = () => { const disconnect = () => {
manualDisconnect.value = true manualDisconnect.value = true;
if (null === socket.value) { if (null === socket.value) {
return return;
} }
socket.value.close() socket.value.close();
socket.value = null socket.value = null;
isConnected.value = false isConnected.value = false;
connectionStatus.value = 'disconnected' connectionStatus.value = 'disconnected';
cleanupVisibilityListener() cleanupVisibilityListener();
} };
const buildWsUrl = (): string => { const buildWsUrl = (): string => {
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '') const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '');
const wsPath = `${basePath}/ws?_=${Date.now()}` const wsPath = `${basePath}/ws?_=${Date.now()}`;
const configuredBase = runtimeConfig.public.wss?.trim() const configuredBase = runtimeConfig.public.wss?.trim();
if (configuredBase) { if (configuredBase) {
return new URL(wsPath, configuredBase).toString() return new URL(wsPath, configuredBase).toString();
} }
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws' const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws';
return new URL(wsPath, `${scheme}://${window.location.host}`).toString() return new URL(wsPath, `${scheme}://${window.location.host}`).toString();
} };
const connect = () => { const connect = () => {
if (socket.value && WebSocket.OPEN === socket.value.readyState) { if (socket.value && WebSocket.OPEN === socket.value.readyState) {
return return;
} }
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) { if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
return return;
} }
manualDisconnect.value = false manualDisconnect.value = false;
connectionStatus.value = 'connecting' connectionStatus.value = 'connecting';
socket.value = new WebSocket(buildWsUrl()) socket.value = new WebSocket(buildWsUrl());
if ('development' === runtimeConfig.public?.APP_ENV) { if ('development' === runtimeConfig.public?.APP_ENV) {
window.ws = socket.value window.ws = socket.value;
} }
socket.value.addEventListener('open', () => { socket.value.addEventListener('open', () => {
isConnected.value = true isConnected.value = true;
connectionStatus.value = 'connected' connectionStatus.value = 'connected';
error.value = null error.value = null;
error_count.value = 0 error_count.value = 0;
reconnectAttempts.value = 0 reconnectAttempts.value = 0;
dispatch('connect', null) dispatch('connect', null);
}) });
socket.value.addEventListener('close', () => { socket.value.addEventListener('close', () => {
isConnected.value = false isConnected.value = false;
connectionStatus.value = 'disconnected' connectionStatus.value = 'disconnected';
error.value = 'Disconnected from server.' error.value = 'Disconnected from server.';
dispatch('disconnect', null) dispatch('disconnect', null);
scheduleReconnect() scheduleReconnect();
}) });
socket.value.addEventListener('error', () => { socket.value.addEventListener('error', () => {
isConnected.value = false isConnected.value = false;
connectionStatus.value = 'disconnected' connectionStatus.value = 'disconnected';
error.value = 'Connection error: Unknown error' error.value = 'Connection error: Unknown error';
error_count.value += 1 error_count.value += 1;
dispatch('connect_error', { message: 'Unknown error' }) dispatch('connect_error', { message: 'Unknown error' });
scheduleReconnect() scheduleReconnect();
}) });
socket.value.addEventListener('message', (event: MessageEvent<string>) => { socket.value.addEventListener('message', (event: MessageEvent<string>) => {
let payload: WebSocketEnvelope | null = null let payload: WebSocketEnvelope | null = null;
try { try {
payload = JSON.parse(event.data) payload = JSON.parse(event.data);
} catch { } catch {
return return;
} }
if (!payload?.event || 'string' != typeof payload.event) { if (!payload?.event || 'string' != typeof payload.event) {
return return;
} }
let data = payload.data let data = payload.data;
if ('string' === typeof data) { if ('string' === typeof data) {
try { try {
data = JSON.parse(data) data = JSON.parse(data);
} catch { } catch {
data = payload.data data = payload.data;
} }
} }
dispatch(payload.event, data) dispatch(payload.event, data);
}) });
setupVisibilityListener() setupVisibilityListener();
} };
on('connect', () => config.loadConfig(false)) on('connect', () => config.loadConfig(false));
on('connected', () => { on('connected', () => {
error.value = null error.value = null;
config.loadConfig(false) config.loadConfig(false);
}) });
on('item_added', (data: WSEP['item_added']) => { on('item_added', (data: WSEP['item_added']) => {
stateStore.add('queue', data.data._id, data.data) stateStore.add('queue', data.data._id, data.data);
toast.success(`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`) toast.success(
}) `Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`,
);
});
on(['log_info', 'log_success', 'log_warning', 'log_error'], (event, data: WSEP['log_info']) => { on(
const message = 'string' === typeof data?.message ['log_info', 'log_success', 'log_warning', 'log_error'],
? data.message (event, data: WSEP['log_info']) => {
: String((data?.data as Record<string, unknown>)?.message ?? '') const message =
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {} 'string' === typeof data?.message
switch (event) { ? data.message
case 'log_info': : String((data?.data as Record<string, unknown>)?.message ?? '');
toast.info(message, extra) const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {};
break switch (event) {
case 'log_success': case 'log_info':
toast.success(message, extra) toast.info(message, extra);
break break;
case 'log_warning': case 'log_success':
toast.warning(message, extra) toast.success(message, extra);
break break;
case 'log_error': case 'log_warning':
toast.error(message, extra) toast.warning(message, extra);
break break;
} case 'log_error':
}, true) toast.error(message, extra);
break;
}
},
true,
);
on('item_cancelled', (data: WSEP['item_cancelled']) => { on('item_cancelled', (data: WSEP['item_cancelled']) => {
const id = data.data._id const id = data.data._id;
if (true !== stateStore.has('queue', id)) { if (true !== stateStore.has('queue', id)) {
return return;
} }
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`) toast.warning(
`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`,
);
if (true === stateStore.has('queue', id)) { if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id) stateStore.remove('queue', id);
} }
}) });
on('item_deleted', (data: WSEP['item_deleted']) => { on('item_deleted', (data: WSEP['item_deleted']) => {
const id = data.data._id const id = data.data._id;
if (true !== stateStore.has('history', id)) { if (true !== stateStore.has('history', id)) {
return return;
} }
stateStore.remove('history', id) stateStore.remove('history', id);
}) });
on('item_updated', (data: WSEP['item_updated']) => { on('item_updated', (data: WSEP['item_updated']) => {
const id = data.data._id const id = data.data._id;
if (true === stateStore.has('history', id)) { if (true === stateStore.has('history', id)) {
stateStore.update('history', id, data.data) stateStore.update('history', id, data.data);
return return;
} }
if (true === stateStore.has('queue', id)) { if (true === stateStore.has('queue', id)) {
stateStore.update('queue', id, data.data) stateStore.update('queue', id, data.data);
} }
}) });
on('item_moved', (data: WSEP['item_moved']) => { on('item_moved', (data: WSEP['item_moved']) => {
const to = data.data.to const to = data.data.to;
const id = data.data.item._id const id = data.data.item._id;
if ('queue' === to) { if ('queue' === to) {
if (true === stateStore.has('history', id)) { if (true === stateStore.has('history', id)) {
stateStore.remove('history', id) stateStore.remove('history', id);
} }
stateStore.add('queue', id, data.data.item) stateStore.add('queue', id, data.data.item);
} }
if ('history' === to) { if ('history' === to) {
if (true === stateStore.has('queue', id)) { if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id) stateStore.remove('queue', id);
} }
stateStore.add('history', id, data.data.item) stateStore.add('history', id, data.data.item);
} }
}) });
on(['paused', 'resumed'], (event, data: WSEP['paused']) => { on(
const pausedState = Boolean(data.data.paused) ['paused', 'resumed'],
config.update('paused', pausedState) (event, data: WSEP['paused']) => {
const pausedState = Boolean(data.data.paused);
config.update('paused', pausedState);
if ('resumed' === event) { if ('resumed' === event) {
toast.success('Download queue resumed.') toast.success('Download queue resumed.');
return return;
} }
toast.warning('Download queue paused.', { timeout: 10000 }) toast.warning('Download queue paused.', { timeout: 10000 });
}, true) },
true,
);
on('config_update', (data: WSEP['config_update']) => { on('config_update', (data: WSEP['config_update']) => {
const configUpdate = data.data as ConfigUpdatePayload const configUpdate = data.data as ConfigUpdatePayload;
if (!configUpdate) { if (!configUpdate) {
return return;
} }
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data) config.patch(configUpdate.feature, configUpdate.action, configUpdate.data);
}) });
return { return {
connect, reconnect, disconnect, connect,
on, off, emit, reconnect,
disconnect,
on,
off,
emit,
isConnected, isConnected,
getSessionId, getSessionId,
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>, connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>, error: readonly(error) as Readonly<Ref<string | null>>,
error_count: readonly(error_count) as Readonly<Ref<number>>, error_count: readonly(error_count) as Readonly<Ref<number>>,
} };
}) });

View file

@ -1,24 +1,24 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item';
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store';
import { request } from '~/utils' import { request } from '~/utils';
type StateType = 'queue' | 'history' type StateType = 'queue' | 'history';
type KeyType = string type KeyType = string;
interface State { interface State {
queue: Record<KeyType, StoreItem> queue: Record<KeyType, StoreItem>;
history: Record<KeyType, StoreItem> history: Record<KeyType, StoreItem>;
pagination: { pagination: {
page: number page: number;
per_page: number per_page: number;
total: number total: number;
total_pages: number total_pages: number;
has_next: boolean has_next: boolean;
has_prev: boolean has_prev: boolean;
isLoaded: boolean isLoaded: boolean;
isLoading: boolean isLoading: boolean;
} };
} }
export const useStateStore = defineStore('state', () => { export const useStateStore = defineStore('state', () => {
@ -35,110 +35,121 @@ export const useStateStore = defineStore('state', () => {
isLoaded: false, isLoaded: false,
isLoading: false, isLoading: false,
}, },
}) });
const add = (type: StateType, key: KeyType, value: StoreItem): void => { const add = (type: StateType, key: KeyType, value: StoreItem): void => {
if ('history' === type && state.pagination.total > 0) { if ('history' === type && state.pagination.total > 0) {
state.pagination.total += 1 state.pagination.total += 1;
} }
state[type][key] = value state[type][key] = value;
} };
const update = (type: StateType, key: KeyType, value: StoreItem): void => { const update = (type: StateType, key: KeyType, value: StoreItem): void => {
state[type][key] = value state[type][key] = value;
} };
const remove = (type: StateType, key: KeyType): void => { const remove = (type: StateType, key: KeyType): void => {
if (!state[type][key]) { if (!state[type][key]) {
return return;
} }
if ('history' === type && state.pagination.total > 0) { if ('history' === type && state.pagination.total > 0) {
state.pagination.total -= 1 state.pagination.total -= 1;
} }
const { [key]: _, ...rest } = state[type] const { [key]: _, ...rest } = state[type];
state[type] = rest state[type] = rest;
} };
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => { const get = (
return state[type][key] || defaultValue type: StateType,
} key: KeyType,
defaultValue: StoreItem | null = null,
): StoreItem | null => {
return state[type][key] || defaultValue;
};
const has = (type: StateType, key: KeyType): boolean => { const has = (type: StateType, key: KeyType): boolean => {
return !!state[type][key] return !!state[type][key];
} };
const clearAll = (type: StateType): void => { const clearAll = (type: StateType): void => {
state[type] = {} state[type] = {};
if ('queue' === type) { if ('queue' === type) {
return return;
} }
state.pagination.total = 0 state.pagination.total = 0;
state.pagination.page = 1 state.pagination.page = 1;
state.pagination.total_pages = 0 state.pagination.total_pages = 0;
state.pagination.has_next = false state.pagination.has_next = false;
state.pagination.has_prev = false state.pagination.has_prev = false;
} };
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => { const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
state[type] = data state[type] = data;
} };
const move = (fromType: StateType, toType: StateType, key: KeyType): void => { const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
if (true === has(fromType, key)) { if (true === has(fromType, key)) {
remove(fromType, key) remove(fromType, key);
} }
add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem) add(toType, key, get(fromType, key, {} as StoreItem) as StoreItem);
} };
const count = (type: StateType): number => { const count = (type: StateType): number => {
if ('history' === type && state.pagination.total > 0) { if ('history' === type && state.pagination.total > 0) {
return state.pagination.total return state.pagination.total;
} }
return Object.keys(state[type]).length return Object.keys(state[type]).length;
} };
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false, status?: string): Promise<void> => { const loadPaginated = async (
type: StateType,
page: number = 1,
per_page: number = 50,
order: 'ASC' | 'DESC' = 'DESC',
append: boolean = false,
status?: string,
): Promise<void> => {
if ('history' !== type) { if ('history' !== type) {
throw new Error('Pagination is only supported for history type'); throw new Error('Pagination is only supported for history type');
} }
state.pagination.isLoading = true state.pagination.isLoading = true;
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
type: 'done', type: 'done',
page: page.toString(), page: page.toString(),
per_page: per_page.toString(), per_page: per_page.toString(),
order order,
} };
if (status) { if (status) {
params.status = status params.status = status;
} }
const search = new URLSearchParams(params) const search = new URLSearchParams(params);
const response = await request(`/api/history?${search}`) const response = await request(`/api/history?${search}`);
const data = await response.json() const data = await response.json();
if (data.pagination) { if (data.pagination) {
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false, } state.pagination = { ...data.pagination, isLoaded: true, isLoading: false };
const items: Record<KeyType, StoreItem> = {} const items: Record<KeyType, StoreItem> = {};
for (const item of data.items || []) { for (const item of data.items || []) {
items[item._id] = item items[item._id] = item;
} }
state[type] = append ? { ...state[type], ...items } : items state[type] = append ? { ...state[type], ...items } : items;
} }
} catch (error) { } catch (error) {
console.error(`Failed to load ${type} page ${page}:`, error) console.error(`Failed to load ${type} page ${page}:`, error);
state.pagination.isLoading = false state.pagination.isLoading = false;
} }
} };
const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => { const loadNextPage = async (type: StateType, append: boolean = false): Promise<void> => {
if ('history' !== type) { if ('history' !== type) {
@ -146,11 +157,11 @@ export const useStateStore = defineStore('state', () => {
} }
if (!state.pagination.has_next || state.pagination.isLoading) { if (!state.pagination.has_next || state.pagination.isLoading) {
return return;
} }
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append) await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page, 'DESC', append);
} };
const loadPreviousPage = async (type: StateType): Promise<void> => { const loadPreviousPage = async (type: StateType): Promise<void> => {
if ('history' !== type) { if ('history' !== type) {
@ -158,31 +169,31 @@ export const useStateStore = defineStore('state', () => {
} }
if (!state.pagination.has_prev || state.pagination.isLoading) { if (!state.pagination.has_prev || state.pagination.isLoading) {
return return;
} }
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page) await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page);
} };
const reloadCurrentPage = async (type: StateType): Promise<void> => { const reloadCurrentPage = async (type: StateType): Promise<void> => {
if ('history' !== type) { if ('history' !== type) {
throw new Error('Pagination is only supported for history type'); throw new Error('Pagination is only supported for history type');
} }
if (!state.pagination.isLoaded) { if (!state.pagination.isLoaded) {
return return;
} }
await loadPaginated(type, state.pagination.page, state.pagination.per_page) await loadPaginated(type, state.pagination.page, state.pagination.per_page);
} };
const getPagination = () => state.pagination const getPagination = () => state.pagination;
const setHistoryCount = (count: number) => { const setHistoryCount = (count: number) => {
state.pagination.total = count state.pagination.total = count;
if (count > 0 && !state.pagination.isLoaded) { if (count > 0 && !state.pagination.isLoaded) {
state.pagination.isLoaded = false state.pagination.isLoaded = false;
} }
} };
/** /**
* Load queue data from REST API. * Load queue data from REST API.
@ -192,19 +203,19 @@ export const useStateStore = defineStore('state', () => {
*/ */
const loadQueue = async (): Promise<void> => { const loadQueue = async (): Promise<void> => {
try { try {
const response = await request('/api/history/live') const response = await request('/api/history/live');
const data = await response.json() as { const data = (await response.json()) as {
"queue": Record<KeyType, StoreItem>, queue: Record<KeyType, StoreItem>;
"history_count": number, history_count: number;
} };
state.queue = data.queue || {} state.queue = data.queue || {};
setHistoryCount(data.history_count) setHistoryCount(data.history_count);
} catch (error) { } catch (error) {
console.error('Failed to load queue:', error) console.error('Failed to load queue:', error);
throw error throw error;
} }
} };
/** /**
* Add a download using WebSocket if connected, fallback to REST API. * Add a download using WebSocket if connected, fallback to REST API.
@ -213,37 +224,37 @@ export const useStateStore = defineStore('state', () => {
* @returns Promise that resolves when download is added * @returns Promise that resolves when download is added
*/ */
const addDownload = async (data: item_request): Promise<void> => { const addDownload = async (data: item_request): Promise<void> => {
const socket = useSocketStore() const socket = useSocketStore();
const toast = useNotification() const toast = useNotification();
if (socket.isConnected) { if (socket.isConnected) {
socket.emit('add_url', data) socket.emit('add_url', data);
return return;
} }
try { try {
const response = await request('/api/history/', { const response = await request('/api/history/', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data),
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json();
toast.error(error.error || 'Failed to add download') toast.error(error.error || 'Failed to add download');
throw new Error(error.error || 'Failed to add download') throw new Error(error.error || 'Failed to add download');
} }
toast.success('Download added successfully') toast.success('Download added successfully');
await loadQueue() await loadQueue();
} catch (error) { } catch (error) {
console.error('Failed to add download:', error) console.error('Failed to add download:', error);
if (error instanceof Error && !error.message.includes('Failed to add download')) { if (error instanceof Error && !error.message.includes('Failed to add download')) {
toast.error('Failed to add download') toast.error('Failed to add download');
} }
throw error throw error;
} }
} };
/** /**
* Start one or more downloads using WebSocket if connected, fallback to REST API. * Start one or more downloads using WebSocket if connected, fallback to REST API.
@ -252,47 +263,47 @@ export const useStateStore = defineStore('state', () => {
* @returns Promise that resolves when items are started * @returns Promise that resolves when items are started
*/ */
const startItems = async (ids: string[]): Promise<void> => { const startItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore() const socket = useSocketStore();
const toast = useNotification() const toast = useNotification();
if (socket.isConnected) { if (socket.isConnected) {
ids.forEach(id => socket.emit('item_start', id)) ids.forEach((id) => socket.emit('item_start', id));
return return;
} }
try { try {
const response = await request('/api/history/start', { const response = await request('/api/history/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }) body: JSON.stringify({ ids }),
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json();
toast.error(error.error || 'Failed to start items') toast.error(error.error || 'Failed to start items');
throw new Error(error.error || 'Failed to start items') throw new Error(error.error || 'Failed to start items');
} }
const result = await response.json() const result = await response.json();
for (const id of ids) { for (const id of ids) {
if ('started' === result[id]) { if ('started' === result[id]) {
const item = get('queue', id) const item = get('queue', id);
if (item) { if (item) {
update('queue', id, { ...item, auto_start: true }) update('queue', id, { ...item, auto_start: true });
} }
} }
} }
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`) toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) { } catch (error) {
console.error('Failed to start items:', error) console.error('Failed to start items:', error);
if (error instanceof Error && !error.message.includes('Failed to start items')) { if (error instanceof Error && !error.message.includes('Failed to start items')) {
toast.error('Failed to start items') toast.error('Failed to start items');
} }
throw error throw error;
} }
} };
/** /**
* Pause one or more downloads using WebSocket if connected, fallback to REST API. * Pause one or more downloads using WebSocket if connected, fallback to REST API.
@ -301,47 +312,47 @@ export const useStateStore = defineStore('state', () => {
* @returns Promise that resolves when items are paused * @returns Promise that resolves when items are paused
*/ */
const pauseItems = async (ids: string[]): Promise<void> => { const pauseItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore() const socket = useSocketStore();
const toast = useNotification() const toast = useNotification();
if (socket.isConnected) { if (socket.isConnected) {
ids.forEach(id => socket.emit('item_pause', id)) ids.forEach((id) => socket.emit('item_pause', id));
return return;
} }
try { try {
const response = await request('/api/history/pause', { const response = await request('/api/history/pause', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }) body: JSON.stringify({ ids }),
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json();
toast.error(error.error || 'Failed to pause items') toast.error(error.error || 'Failed to pause items');
throw new Error(error.error || 'Failed to pause items') throw new Error(error.error || 'Failed to pause items');
} }
const result = await response.json() const result = await response.json();
for (const id of ids) { for (const id of ids) {
if ('paused' === result[id]) { if ('paused' === result[id]) {
const item = get('queue', id) const item = get('queue', id);
if (item) { if (item) {
update('queue', id, { ...item, auto_start: false }) update('queue', id, { ...item, auto_start: false });
} }
} }
} }
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`) toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) { } catch (error) {
console.error('Failed to pause items:', error) console.error('Failed to pause items:', error);
if (error instanceof Error && !error.message.includes('Failed to pause items')) { if (error instanceof Error && !error.message.includes('Failed to pause items')) {
toast.error('Failed to pause items') toast.error('Failed to pause items');
} }
throw error throw error;
} }
} };
/** /**
* Cancel one or more downloads using WebSocket if connected, fallback to REST API. * Cancel one or more downloads using WebSocket if connected, fallback to REST API.
@ -350,19 +361,19 @@ export const useStateStore = defineStore('state', () => {
* @returns Promise that resolves when items are cancelled * @returns Promise that resolves when items are cancelled
*/ */
const cancelItems = async (ids: string[]): Promise<void> => { const cancelItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore() const socket = useSocketStore();
const toast = useNotification() const toast = useNotification();
if (socket.isConnected) { if (socket.isConnected) {
ids.forEach(id => socket.emit('item_cancel', id)) ids.forEach((id) => socket.emit('item_cancel', id));
return return;
} }
const itemsToMove: Record<string, StoreItem> = {} const itemsToMove: Record<string, StoreItem> = {};
for (const id of ids) { for (const id of ids) {
const item = get('queue', id) const item = get('queue', id);
if (item) { if (item) {
itemsToMove[id] = { ...item } itemsToMove[id] = { ...item };
} }
} }
@ -370,34 +381,34 @@ export const useStateStore = defineStore('state', () => {
const response = await request('/api/history/cancel', { const response = await request('/api/history/cancel', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }) body: JSON.stringify({ ids }),
}) });
if (!response.ok) { if (!response.ok) {
const error = await response.json() const error = await response.json();
toast.error(error.error || 'Failed to cancel items') toast.error(error.error || 'Failed to cancel items');
throw new Error(error.error || 'Failed to cancel items') throw new Error(error.error || 'Failed to cancel items');
} }
const result = await response.json() const result = await response.json();
for (const id of ids) { for (const id of ids) {
if ('ok' === result[id] && itemsToMove[id]) { if ('ok' === result[id] && itemsToMove[id]) {
remove('queue', id) remove('queue', id);
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem;
add('history', id, cancelledItem) add('history', id, cancelledItem);
} }
} }
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`) toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`);
} catch (error) { } catch (error) {
console.error('Failed to cancel items:', error) console.error('Failed to cancel items:', error);
if (error instanceof Error && !error.message.includes('Failed to cancel items')) { if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
toast.error('Failed to cancel items') toast.error('Failed to cancel items');
} }
throw error throw error;
} }
} };
/** /**
* Remove items using WebSocket if connected, fallback to REST API. * Remove items using WebSocket if connected, fallback to REST API.
@ -407,23 +418,27 @@ export const useStateStore = defineStore('state', () => {
* @param removeFile - Whether to remove files from disk (default: false) * @param removeFile - Whether to remove files from disk (default: false)
* @returns Promise that resolves when items are removed * @returns Promise that resolves when items are removed
*/ */
const removeItems = async (type: StateType, ids: string[], removeFile: boolean = false): Promise<void> => { const removeItems = async (
const socket = useSocketStore() type: StateType,
const toast = useNotification() ids: string[],
removeFile: boolean = false,
): Promise<void> => {
const socket = useSocketStore();
const toast = useNotification();
if (socket.isConnected) { if (socket.isConnected) {
ids.forEach(id => socket.emit('item_delete', { id, remove_file: removeFile })) ids.forEach((id) => socket.emit('item_delete', { id, remove_file: removeFile }));
return return;
} }
try { try {
await deleteItems(type, { ids, removeFile }) await deleteItems(type, { ids, removeFile });
} catch (error) { } catch (error) {
console.error('Failed to remove items:', error) console.error('Failed to remove items:', error);
toast.error('Failed to remove items') toast.error('Failed to remove items');
throw error throw error;
} }
} };
/** /**
* Delete items by specific IDs or status filter. * Delete items by specific IDs or status filter.
@ -438,55 +453,55 @@ export const useStateStore = defineStore('state', () => {
*/ */
const deleteItems = async ( const deleteItems = async (
type: StateType, type: StateType,
options: { ids?: string[]; status?: string; removeFile?: boolean } = {} options: { ids?: string[]; status?: string; removeFile?: boolean } = {},
): Promise<number> => { ): Promise<number> => {
const { ids, status, removeFile = true } = options const { ids, status, removeFile = true } = options;
if (!ids && !status) { if (!ids && !status) {
throw new Error('Either ids or status filter must be provided') throw new Error('Either ids or status filter must be provided');
} }
try { try {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
type: type === 'queue' ? 'queue' : 'done', type: type === 'queue' ? 'queue' : 'done',
remove_file: removeFile remove_file: removeFile,
} };
if (ids) { if (ids) {
body.ids = ids body.ids = ids;
} }
if (status) { if (status) {
body.status = status body.status = status;
} }
const response = await request('/api/history', { const response = await request('/api/history', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body),
}) });
const result = await response.json() as { const result = (await response.json()) as {
items: Record<string, string>, items: Record<string, string>;
deleted: number, deleted: number;
error?: string, error?: string;
message?: string, message?: string;
} };
if (result.error || result.message || !response.ok) { if (result.error || result.message || !response.ok) {
throw new Error(result.error || result.message || 'Failed to delete items.') throw new Error(result.error || result.message || 'Failed to delete items.');
} }
for (const id of Object.keys(result.items)) { for (const id of Object.keys(result.items)) {
remove(type, id) remove(type, id);
} }
return result.deleted return result.deleted;
} catch (error) { } catch (error) {
console.error(`Failed to delete items:`, error) console.error(`Failed to delete items:`, error);
throw error throw error;
} }
} };
return { return {
...toRefs(state), ...toRefs(state),
@ -512,5 +527,5 @@ export const useStateStore = defineStore('state', () => {
cancelItems, cancelItems,
removeItems, removeItems,
deleteItems, deleteItems,
} };
}) });

View file

@ -1,9 +1,8 @@
interface Option { interface Option {
value: string value: string;
description: string description: string;
} }
type AutoCompleteOptions = Option[];
type AutoCompleteOptions = Option[] export type { Option, AutoCompleteOptions };
export type { Option, AutoCompleteOptions }

View file

@ -1,18 +1,18 @@
type changelogs = changeset[] type changelogs = changeset[];
type changeset = { type changeset = {
tag: string tag: string;
full_sha: string full_sha: string;
date: string date: string;
commits: Commit[] commits: Commit[];
} };
type Commit = { type Commit = {
sha: string sha: string;
full_sha: string full_sha: string;
message: string message: string;
author: string author: string;
date: string date: string;
} };
export type {changelogs, changeset, Commit} export type { changelogs, changeset, Commit };

View file

@ -1,31 +1,31 @@
export interface Condition { export interface Condition {
id?: number id?: number;
name: string name: string;
filter: string filter: string;
cli: string cli: string;
extras: Record<string, unknown> extras: Record<string, unknown>;
enabled: boolean enabled: boolean;
priority: number priority: number;
description: string description: string;
} }
export interface Pagination { export interface Pagination {
page: number page: number;
per_page: number per_page: number;
total: number total: number;
total_pages: number total_pages: number;
has_next: boolean has_next: boolean;
has_prev: boolean has_prev: boolean;
} }
export interface ConditionTestRequest { export interface ConditionTestRequest {
url: string url: string;
condition: string condition: string;
preset?: string preset?: string;
} }
export interface ConditionTestResponse { export interface ConditionTestResponse {
status: boolean status: boolean;
condition: string condition: string;
data: Record<string, unknown> data: Record<string, unknown>;
} }

View file

@ -1,77 +1,77 @@
import type { Preset } from "./presets"; import type { Preset } from './presets';
import type { YTDLPOption } from './ytdlp'; import type { YTDLPOption } from './ytdlp';
import type { DLField } from "./dl_fields" import type { DLField } from './dl_fields';
type AppConfig = { type AppConfig = {
/** Path where downloaded files will be saved */ /** Path where downloaded files will be saved */
download_path: string download_path: string;
/** Indicates if files should be removed after download */ /** Indicates if files should be removed after download */
remove_files: boolean remove_files: boolean;
/** Indicates if the UI should update the title with the current download status */ /** Indicates if the UI should update the title with the current download status */
ui_update_title: boolean ui_update_title: boolean;
/** Output template for yt-dlp, e.g. "%(title)s.%(ext)s" */ /** Output template for yt-dlp, e.g. "%(title)s.%(ext)s" */
output_template: string output_template: string;
/** yt-dlp version, e.g. "2023.10.01" */ /** yt-dlp version, e.g. "2023.10.01" */
ytdlp_version: string ytdlp_version: string;
/** Maximum number of concurrent download workers */ /** Maximum number of concurrent download workers */
max_workers: number max_workers: number;
/** Maximum number of concurrent workers per extractor */ /** Maximum number of concurrent workers per extractor */
max_workers_per_extractor: number max_workers_per_extractor: number;
/** Default preset name, e.g. "default" */ /** Default preset name, e.g. "default" */
default_preset: string default_preset: string;
/** Instance title for the app, null if not set */ /** Instance title for the app, null if not set */
instance_title: string | null instance_title: string | null;
/** Indicates if the console is enabled */ /** Indicates if the console is enabled */
console_enabled: boolean console_enabled: boolean;
/** Indicates if the file browser control is enabled */ /** Indicates if the file browser control is enabled */
browser_control_enabled: boolean browser_control_enabled: boolean;
/** Indicates if file logging is enabled */ /** Indicates if file logging is enabled */
file_logging: boolean file_logging: boolean;
/** Indicates if the app is in simple mode */ /** Indicates if the app is in simple mode */
simple_mode: boolean simple_mode: boolean;
/** Indicates if the app is running in a native environment (e.g., Electron) */ /** Indicates if the app is running in a native environment (e.g., Electron) */
is_native: boolean is_native: boolean;
/** App version in format "1.0.0" */ /** App version in format "1.0.0" */
app_version: string app_version: string;
/** App version in semantic versioning format, e.g. "1.0.0" */ /** App version in semantic versioning format, e.g. "1.0.0" */
app_commit_sha: string app_commit_sha: string;
/** App build date in ISO format, e.g. "2023-10-01T12:00:00Z" */ /** App build date in ISO format, e.g. "2023-10-01T12:00:00Z" */
app_build_date: string app_build_date: string;
/** App branch name, e.g. "main" or "develop" */ /** App branch name, e.g. "main" or "develop" */
app_branch: string app_branch: string;
/** When the app started */ /** When the app started */
started: number, started: number;
/** Application environment, e.g. "production", "development" */ /** Application environment, e.g. "production", "development" */
app_env: "production" | "development" app_env: 'production' | 'development';
/** Default number of items per page for pagination */ /** Default number of items per page for pagination */
default_pagination: number default_pagination: number;
/** Indicates if the app should check for updates */ /** Indicates if the app should check for updates */
check_for_updates: boolean check_for_updates: boolean;
/** New version available, empty string if none */ /** New version available, empty string if none */
new_version: string new_version: string;
/** New yt-dlp version available, empty string if none */ /** New yt-dlp version available, empty string if none */
yt_new_version: string yt_new_version: string;
} };
type ConfigState = { type ConfigState = {
/** Show or hide the download form */ /** Show or hide the download form */
showForm: RemovableRef<boolean> showForm: RemovableRef<boolean>;
/** Application configuration */ /** Application configuration */
app: AppConfig app: AppConfig;
/** List of presets */ /** List of presets */
presets: Array<Preset> presets: Array<Preset>;
/** List of custom download fields */ /** List of custom download fields */
dl_fields: Array<DLField> dl_fields: Array<DLField>;
/** List of folders where files can be saved */ /** List of folders where files can be saved */
folders: Array<string> folders: Array<string>;
/** List of yt-dlp options */ /** List of yt-dlp options */
ytdlp_options: Array<YTDLPOption> ytdlp_options: Array<YTDLPOption>;
/** Indicates if downloads are currently paused */ /** Indicates if downloads are currently paused */
paused: boolean paused: boolean;
/** Indicates if the configuration has been loaded */ /** Indicates if the configuration has been loaded */
is_loaded: boolean is_loaded: boolean;
/** Indicates if the configuration is currently loading */ /** Indicates if the configuration is currently loading */
is_loading: boolean is_loading: boolean;
} };
export type { AppConfig, ConfigState } export type { AppConfig, ConfigState };

View file

@ -1,4 +1,4 @@
type DLFieldType = "string" | "text" | "bool"; type DLFieldType = 'string' | 'text' | 'bool';
type DLField = { type DLField = {
/** The id of the field */ /** The id of the field */
@ -27,7 +27,7 @@ type DLField = {
/** Additional options for the field */ /** Additional options for the field */
extras: Record<string, any>; extras: Record<string, any>;
} };
/** /**
* Request payload for creating/updating DLField * Request payload for creating/updating DLField
@ -39,6 +39,6 @@ type DLFieldRequest = {
kind: DLFieldType; kind: DLFieldType;
value?: string; value?: string;
extras?: Record<string, any>; extras?: Record<string, any>;
} };
export type { DLField, DLFieldRequest, DLFieldType }; export type { DLField, DLFieldRequest, DLFieldType };

View file

@ -1,30 +1,30 @@
type Pagination = { type Pagination = {
page: number page: number;
per_page: number per_page: number;
total: number total: number;
total_pages: number total_pages: number;
has_next: boolean has_next: boolean;
has_prev: boolean has_prev: boolean;
} };
type FileItem = { type FileItem = {
type: 'file' | 'dir' | 'link' type: 'file' | 'dir' | 'link';
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string;
name: string name: string;
path: string path: string;
size: number size: number;
mime: string mime: string;
mtime: string mtime: string;
ctime: string ctime: string;
is_dir: boolean is_dir: boolean;
is_file: boolean is_file: boolean;
is_symlink: boolean is_symlink: boolean;
} };
type FileBrowserResponse = { type FileBrowserResponse = {
path: string path: string;
contents: FileItem[] contents: FileItem[];
pagination: Pagination pagination: Pagination;
} };
export type { FileItem, FileBrowserResponse, Pagination } export type { FileItem, FileBrowserResponse, Pagination };

View file

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

View file

@ -1,19 +1,19 @@
type ImportedItem = { type ImportedItem = {
_type: string, _type: string;
_version: string _version: string;
} };
type version_check = { type version_check = {
app: { app: {
status: 'up_to_date' | 'update_available' | 'error', status: 'up_to_date' | 'update_available' | 'error';
current_version: string, current_version: string;
new_version: string new_version: string;
}, };
ytdlp: { ytdlp: {
status: 'up_to_date' | 'update_available' | 'error', status: 'up_to_date' | 'update_available' | 'error';
current_version: string, current_version: string;
new_version: string new_version: string;
} };
} };
export { ImportedItem, version_check } export { ImportedItem, version_check };

View file

@ -1,20 +1,20 @@
export type item_request = { export type item_request = {
/** Unique identifier for the item */ /** Unique identifier for the item */
id?: string|null, id?: string | null;
/** URL of the item to download */ /** URL of the item to download */
url: string, url: string;
/** Preset to use for the download */ /** Preset to use for the download */
preset?: string, preset?: string;
/** Where to save the downloaded item */ /** Where to save the downloaded item */
folder?: string, folder?: string;
/** Output template for the downloaded item */ /** Output template for the downloaded item */
template?: string, template?: string;
/** Additional command line options for yt-dlp */ /** Additional command line options for yt-dlp */
cli?: string, cli?: string;
/** Cookies file for the download */ /** Cookies file for the download */
cookies?: string, cookies?: string;
/** Auto start the download */ /** Auto start the download */
auto_start?: boolean, auto_start?: boolean;
/** Extras data for the item */ /** Extras data for the item */
extras?: Record<string, any>, extras?: Record<string, any>;
} };

View file

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

View file

@ -1,16 +1,16 @@
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
export type PopoverTrigger = 'hover' | 'click' | 'focus' export type PopoverTrigger = 'hover' | 'click' | 'focus';
export interface PopoverProps { export interface PopoverProps {
title?: string title?: string;
description?: string description?: string;
placement?: PopoverPlacement placement?: PopoverPlacement;
trigger?: PopoverTrigger trigger?: PopoverTrigger;
offset?: number offset?: number;
disabled?: boolean disabled?: boolean;
closeOnClickOutside?: boolean closeOnClickOutside?: boolean;
minWidth?: number minWidth?: number;
maxWidth?: number maxWidth?: number;
maxHeight?: number maxHeight?: number;
showDelay?: number showDelay?: number;
} }

View file

@ -1,35 +1,35 @@
type Preset = { type Preset = {
/** Unique identifier for the preset */ /** Unique identifier for the preset */
id?: number id?: number;
/** Preset name, e.g. "default" */ /** Preset name, e.g. "default" */
name: string name: string;
/** Optional description for the preset */ /** Optional description for the preset */
description: string description: string;
/** Folder where files will be saved, e.g. "/downloads" */ /** Folder where files will be saved, e.g. "/downloads" */
folder: string folder: string;
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */ /** Output template for the preset, e.g. "%(title)s.%(ext)s" */
template: string template: string;
/** Cookies for the preset, e.g. "cookies.txt" */ /** Cookies for the preset, e.g. "cookies.txt" */
cookies: string cookies: string;
/** Additional command line options for yt-dlp */ /** Additional command line options for yt-dlp */
cli: string cli: string;
/** Indicates if this is the default preset */ /** Indicates if this is the default preset */
default: boolean default: boolean;
/** Priority for sorting. Higher priority presets appear first */ /** Priority for sorting. Higher priority presets appear first */
priority: number priority: number;
} };
/** /**
* Request payload for creating/updating preset * Request payload for creating/updating preset
*/ */
type PresetRequest = { type PresetRequest = {
name: string name: string;
description?: string description?: string;
folder?: string folder?: string;
template?: string template?: string;
cookies?: string cookies?: string;
cli?: string cli?: string;
priority?: number priority?: number;
} };
export type { Preset, PresetRequest } export type { Preset, PresetRequest };

View file

@ -1,38 +1,38 @@
export type error_response = { export type error_response = {
/** The error message */ /** The error message */
error: string, error: string;
} };
export type convert_args_response = { export type convert_args_response = {
/** The converted CLI args */ /** The converted CLI args */
opts?: Record<string, any>, opts?: Record<string, any>;
/** The output template if was provided */ /** The output template if was provided */
output_template?: string, output_template?: string;
/** The download path if was provided */ /** The download path if was provided */
download_path?: string, download_path?: string;
/** The download format if was provided */ /** The download format if was provided */
format?: string, format?: string;
/** The removed options from the original CLI args if any. */ /** The removed options from the original CLI args if any. */
removed_options?: Array<string>, removed_options?: Array<string>;
} };
export type Pagination = { export type Pagination = {
page: number page: number;
per_page: number per_page: number;
total: number total: number;
total_pages: number total_pages: number;
has_next: boolean has_next: boolean;
has_prev: boolean has_prev: boolean;
} };
export type Paginated<T> = { export type Paginated<T> = {
items: Array<T> items: Array<T>;
pagination: Pagination pagination: Pagination;
} };
export interface APIResponse<T = unknown> { export interface APIResponse<T = unknown> {
success: boolean success: boolean;
error: string | null error: string | null;
detail: unknown detail: unknown;
data?: T data?: T;
} }

View file

@ -1,58 +1,58 @@
import type { StoreItem } from "./store" import type { StoreItem } from './store';
export type Event = { export type Event = {
id: string id: string;
created_at: string created_at: string;
event: string event: string;
title: string | null title: string | null;
message: string | null message: string | null;
data: Record<string, unknown> data: Record<string, unknown>;
} };
export type EventPayload<T> = Event & { data: T } export type EventPayload<T> = Event & { data: T };
export type WebSocketEnvelope<T = unknown> = { export type WebSocketEnvelope<T = unknown> = {
event: string event: string;
data: T data: T;
} };
export type WSEP = { export type WSEP = {
connect: null connect: null;
disconnect: null disconnect: null;
connect_error: { message?: string } connect_error: { message?: string };
connected: EventPayload<{ sid: string }> connected: EventPayload<{ sid: string }>;
item_added: EventPayload<StoreItem> item_added: EventPayload<StoreItem>;
item_updated: EventPayload<StoreItem> item_updated: EventPayload<StoreItem>;
item_cancelled: EventPayload<StoreItem> item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem> item_deleted: EventPayload<StoreItem>;
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }> item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>;
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }> item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
paused: EventPayload<{ paused?: boolean }> paused: EventPayload<{ paused?: boolean }>;
resumed: EventPayload<{ paused?: boolean }> resumed: EventPayload<{ paused?: boolean }>;
log_info: EventPayload<Record<string, unknown>> log_info: EventPayload<Record<string, unknown>>;
log_success: EventPayload<Record<string, unknown>> log_success: EventPayload<Record<string, unknown>>;
log_warning: EventPayload<Record<string, unknown>> log_warning: EventPayload<Record<string, unknown>>;
log_error: EventPayload<Record<string, unknown>> log_error: EventPayload<Record<string, unknown>>;
config_update: EventPayload<ConfigUpdatePayload> config_update: EventPayload<ConfigUpdatePayload>;
} };
export type WebSocketClientEmits = { export type WebSocketClientEmits = {
add_url: Record<string, unknown> add_url: Record<string, unknown>;
item_cancel: string item_cancel: string;
item_delete: { id: string; remove_file?: boolean } item_delete: { id: string; remove_file?: boolean };
item_start: string | string[] item_start: string | string[];
item_pause: string | string[] item_pause: string | string[];
} };
type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace' type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace';
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions' type ConfigFeature = 'presets' | 'dl_fields' | 'conditions';
type ConfigUpdatePayload<T = unknown> = { type ConfigUpdatePayload<T = unknown> = {
feature: ConfigFeature feature: ConfigFeature;
action: ConfigUpdateAction action: ConfigUpdateAction;
data: T | Array<T> data: T | Array<T>;
meta?: Record<string, unknown> meta?: Record<string, unknown>;
} };
export type { ConfigUpdateAction, ConfigFeature, ConfigUpdatePayload } export type { ConfigUpdateAction, ConfigFeature, ConfigUpdatePayload };

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 = { type SideCar = {
file: string file: string;
} };
type sideCarSubtitle = SideCar & { type sideCarSubtitle = SideCar & {
lang: string lang: string;
name: string name: string;
} };
type StoreItem = { type StoreItem = {
/** Unique identifier for the item */ /** Unique identifier for the item */
_id: string _id: string;
/** Error message if available */ /** Error message if available */
error: string | null error: string | null;
/** Item source id */ /** Item source id */
id: string id: string;
/** Title of the item */ /** Title of the item */
title: string title: string;
/** Description of the item */ /** Description of the item */
description: string description: string;
/** URL of the item */ /** URL of the item */
url: string url: string;
/** Preset used for the item */ /** Preset used for the item */
preset: string preset: string;
/** Folder where the item is saved */ /** Folder where the item is saved */
folder: string folder: string;
/** Download directory */ /** Download directory */
download_dir: string download_dir: string;
/** Temporary directory for the item */ /** Temporary directory for the item */
temp_dir: string temp_dir: string;
/** Status of the item */ /** Status of the item */
status: ItemStatus status: ItemStatus;
/** If the item has cookies */ /** If the item has cookies */
cookies: string cookies: string;
/** If the item has custom output_template */ /** If the item has custom output_template */
template: string template: string;
/** If the item has custom output_template for chapters */ /** If the item has custom output_template for chapters */
template_chapter: string template_chapter: string;
/** When the item was created */ /** When the item was created */
timestamp: number timestamp: number;
/** If the item is a live stream */ /** If the item is a live stream */
is_live: boolean is_live: boolean;
/** ISO 8601 formatted start time of the item */ /** ISO 8601 formatted start time of the item */
datetime: string datetime: string;
/** Live stream start time if available */ /** Live stream start time if available */
live_in: string | null live_in: string | null;
/** File size of the item if available */ /** File size of the item if available */
file_size: number | null file_size: number | null;
/** Custom yt-dlp command line arguments */ /** Custom yt-dlp command line arguments */
cli: string cli: string;
/** If the item is auto-started */ /** If the item is auto-started */
auto_start: boolean auto_start: boolean;
/** Options for the item */ /** Options for the item */
options: Record<string, unknown> options: Record<string, unknown>;
/** Sidecar associated with the item. */ /** Sidecar associated with the item. */
sidecar: { sidecar: {
Unknown?: Array<SideCar> Unknown?: Array<SideCar>;
subtitle?: Array<sideCarSubtitle> subtitle?: Array<sideCarSubtitle>;
image?: Array<SideCar> image?: Array<SideCar>;
}, };
/** Extras for the item */ /** Extras for the item */
extras: { extras: {
/** Which channel the item belongs to */ /** Which channel the item belongs to */
channel?: string channel?: string;
/** The video duration if available */ /** The video duration if available */
duration?: number | null duration?: number | null;
/** The video release date if available */ /** The video release date if available */
release_in?: string release_in?: string;
/** The video thumbnail URL if available */ /** The video thumbnail URL if available */
thumbnail?: string thumbnail?: string;
/** The uploader of the item if available */ /** The uploader of the item if available */
uploader?: string uploader?: string;
/** Uploader name if available */ /** Uploader name if available */
is_audio?: boolean is_audio?: boolean;
/** If the item has audio stream */ /** If the item has audio stream */
is_video?: boolean is_video?: boolean;
/** If the item has video stream */ /** If the item has video stream */
live_in?: string live_in?: string;
/** Live stream start time if available */ /** Live stream start time if available */
is_premiere?: boolean is_premiere?: boolean;
/** If the item is a premiere */ /** If the item is a premiere */
} };
/** The item temporary filename */ /** The item temporary filename */
tmpfilename?: string | null tmpfilename?: string | null;
/** The item filename */ /** The item filename */
filename?: string | null filename?: string | null;
/** Actual file size of the item if available */ /** Actual file size of the item if available */
total_bytes?: number | null total_bytes?: number | null;
/** Estimated total bytes for the item if available */ /** Estimated total bytes for the item if available */
total_bytes_estimate?: number | null total_bytes_estimate?: number | null;
/** Downloaded bytes of the item if available */ /** Downloaded bytes of the item if available */
downloaded_bytes?: number | null downloaded_bytes?: number | null;
/** Message attached with the item if available */ /** Message attached with the item if available */
msg?: string | null msg?: string | null;
/** Progress percentage of the item if available */ /** Progress percentage of the item if available */
percent?: number | null percent?: number | null;
/** Speed of the item download if available */ /** Speed of the item download if available */
speed?: number | null speed?: number | null;
/** Time remaining for the item download if available */ /** Time remaining for the item download if available */
eta?: number | null eta?: number | null;
/** If the item can be archived */ /** If the item can be archived */
is_archivable?: boolean is_archivable?: boolean;
/** If the item is archived */ /** If the item is archived */
is_archived?: boolean is_archived?: boolean;
/** Item archive ID */ /** Item archive ID */
archive_id?: string | null archive_id?: string | null;
/** Postprocessor running for the item if available */ /** Postprocessor running for the item if available */
postprocessor?: string | null postprocessor?: string | null;
} };
export type { ItemStatus, StoreItem } export type { ItemStatus, StoreItem };

View file

@ -1,114 +1,114 @@
// --- Task Definition Schema Types --- // --- Task Definition Schema Types ---
import type { Paginated } from '~/types/responses' import type { Paginated } from '~/types/responses';
export type EngineType = 'httpx' | 'selenium' export type EngineType = 'httpx' | 'selenium';
export interface EngineOptions { export interface EngineOptions {
url?: string url?: string;
browser?: 'chrome' browser?: 'chrome';
arguments?: Array<string> | string arguments?: Array<string> | string;
wait_for?: WaitForSelector wait_for?: WaitForSelector;
wait_timeout?: number wait_timeout?: number;
page_load_timeout?: number page_load_timeout?: number;
[key: string]: unknown [key: string]: unknown;
} }
export interface EngineConfig { export interface EngineConfig {
type?: EngineType type?: EngineType;
options?: EngineOptions options?: EngineOptions;
} }
export type HttpMethod = 'GET' | 'POST' export type HttpMethod = 'GET' | 'POST';
export type StringMap = Record<string, string | number | boolean | null> export type StringMap = Record<string, string | number | boolean | null>;
export interface RequestConfig { export interface RequestConfig {
method?: HttpMethod method?: HttpMethod;
url?: string url?: string;
headers?: StringMap headers?: StringMap;
params?: StringMap params?: StringMap;
data?: StringMap | string | null data?: StringMap | string | null;
json_data?: object | Array<unknown> | string | number | boolean | null json_data?: object | Array<unknown> | string | number | boolean | null;
timeout?: number timeout?: number;
} }
export type ResponseType = 'html' | 'json' export type ResponseType = 'html' | 'json';
export interface ResponseConfig { export interface ResponseConfig {
type?: ResponseType type?: ResponseType;
} }
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath' export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath';
export interface PostFilter { export interface PostFilter {
filter: string filter: string;
value?: string value?: string;
} }
export interface ExtractionRule { export interface ExtractionRule {
type: ExtractionType type: ExtractionType;
expression: string expression: string;
attribute?: string attribute?: string;
post_filter?: PostFilter post_filter?: PostFilter;
} }
export interface ContainerFields { export interface ContainerFields {
link: ExtractionRule link: ExtractionRule;
[field: string]: ExtractionRule [field: string]: ExtractionRule;
} }
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath' export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath';
export interface Container { export interface Container {
type?: ContainerSelectorType type?: ContainerSelectorType;
selector?: string selector?: string;
expression?: string expression?: string;
fields: ContainerFields fields: ContainerFields;
} }
export interface WaitForSelector { export interface WaitForSelector {
type?: 'css' | 'xpath' type?: 'css' | 'xpath';
expression?: string expression?: string;
value?: string value?: string;
} }
export interface ParseConfig { export interface ParseConfig {
items?: Container items?: Container;
link?: ExtractionRule link?: ExtractionRule;
[field: string]: ExtractionRule | Container | undefined [field: string]: ExtractionRule | Container | undefined;
} }
export interface TaskDefinitionConfig { export interface TaskDefinitionConfig {
parse: ParseConfig parse: ParseConfig;
engine?: EngineConfig engine?: EngineConfig;
request?: RequestConfig request?: RequestConfig;
response?: ResponseConfig response?: ResponseConfig;
} }
export interface TaskDefinitionDocument { export interface TaskDefinitionDocument {
name: string name: string;
match_url: string[] match_url: string[];
priority?: number priority?: number;
enabled?: boolean enabled?: boolean;
definition: TaskDefinitionConfig definition: TaskDefinitionConfig;
} }
export type TaskDefinitionSummary = { export type TaskDefinitionSummary = {
id: number id: number;
name: string name: string;
priority: number priority: number;
match_url: ReadonlyArray<string> match_url: ReadonlyArray<string>;
enabled: boolean enabled: boolean;
created_at: string created_at: string;
updated_at: string updated_at: string;
} };
export type TaskDefinitionDetailed = TaskDefinitionSummary & { export type TaskDefinitionDetailed = TaskDefinitionSummary & {
definition: TaskDefinitionConfig definition: TaskDefinitionConfig;
} };
export type TaskDefinitionList = Paginated<TaskDefinitionSummary> export type TaskDefinitionList = Paginated<TaskDefinitionSummary>;
export type TaskDefinitionErrorResponse = { export type TaskDefinitionErrorResponse = {
error: string, error: string;
} };

View file

@ -1,20 +1,20 @@
// Types for api/tasks/inspect // Types for api/tasks/inspect
export interface TaskInspectRequest { export interface TaskInspectRequest {
url: string; url: string;
preset?: string; preset?: string;
handler?: string; handler?: string;
} }
export interface TaskInspectSuccess { export interface TaskInspectSuccess {
// The structure depends on TaskResult, but at minimum: // The structure depends on TaskResult, but at minimum:
success?: boolean; success?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }
export interface TaskInspectError { export interface TaskInspectError {
error: string; error: string;
message?: string; message?: string;
} }
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectError; export type TaskInspectResponse = TaskInspectSuccess | TaskInspectError;

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. * Main Task interface matching backend Task schema.
*/ */
export interface Task { export interface Task {
id?: number id?: number;
name: string name: string;
url: string url: string;
folder?: string folder?: string;
preset?: string preset?: string;
timer?: string timer?: string;
template?: string template?: string;
cli?: string cli?: string;
auto_start?: boolean auto_start?: boolean;
handler_enabled?: boolean handler_enabled?: boolean;
enabled?: boolean enabled?: boolean;
created_at?: string created_at?: string;
updated_at?: string updated_at?: string;
} }
/** /**
* Partial Task interface for PATCH operations. * Partial Task interface for PATCH operations.
*/ */
export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & { export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & {
name?: string name?: string;
url?: string url?: string;
} };
/** /**
* Paginated list of tasks response. * Paginated list of tasks response.
*/ */
export type TaskList = Paginated<Task> export type TaskList = Paginated<Task>;
/** /**
* Task handler inspect request. * Task handler inspect request.
*/ */
export interface TaskInspectRequest { export interface TaskInspectRequest {
url: string url: string;
preset?: string preset?: string;
handler?: string handler?: string;
static_only?: boolean static_only?: boolean;
} }
/** /**
* Task handler inspect response - success. * Task handler inspect response - success.
*/ */
export interface TaskInspectSuccess { export interface TaskInspectSuccess {
matched: true matched: true;
handler: string handler: string;
message: string message: string;
} }
/** /**
* Task handler inspect response - failure. * Task handler inspect response - failure.
*/ */
export interface TaskInspectFailure { export interface TaskInspectFailure {
matched: false matched: false;
message: string message: string;
error: string error: string;
} }
/** /**
* Task handler inspect response (union type). * Task handler inspect response (union type).
*/ */
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure;
/** /**
* Task metadata response. * Task metadata response.
*/ */
export interface TaskMetadataResponse { export interface TaskMetadataResponse {
id: string id: string;
id_type: string | null id_type: string | null;
title: string | null title: string | null;
description: string description: string;
uploader: string uploader: string;
tags: Array<string> tags: Array<string>;
year: number | null year: number | null;
thumbnails: Record<string, string> thumbnails: Record<string, string>;
json_file?: string json_file?: string;
nfo_file?: string nfo_file?: string;
} }
/** /**
* Exported task for import/export functionality. * Exported task for import/export functionality.
*/ */
export interface ExportedTask extends Omit<Task, 'id' | 'created_at' | 'updated_at' | 'in_progress'> { export interface ExportedTask extends Omit<
_type: string Task,
_version: string 'id' | 'created_at' | 'updated_at' | 'in_progress'
> {
_type: string;
_version: string;
} }
/** /**
* Generic error response. * Generic error response.
*/ */
export interface ErrorResponse { export interface ErrorResponse {
error: string error: string;
detail?: unknown detail?: unknown;
} }
/** /**
* Legacy alias for backward compatibility (deprecated). * Legacy alias for backward compatibility (deprecated).
* @deprecated Use Task instead * @deprecated Use Task instead
*/ */
export type task_item = Task export type task_item = Task;
/** /**
* Legacy alias for backward compatibility (deprecated). * Legacy alias for backward compatibility (deprecated).
* @deprecated Use ExportedTask instead * @deprecated Use ExportedTask instead
*/ */
export type exported_task = ExportedTask export type exported_task = ExportedTask;
/** /**
* Legacy alias for backward compatibility (deprecated). * Legacy alias for backward compatibility (deprecated).
* @deprecated Use ErrorResponse instead * @deprecated Use ErrorResponse instead
*/ */
export type error_response = ErrorResponse export type error_response = ErrorResponse;

Some files were not shown because too many files have changed in this diff Show more