mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-02 00:40:39 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4dc1c1f03 | |||
| 633e83a4ab | |||
| d745e6b656 | |||
| b62e88c4ed | |||
| 258b9ec54e | |||
| 54ca58e610 | |||
| 2131a65299 | |||
| 243bc7b0d0 | |||
| b8de041497 | |||
| 8287822354 | |||
| 0f83a292f6 | |||
| c738e35a8c | |||
| b2e1e24ca5 | |||
| c7f457da3e | |||
| bed3758268 | |||
| a1a923df94 | |||
| bbf324ea83 | |||
| adc4309951 | |||
| b8ab72a141 |
@@ -1,15 +1,4 @@
|
|||||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||||
ARG VARIANT=16
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
&& apt-get install ffmpeg gnupg2 -y
|
||||||
|
|
||||||
# Setup the node environment
|
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
# Install additional OS packages.
|
|
||||||
RUN apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
|
||||||
curl tzdata ffmpeg && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Move tone executable to appropriate directory
|
|
||||||
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// Using port 3333 is important when running the client web app separately
|
|
||||||
const Path = require('path')
|
|
||||||
module.exports.config = {
|
|
||||||
Port: 3333,
|
|
||||||
ConfigPath: Path.resolve('config'),
|
|
||||||
MetadataPath: Path.resolve('metadata'),
|
|
||||||
FFmpegPath: '/usr/bin/ffmpeg',
|
|
||||||
FFProbePath: '/usr/bin/ffprobe'
|
|
||||||
}
|
|
||||||
@@ -1,40 +1,12 @@
|
|||||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
|
||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
|
||||||
{
|
{
|
||||||
"name": "Audiobookshelf",
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
"build": {
|
|
||||||
"dockerfile": "Dockerfile",
|
|
||||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
|
||||||
// Append -bullseye or -buster to pin to an OS version.
|
|
||||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
|
||||||
"args": {
|
|
||||||
"VARIANT": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mounts": [
|
"mounts": [
|
||||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
|
||||||
],
|
],
|
||||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
"features": {
|
||||||
// "features": {},
|
"fish": "latest"
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
},
|
||||||
"forwardPorts": [
|
|
||||||
3000,
|
|
||||||
3333
|
|
||||||
],
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
|
||||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
// Configure properties specific to VS Code.
|
|
||||||
"vscode": {
|
|
||||||
// Add the IDs of extensions you want installed when the container is created.
|
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"dbaeumer.vscode-eslint",
|
"eamodio.gitlens"
|
||||||
"octref.vetur"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
|
||||||
// "remoteUser": "root"
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Mark the working directory as safe for use with git
|
|
||||||
git config --global --add safe.directory $PWD
|
|
||||||
|
|
||||||
# If there is no dev.js file, create it
|
|
||||||
if [ ! -f dev.js ]; then
|
|
||||||
cp .devcontainer/dev.js .
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update permissions for node_modules folders
|
|
||||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
|
||||||
if [ -d node_modules ]; then
|
|
||||||
sudo chown $(id -u):$(id -g) node_modules
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d client/node_modules ]; then
|
|
||||||
sudo chown $(id -u):$(id -g) client/node_modules
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install packages for the server
|
|
||||||
if [ -f package.json ]; then
|
|
||||||
npm ci
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install packages and build the client
|
|
||||||
if [ -f client/package.json ]; then
|
|
||||||
(cd client; npm ci; npm run generate)
|
|
||||||
fi
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
|
||||||
* text=auto
|
|
||||||
|
|
||||||
# Declare files that will always have CRLF line endings on checkout.
|
|
||||||
.devcontainer/post-create.sh text eol=lf
|
|
||||||
+3
-5
@@ -1,19 +1,17 @@
|
|||||||
.env
|
.env
|
||||||
/dev.js
|
dev.js
|
||||||
**/node_modules/
|
node_modules/
|
||||||
/config/
|
/config/
|
||||||
/audiobooks/
|
/audiobooks/
|
||||||
/audiobooks2/
|
/audiobooks2/
|
||||||
/podcasts/
|
/podcasts/
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
|
test/
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
/deploy/
|
/deploy/
|
||||||
/coverage/
|
|
||||||
/.nyc_output/
|
|
||||||
|
|
||||||
sw.*
|
sw.*
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
.idea/*
|
|
||||||
|
|||||||
Vendored
-44
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Debug server",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"skipFiles": [
|
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Debug client (nuxt)",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/client",
|
|
||||||
"skipFiles": [
|
|
||||||
"${workspaceFolder}/<node_internals>/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compounds": [
|
|
||||||
{
|
|
||||||
"name": "Debug server and client (nuxt)",
|
|
||||||
"configurations": [
|
|
||||||
"Debug server",
|
|
||||||
"Debug client (nuxt)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
+1
-2
@@ -16,6 +16,5 @@
|
|||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.detectIndentation": true,
|
"editor.detectIndentation": true,
|
||||||
"editor.tabSize": 2,
|
"editor.tabSize": 2
|
||||||
"javascript.format.semicolons": "remove"
|
|
||||||
}
|
}
|
||||||
Vendored
-40
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"path": "client",
|
|
||||||
"type": "npm",
|
|
||||||
"script": "generate",
|
|
||||||
"detail": "nuxt generate",
|
|
||||||
"label": "Build client",
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dependsOn": [
|
|
||||||
"Build client"
|
|
||||||
],
|
|
||||||
"type": "npm",
|
|
||||||
"script": "dev",
|
|
||||||
"detail": "nodemon --watch server index.js",
|
|
||||||
"label": "Run server",
|
|
||||||
"group": {
|
|
||||||
"kind": "test",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "client",
|
|
||||||
"type": "npm",
|
|
||||||
"script": "dev",
|
|
||||||
"detail": "nuxt",
|
|
||||||
"label": "Run Live-reload client",
|
|
||||||
"group": {
|
|
||||||
"kind": "test",
|
|
||||||
"isDefault": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+6
-3
@@ -6,11 +6,10 @@ RUN npm ci && npm cache clean --force
|
|||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
### STAGE 1: Build server ###
|
### STAGE 1: Build server ###
|
||||||
FROM sandreas/tone:v0.1.5 AS tone
|
FROM sandreas/tone:v0.1.2 AS tone
|
||||||
FROM node:16-alpine
|
FROM node:16-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add --no-cache --update \
|
apk add --no-cache --update \
|
||||||
curl \
|
curl \
|
||||||
@@ -30,5 +29,9 @@ RUN npm ci --only=production
|
|||||||
RUN apk del make python3 g++
|
RUN apk del make python3 g++
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
HEALTHCHECK \
|
||||||
|
--interval=30s \
|
||||||
|
--timeout=3s \
|
||||||
|
--start-period=10s \
|
||||||
|
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ install_ffmpeg() {
|
|||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
|
|
||||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
@@ -60,14 +60,14 @@ install_ffmpeg() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
$WGET
|
$WGET
|
||||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||||
rm ffmpeg-git-amd64-static.tar.xz
|
rm ffmpeg-git-amd64-static.tar.xz
|
||||||
|
|
||||||
# Temp downloading tone library to the ffmpeg dir
|
# Temp downloading tone library to the ffmpeg dir
|
||||||
echo "Getting tone.."
|
echo "Getting tone.."
|
||||||
$WGET_TONE
|
$WGET_TONE
|
||||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
|
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
|
||||||
rm tone-0.1.5-linux-x64.tar.gz
|
rm tone-0.1.2-linux-x64.tar.gz
|
||||||
|
|
||||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-35
@@ -112,7 +112,7 @@ input[type=number] {
|
|||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover:not(:has(th)) {
|
.tracksTable tr:hover {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,20 +232,6 @@ Bookshelf Label
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-subtitle-long {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
line-height: 16px;
|
|
||||||
/* fallback */
|
|
||||||
max-height: 72px;
|
|
||||||
/* fallback */
|
|
||||||
-webkit-line-clamp: 6;
|
|
||||||
/* number of lines to show */
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
@@ -259,23 +245,3 @@ Bookshelf Label
|
|||||||
.no-bars .Vue-Toastification__container.top-right {
|
.no-bars .Vue-Toastification__container.top-right {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.abs-btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 6px;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(255, 255, 255, 0);
|
|
||||||
transition: all 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-btn:hover:not(:disabled)::before {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.abs-btn:disabled::before {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<widgets-notification-widget class="hidden md:block" />
|
||||||
|
|
||||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||||
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -22,8 +24,6 @@
|
|||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-notification-widget class="hidden md:block" />
|
|
||||||
|
|
||||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||||
@@ -58,6 +58,9 @@
|
|||||||
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ $strings.ButtonPlay }}
|
{{ $strings.ButtonPlay }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
<ui-tooltip v-if="userIsAdminOrUp && isBookLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||||
|
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
||||||
|
</ui-tooltip>
|
||||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -72,11 +75,8 @@
|
|||||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
|
||||||
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +149,9 @@ export default {
|
|||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.$store.state.processingBatch
|
return this.$store.state.processingBatch
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
isChromecastEnabled() {
|
isChromecastEnabled() {
|
||||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||||
},
|
},
|
||||||
@@ -157,90 +160,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isHttps() {
|
isHttps() {
|
||||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
if (!this.userIsAdminOrUp) return []
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
text: this.$strings.ButtonQuickMatch,
|
|
||||||
action: 'quick-match'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
|
||||||
options.push({
|
|
||||||
text: 'Quick Embed Metadata',
|
|
||||||
action: 'quick-embed'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options.push({
|
|
||||||
text: 'Re-Scan',
|
|
||||||
action: 'rescan'
|
|
||||||
})
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
requestBatchQuickEmbed() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmQuickEmbed,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/tools/batch/embed-metadata`, {
|
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
console.log('Audio metadata embed started')
|
|
||||||
this.cancelSelectionMode()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Audio metadata embed failed', error)
|
|
||||||
const errorMsg = error.response.data || 'Failed to embed metadata'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'quick-embed') {
|
|
||||||
this.requestBatchQuickEmbed()
|
|
||||||
} else if (action === 'quick-match') {
|
|
||||||
this.batchAutoMatchClick()
|
|
||||||
} else if (action === 'rescan') {
|
|
||||||
this.batchRescan()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async batchRescan() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/items/batch/scan`, {
|
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
console.log('Batch Re-Scan started')
|
|
||||||
this.cancelSelectionMode()
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Batch Re-Scan failed', error)
|
|
||||||
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
async playSelectedItems() {
|
async playSelectedItems() {
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
@@ -303,52 +225,39 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
this.$toast.success('Batch update success!')
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
this.$toast.error('Batch update failed')
|
||||||
console.error('Failed to batch update read/not read', error)
|
console.error('Failed to batch update read/not read', error)
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
batchDeleteClick() {
|
batchDeleteClick() {
|
||||||
const payload = {
|
const audiobookText = this.numMediaItemsSelected > 1 ? `these ${this.numMediaItemsSelected} items` : 'this item'
|
||||||
message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]),
|
const confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
if (confirm(confirmMsg)) {
|
||||||
yesButtonText: this.$strings.ButtonDelete,
|
|
||||||
yesButtonColor: 'error',
|
|
||||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
|
||||||
callback: (confirmed, hardDelete) => {
|
|
||||||
if (confirmed) {
|
|
||||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
|
||||||
|
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
.$post(`/api/items/batch/delete`, {
|
||||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Batch delete success')
|
this.$toast.success('Batch delete success!')
|
||||||
|
this.$store.commit('setProcessingBatch', false)
|
||||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Batch delete failed', error)
|
|
||||||
this.$toast.error('Batch delete failed')
|
this.$toast.error('Batch delete failed')
|
||||||
})
|
console.error('Failed to batch delete', error)
|
||||||
.finally(() => {
|
|
||||||
this.$store.commit('setProcessingBatch', false)
|
this.$store.commit('setProcessingBatch', false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
batchEditClick() {
|
batchEditClick() {
|
||||||
this.$router.push('/batch')
|
this.$router.push('/batch')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Alternate plain view -->
|
<!-- Alternate plain view -->
|
||||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-item-slider>
|
</widgets-item-slider>
|
||||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||||
@@ -28,15 +28,12 @@
|
|||||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||||
</widgets-authors-slider>
|
</widgets-authors-slider>
|
||||||
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
|
||||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
|
||||||
</widgets-narrators-slider>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Regular bookshelf view -->
|
<!-- Regular bookshelf view -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<template v-for="(shelf, index) in shelves">
|
<template v-for="(shelf, index) in shelves">
|
||||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,12 +62,12 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
currentLibraryMediaType() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
|
||||||
},
|
|
||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
@@ -171,7 +168,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async fetchCategories() {
|
async fetchCategories() {
|
||||||
const categories = await this.$axios
|
const categories = await this.$axios
|
||||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
|
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
@@ -188,8 +185,8 @@ export default {
|
|||||||
this.shelves = categories
|
this.shelves = categories
|
||||||
},
|
},
|
||||||
async setShelvesFromSearch() {
|
async setShelvesFromSearch() {
|
||||||
const shelves = []
|
var shelves = []
|
||||||
if (this.results.books?.length) {
|
if (this.results.books && this.results.books.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'books',
|
id: 'books',
|
||||||
label: 'Books',
|
label: 'Books',
|
||||||
@@ -199,7 +196,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.podcasts?.length) {
|
if (this.results.podcasts && this.results.podcasts.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'podcasts',
|
id: 'podcasts',
|
||||||
label: 'Podcasts',
|
label: 'Podcasts',
|
||||||
@@ -209,7 +206,7 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.results.series?.length) {
|
if (this.results.series && this.results.series.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'series',
|
id: 'series',
|
||||||
label: 'Series',
|
label: 'Series',
|
||||||
@@ -224,7 +221,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.tags?.length) {
|
if (this.results.tags && this.results.tags.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'tags',
|
id: 'tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
@@ -239,7 +236,7 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.authors?.length) {
|
if (this.results.authors && this.results.authors.length) {
|
||||||
shelves.push({
|
shelves.push({
|
||||||
id: 'authors',
|
id: 'authors',
|
||||||
label: 'Authors',
|
label: 'Authors',
|
||||||
@@ -253,20 +250,6 @@ export default {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.results.narrators?.length) {
|
|
||||||
shelves.push({
|
|
||||||
id: 'narrators',
|
|
||||||
label: 'Narrators',
|
|
||||||
labelStringKey: 'LabelNarrators',
|
|
||||||
type: 'narrators',
|
|
||||||
entities: this.results.narrators.map((n) => {
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
type: 'narrator'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.shelves = shelves
|
this.shelves = shelves
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
@@ -286,8 +269,7 @@ export default {
|
|||||||
}
|
}
|
||||||
if (user.mediaProgress.length) {
|
if (user.mediaProgress.length) {
|
||||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
this.removeItemsFromContinueListening(mediaProgressToHide)
|
||||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
@@ -337,16 +319,9 @@ export default {
|
|||||||
},
|
},
|
||||||
libraryItemsAdded(libraryItems) {
|
libraryItemsAdded(libraryItems) {
|
||||||
console.log('libraryItems added', libraryItems)
|
console.log('libraryItems added', libraryItems)
|
||||||
|
// TODO: Check if audiobook would be on this shelf
|
||||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
if (!this.search) {
|
||||||
if (!recentlyAddedShelf) return
|
this.fetchCategories()
|
||||||
|
|
||||||
// Add new library item to the recently added shelf
|
|
||||||
for (const libraryItem of libraryItems) {
|
|
||||||
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
|
|
||||||
// Add to front of array
|
|
||||||
recentlyAddedShelf.entities.unshift(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemsUpdated(items) {
|
libraryItemsUpdated(items) {
|
||||||
@@ -354,12 +329,6 @@ export default {
|
|||||||
this.libraryItemUpdated(li)
|
this.libraryItemUpdated(li)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
episodeAdded(episodeWithLibraryItem) {
|
|
||||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
|
||||||
if (!this.search && isThisLibrary) {
|
|
||||||
this.fetchCategories()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||||
@@ -371,8 +340,8 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
removeItemsFromContinueListening(mediaProgressItems) {
|
||||||
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
||||||
if (continueListeningShelf) {
|
if (continueListeningShelf) {
|
||||||
if (continueListeningShelf.type === 'book') {
|
if (continueListeningShelf.type === 'book') {
|
||||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||||
@@ -387,6 +356,17 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// this.shelves.forEach((shelf) => {
|
||||||
|
// if (shelf.id == 'continue-listening') {
|
||||||
|
// if (shelf.type == 'book') {
|
||||||
|
// // Filter out books from continue listening shelf
|
||||||
|
// shelf.entities = shelf.entities.filter((ent) => {
|
||||||
|
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
||||||
|
// return true
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
},
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
this.shelves.forEach((shelf) => {
|
this.shelves.forEach((shelf) => {
|
||||||
@@ -420,7 +400,6 @@ export default {
|
|||||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
@@ -435,7 +414,6 @@ export default {
|
|||||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,6 @@
|
|||||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
|
||||||
<template v-for="entity in shelf.entities">
|
|
||||||
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +88,6 @@ export default {
|
|||||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.shelf.type === 'narrators') return 148
|
|
||||||
return this.bookCoverHeight + 48
|
return this.bookCoverHeight + 48
|
||||||
},
|
},
|
||||||
paddingLeft() {
|
paddingLeft() {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||||
@@ -81,8 +81,6 @@
|
|||||||
|
|
||||||
<!-- issues page remove all button -->
|
<!-- issues page remove all button -->
|
||||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||||
|
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
|
||||||
</template>
|
</template>
|
||||||
<!-- search page -->
|
<!-- search page -->
|
||||||
<template v-else-if="page === 'search'">
|
<template v-else-if="page === 'search'">
|
||||||
@@ -165,14 +163,6 @@ export default {
|
|||||||
text: this.$strings.LabelAddedAt,
|
text: this.$strings.LabelAddedAt,
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelLastBookAdded,
|
|
||||||
value: 'lastBookAdded'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLastBookUpdated,
|
|
||||||
value: 'lastBookUpdated'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelTotalDuration,
|
text: this.$strings.LabelTotalDuration,
|
||||||
value: 'totalDuration'
|
value: 'totalDuration'
|
||||||
@@ -188,15 +178,9 @@ export default {
|
|||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
},
|
|
||||||
currentLibraryMediaType() {
|
currentLibraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@@ -281,30 +265,10 @@ export default {
|
|||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
const items = []
|
|
||||||
|
|
||||||
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
|
||||||
items.push({
|
|
||||||
text: 'Export OPML',
|
|
||||||
action: 'export-opml'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
contextMenuAction({ action }) {
|
seriesContextMenuAction(action) {
|
||||||
if (action === 'export-opml') {
|
|
||||||
this.exportOPML()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
exportOPML() {
|
|
||||||
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
|
||||||
},
|
|
||||||
seriesContextMenuAction({ action }) {
|
|
||||||
if (action === 'open-rss-feed') {
|
if (action === 'open-rss-feed') {
|
||||||
this.showOpenSeriesRSSFeed()
|
this.showOpenSeriesRSSFeed()
|
||||||
} else if (action === 're-add-to-continue-listening') {
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
@@ -351,11 +315,7 @@ export default {
|
|||||||
const payload = {}
|
const payload = {}
|
||||||
if (author.asin) payload.asin = author.asin
|
if (author.asin) payload.asin = author.asin
|
||||||
else payload.q = author.name
|
else payload.q = author.name
|
||||||
|
console.log('Payload', payload, 'author', author)
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex justify-between">
|
||||||
<button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button>
|
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
|
|
||||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,25 +90,10 @@ export default {
|
|||||||
title: this.$strings.HeaderNotifications,
|
title: this.$strings.HeaderNotifications,
|
||||||
path: '/config/notifications'
|
path: '/config/notifications'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'config-email',
|
|
||||||
title: this.$strings.HeaderEmail,
|
|
||||||
path: '/config/email'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'config-item-metadata-utils',
|
id: 'config-item-metadata-utils',
|
||||||
title: this.$strings.HeaderItemMetadataUtils,
|
title: this.$strings.HeaderItemMetadataUtils,
|
||||||
path: '/config/item-metadata-utils'
|
path: '/config/item-metadata-utils'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'config-rss-feeds',
|
|
||||||
title: this.$strings.HeaderRSSFeeds,
|
|
||||||
path: '/config/rss-feeds'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'config-authentication',
|
|
||||||
title: this.$strings.HeaderAuthentication,
|
|
||||||
path: '/config/authentication'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
libraryMediaType() {
|
libraryMediaType() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
},
|
},
|
||||||
@@ -313,12 +316,12 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
|
||||||
|
|
||||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch items', error)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -623,11 +626,6 @@ export default {
|
|||||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||||
},
|
},
|
||||||
async init(bookshelf) {
|
async init(bookshelf) {
|
||||||
if (this.entityName === 'series') {
|
|
||||||
this.booksPerFetch = 50
|
|
||||||
} else {
|
|
||||||
this.booksPerFetch = 100
|
|
||||||
}
|
|
||||||
this.checkUpdateSearchParams()
|
this.checkUpdateSearchParams()
|
||||||
this.initSizeData(bookshelf)
|
this.initSizeData(bookshelf)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
<slot name="header-items"></slot>
|
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
|
||||||
|
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
<p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" />
|
||||||
@@ -17,9 +19,14 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
headerText: String,
|
headerText: String,
|
||||||
description: String,
|
description: String,
|
||||||
note: String
|
note: String,
|
||||||
|
showAddButton: Boolean
|
||||||
},
|
},
|
||||||
methods: {}
|
methods: {
|
||||||
|
clicked() {
|
||||||
|
this.$emit('clicked')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden">
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
@@ -50,14 +49,6 @@
|
|||||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<span class="material-icons text-2.5xl">queue_music</span>
|
|
||||||
|
|
||||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
|
||||||
|
|
||||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@@ -71,18 +62,10 @@
|
|||||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
|
||||||
<span class="material-icons text-2xl">record_voice_over</span>
|
|
||||||
|
|
||||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
|
||||||
|
|
||||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="abs-icons icon-podcast text-xl"></span>
|
<span class="abs-icons icon-podcast text-xl"></span>
|
||||||
|
|
||||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
|
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||||
|
|
||||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -95,6 +78,14 @@
|
|||||||
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
|
<span class="material-icons text-2.5xl">queue_music</span>
|
||||||
|
|
||||||
|
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||||
|
|
||||||
|
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||||
<span class="material-icons text-2xl">file_download</span>
|
<span class="material-icons text-2xl">file_download</span>
|
||||||
|
|
||||||
@@ -113,9 +104,8 @@
|
|||||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
|
<div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }">
|
||||||
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p>
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
|
||||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||||
@@ -188,9 +178,6 @@ export default {
|
|||||||
isAuthorsPage() {
|
isAuthorsPage() {
|
||||||
return this.$route.name === 'library-library-authors'
|
return this.$route.name === 'library-library-authors'
|
||||||
},
|
},
|
||||||
isNarratorsPage() {
|
|
||||||
return this.$route.name === 'library-library-narrators'
|
|
||||||
},
|
|
||||||
isPlaylistsPage() {
|
isPlaylistsPage() {
|
||||||
return this.paramId === 'playlists'
|
return this.paramId === 'playlists'
|
||||||
},
|
},
|
||||||
@@ -237,12 +224,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
#siderail-buttons-container {
|
|
||||||
max-height: calc(100vh - 64px - 48px);
|
|
||||||
}
|
|
||||||
#siderail-buttons-container.player-open {
|
|
||||||
max-height: calc(100vh - 64px - 48px - 160px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||||
<div id="videoDock" />
|
<div id="videoDock" />
|
||||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
</div>
|
</nuxt-link>
|
||||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||||
@@ -81,7 +81,7 @@ export default {
|
|||||||
sleepTimerRemaining: 0,
|
sleepTimerRemaining: 0,
|
||||||
sleepTimer: null,
|
sleepTimer: null,
|
||||||
displayTitle: null,
|
displayTitle: null,
|
||||||
currentPlaybackRate: 1,
|
initialPlaybackRate: 1,
|
||||||
syncFailedToast: null
|
syncFailedToast: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -120,22 +120,17 @@ export default {
|
|||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
streamEpisode() {
|
|
||||||
if (!this.$store.state.streamEpisodeId) return null
|
|
||||||
const episodes = this.streamLibraryItem.media.episodes || []
|
|
||||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.streamLibraryItem?.id || null
|
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.streamLibraryItem?.media || {}
|
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||||
},
|
},
|
||||||
isMusic() {
|
isMusic() {
|
||||||
return this.streamLibraryItem?.mediaType === 'music'
|
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'music' : false
|
||||||
},
|
},
|
||||||
isExplicit() {
|
isExplicit() {
|
||||||
return this.mediaMetadata.explicit || false
|
return this.mediaMetadata.explicit || false
|
||||||
@@ -144,7 +139,6 @@ export default {
|
|||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
@@ -158,8 +152,7 @@ export default {
|
|||||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
totalDurationPretty() {
|
totalDurationPretty() {
|
||||||
// Adjusted by playback rate
|
return this.$secondsToTimestamp(this.totalDuration)
|
||||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
|
||||||
},
|
},
|
||||||
podcastAuthor() {
|
podcastAuthor() {
|
||||||
if (!this.isPodcast) return null
|
if (!this.isPodcast) return null
|
||||||
@@ -262,16 +255,12 @@ export default {
|
|||||||
this.playerHandler.setVolume(volume)
|
this.playerHandler.setVolume(volume)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.currentPlaybackRate = playbackRate
|
this.initialPlaybackRate = playbackRate
|
||||||
this.playerHandler.setPlaybackRate(playbackRate)
|
this.playerHandler.setPlaybackRate(playbackRate)
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
this.playerHandler.seek(time)
|
this.playerHandler.seek(time)
|
||||||
},
|
},
|
||||||
playbackTimeUpdate(time) {
|
|
||||||
// When updating progress from another session
|
|
||||||
this.playerHandler.seek(time, false)
|
|
||||||
},
|
|
||||||
setCurrentTime(time) {
|
setCurrentTime(time) {
|
||||||
this.currentTime = time
|
this.currentTime = time
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
@@ -370,8 +359,9 @@ export default {
|
|||||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Media session not available')
|
console.warn('Media session not available')
|
||||||
}
|
}
|
||||||
@@ -394,7 +384,7 @@ export default {
|
|||||||
libraryItem: session.libraryItem,
|
libraryItem: session.libraryItem,
|
||||||
episodeId: session.episodeId
|
episodeId: session.episodeId
|
||||||
})
|
})
|
||||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
streamOpen(session) {
|
streamOpen(session) {
|
||||||
console.log(`[StreamContainer] Stream session open`, session)
|
console.log(`[StreamContainer] Stream session open`, session)
|
||||||
@@ -461,7 +451,7 @@ export default {
|
|||||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
this.playerHandler.pause()
|
this.playerHandler.pause()
|
||||||
@@ -469,26 +459,17 @@ export default {
|
|||||||
showFailedProgressSyncs() {
|
showFailedProgressSyncs() {
|
||||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||||
},
|
|
||||||
sessionClosedEvent(sessionId) {
|
|
||||||
if (this.playerHandler.currentSessionId === sessionId) {
|
|
||||||
console.log('sessionClosedEvent closing current session', sessionId)
|
|
||||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
|
||||||
this.$store.commit('setMediaPlaying', null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$on('playback-seek', this.seek)
|
this.$eventBus.$on('playback-seek', this.seek)
|
||||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||||
this.$eventBus.$off('playback-seek', this.seek)
|
this.$eventBus.$off('playback-seek', this.seek)
|
||||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Author name & num books overlay -->
|
<!-- Author name & num books overlay -->
|
||||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- Search icon btn -->
|
||||||
@@ -77,12 +77,6 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -98,11 +92,6 @@ export default {
|
|||||||
if (this.asin) payload.asin = this.asin
|
if (this.asin) payload.asin = this.asin
|
||||||
else payload.q = this.name
|
else payload.q = this.name
|
||||||
|
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(book.duration * 60) }}</p>
|
||||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
<div v-if="book.series && book.series.length" class="flex py-1 -mx-1">
|
||||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||||
<p class="leading-3 text-xs text-gray-400">
|
<p class="leading-3 text-xs text-gray-400">
|
||||||
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
{{ series.series }}<span v-if="series.sequence"> #{{ series.sequence }}</span>
|
||||||
@@ -29,7 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="px-4 flex-grow">
|
<div v-else class="px-4 flex-grow">
|
||||||
<h1>
|
<h1>
|
||||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" /></div>
|
<div class="flex items-center">
|
||||||
|
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||||
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||||
@@ -54,8 +56,7 @@ export default {
|
|||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
isPodcast: Boolean,
|
isPodcast: Boolean,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number
|
||||||
currentBookDuration: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,27 +65,12 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
bookCovers() {
|
bookCovers() {
|
||||||
return this.book.covers || []
|
return this.book.covers ? this.book.covers || [] : []
|
||||||
},
|
|
||||||
bookDuration() {
|
|
||||||
return (this.book.duration || 0) * 60
|
|
||||||
},
|
|
||||||
bookDurationComparison() {
|
|
||||||
if (!this.book.duration || !this.currentBookDuration) return ''
|
|
||||||
const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60)
|
|
||||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
|
||||||
if (differenceInMinutes < 0) {
|
|
||||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
|
||||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)`
|
|
||||||
} else if (differenceInMinutes > 0) {
|
|
||||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)`
|
|
||||||
}
|
|
||||||
return '(exact match)'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
selectMatch() {
|
selectMatch() {
|
||||||
const book = { ...this.book }
|
var book = { ...this.book }
|
||||||
book.cover = this.selectedCover
|
book.cover = this.selectedCover
|
||||||
this.$emit('select', book)
|
this.$emit('select', book)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||||
|
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,19 +61,18 @@ export default {
|
|||||||
},
|
},
|
||||||
matchHtml() {
|
matchHtml() {
|
||||||
if (!this.matchText || !this.search) return ''
|
if (!this.matchText || !this.search) return ''
|
||||||
|
if (this.matchKey === 'subtitle') return ''
|
||||||
|
|
||||||
// This used to highlight the part of the search found
|
// This used to highlight the part of the search found
|
||||||
// but with removing commas periods etc this is no longer plausible
|
// but with removing commas periods etc this is no longer plausible
|
||||||
const html = this.matchText
|
const html = this.matchText
|
||||||
|
|
||||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
if (this.matchKey === 'authors') return `by ${html}`
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||||
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
|
||||||
return `${html}`
|
return `${html}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center px-1 overflow-hidden">
|
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||||
<div class="w-8 flex items-center justify-center">
|
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
||||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
||||||
<widgets-loading-spinner v-else />
|
<widgets-loading-spinner v-else />
|
||||||
</div>
|
</div>
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||||
|
|
||||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||||
<p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,14 +23,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
cancelingScan: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userIsAdminOrUp() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
return this.task.title || 'No Title'
|
return this.task.title || 'No Title'
|
||||||
},
|
},
|
||||||
@@ -43,13 +36,10 @@ export default {
|
|||||||
return this.task.details || 'Unknown'
|
return this.task.details || 'Unknown'
|
||||||
},
|
},
|
||||||
isFinished() {
|
isFinished() {
|
||||||
return !!this.task.isFinished
|
return this.task.isFinished || false
|
||||||
},
|
},
|
||||||
isFailed() {
|
isFailed() {
|
||||||
return !!this.task.isFailed
|
return this.task.isFailed || false
|
||||||
},
|
|
||||||
isSuccess() {
|
|
||||||
return this.isFinished && !this.isFailed
|
|
||||||
},
|
},
|
||||||
failedMessage() {
|
failedMessage() {
|
||||||
return this.task.error || ''
|
return this.task.error || ''
|
||||||
@@ -58,11 +48,6 @@ export default {
|
|||||||
return this.task.action || ''
|
return this.task.action || ''
|
||||||
},
|
},
|
||||||
actionIcon() {
|
actionIcon() {
|
||||||
if (this.isFailed) {
|
|
||||||
return 'error'
|
|
||||||
} else if (this.isSuccess) {
|
|
||||||
return 'done'
|
|
||||||
}
|
|
||||||
switch (this.action) {
|
switch (this.action) {
|
||||||
case 'download-podcast-episode':
|
case 'download-podcast-episode':
|
||||||
return 'cloud_download'
|
return 'cloud_download'
|
||||||
@@ -81,21 +66,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
},
|
|
||||||
isLibraryScan() {
|
|
||||||
return this.action === 'library-scan' || this.action === 'library-match-all'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
cancelScan() {
|
|
||||||
const libraryId = this.task?.data?.libraryId
|
|
||||||
if (!libraryId) {
|
|
||||||
console.error('No library id in library-scan task', this.task)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.cancelingScan = true
|
|
||||||
this.$root.socket.emit('cancel_scan', libraryId)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
@@ -103,8 +76,8 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.taskRunningCardContent {
|
.taskRunningCardContent {
|
||||||
width: calc(100% - 84px);
|
width: calc(100% - 80px);
|
||||||
height: 60px;
|
height: 75px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export default {
|
|||||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||||
return {
|
return {
|
||||||
index: this.item.index,
|
index: this.item.index,
|
||||||
directory: this.directory,
|
|
||||||
...this.itemData,
|
...this.itemData,
|
||||||
files
|
files
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,7 @@
|
|||||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Radio button -->
|
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||||
<div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
|
||||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,10 +76,6 @@
|
|||||||
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
|
||||||
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Processing/loading spinner overlay -->
|
<!-- Processing/loading spinner overlay -->
|
||||||
@@ -90,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Series name overlay -->
|
<!-- Series name overlay -->
|
||||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
|
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error widget -->
|
<!-- Error widget -->
|
||||||
@@ -117,14 +112,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
|
||||||
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -180,6 +170,12 @@ export default {
|
|||||||
dateFormat() {
|
dateFormat() {
|
||||||
return this.store.state.serverSettings.dateFormat
|
return this.store.state.serverSettings.dateFormat
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -219,16 +215,13 @@ export default {
|
|||||||
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesName() {
|
|
||||||
return this.series?.name || null
|
|
||||||
},
|
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series?.sequence || null
|
return this.series ? this.series.sequence : null
|
||||||
},
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this._libraryItem.libraryId
|
return this._libraryItem.libraryId
|
||||||
},
|
},
|
||||||
ebookFormat() {
|
hasEbook() {
|
||||||
return this.media.ebookFormat
|
return this.media.ebookFormat
|
||||||
},
|
},
|
||||||
numTracks() {
|
numTracks() {
|
||||||
@@ -236,11 +229,9 @@ export default {
|
|||||||
return this.media.numTracks || 0 // toJSONMinified
|
return this.media.numTracks || 0 // toJSONMinified
|
||||||
},
|
},
|
||||||
numEpisodes() {
|
numEpisodes() {
|
||||||
|
if (!this.isPodcast) return 0
|
||||||
return this.media.numEpisodes || 0
|
return this.media.numEpisodes || 0
|
||||||
},
|
},
|
||||||
numEpisodesIncomplete() {
|
|
||||||
return this._libraryItem.numEpisodesIncomplete || 0
|
|
||||||
},
|
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
@@ -261,14 +252,14 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries?.numBooks || 0
|
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||||
},
|
},
|
||||||
seriesSequenceList() {
|
seriesSequenceList() {
|
||||||
return this.collapsedSeries?.seriesSequenceList || null
|
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||||
},
|
},
|
||||||
libraryItemIdsInSeries() {
|
libraryItemIdsInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries?.libraryItemIds || []
|
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||||
},
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.media.coverPath
|
return !!this.media.coverPath
|
||||||
@@ -322,7 +313,6 @@ export default {
|
|||||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||||
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
episodeProgress() {
|
episodeProgress() {
|
||||||
@@ -335,16 +325,8 @@ export default {
|
|||||||
if (this.episodeProgress) return this.episodeProgress
|
if (this.episodeProgress) return this.episodeProgress
|
||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
isEBookOnly() {
|
|
||||||
return !this.numTracks && this.ebookFormat
|
|
||||||
},
|
|
||||||
useEBookProgress() {
|
|
||||||
if (!this.userProgress || this.userProgress.progress) return false
|
|
||||||
return this.userProgress.ebookProgress > 0
|
|
||||||
},
|
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||||
@@ -373,13 +355,13 @@ export default {
|
|||||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.ebookFormat
|
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -495,18 +477,6 @@ export default {
|
|||||||
text: this.$strings.LabelAddToPlaylist
|
text: this.$strings.LabelAddToPlaylist
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelSendEbookToDevice,
|
|
||||||
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
|
|
||||||
return {
|
|
||||||
text: d.name,
|
|
||||||
func: 'sendToDevice',
|
|
||||||
data: d.name
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (this.userCanUpdate) {
|
if (this.userCanUpdate) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -533,7 +503,7 @@ export default {
|
|||||||
if (this.continueListeningShelf) {
|
if (this.continueListeningShelf) {
|
||||||
items.push({
|
items.push({
|
||||||
func: 'removeFromContinueListening',
|
func: 'removeFromContinueListening',
|
||||||
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
text: this.$strings.ButtonRemoveFromContinueListening
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!this.isPodcast) {
|
if (!this.isPodcast) {
|
||||||
@@ -551,14 +521,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.userCanDelete) {
|
|
||||||
items.push({
|
|
||||||
func: 'deleteLibraryItem',
|
|
||||||
text: this.$strings.ButtonDelete
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
_socket() {
|
_socket() {
|
||||||
@@ -692,6 +654,7 @@ export default {
|
|||||||
.$patch(apiEndpoint, updatePayload)
|
.$patch(apiEndpoint, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
|
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@@ -736,40 +699,7 @@ export default {
|
|||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||||
},
|
},
|
||||||
sendToDevice(deviceName) {
|
|
||||||
// More menu func
|
|
||||||
const payload = {
|
|
||||||
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
|
|
||||||
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
const payload = {
|
|
||||||
libraryItemId: this.libraryItemId,
|
|
||||||
deviceName
|
|
||||||
}
|
|
||||||
this.processing = true
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$post(`/api/emails/send-ebook-to-device`, payload)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to send ebook to device', error)
|
|
||||||
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
removeSeriesFromContinueListening() {
|
removeSeriesFromContinueListening() {
|
||||||
if (!this.series) return
|
|
||||||
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
const axios = this.$axios || this.$nuxt.$axios
|
||||||
this.processing = true
|
this.processing = true
|
||||||
axios
|
axios
|
||||||
@@ -842,37 +772,6 @@ export default {
|
|||||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||||
},
|
},
|
||||||
deleteLibraryItem() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
|
||||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
|
||||||
yesButtonText: this.$strings.ButtonDelete,
|
|
||||||
yesButtonColor: 'error',
|
|
||||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
|
||||||
callback: (confirmed, hardDelete) => {
|
|
||||||
if (confirmed) {
|
|
||||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Item deleted')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete item', error)
|
|
||||||
this.$toast.error('Failed to delete item')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
createMoreMenu() {
|
createMoreMenu() {
|
||||||
if (!this.$refs.moreIcon) return
|
if (!this.$refs.moreIcon) return
|
||||||
|
|
||||||
@@ -884,8 +783,8 @@ export default {
|
|||||||
items: this.moreMenuItems
|
items: this.moreMenuItems
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$on('action', (action) => {
|
this.$on('action', (func) => {
|
||||||
if (action.func && _this[action.func]) _this[action.func](action.data)
|
if (_this[func]) _this[func]()
|
||||||
})
|
})
|
||||||
this.$on('close', () => {
|
this.$on('close', () => {
|
||||||
_this.isMoreMenuOpen = false
|
_this.isMoreMenuOpen = false
|
||||||
@@ -897,7 +796,7 @@ export default {
|
|||||||
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
|
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
|
||||||
var el = instance.$el
|
var el = instance.$el
|
||||||
|
|
||||||
var elHeight = this.moreMenuItems.length * 28 + 10
|
var elHeight = this.moreMenuItems.length * 28 + 2
|
||||||
var elWidth = 130
|
var elWidth = 130
|
||||||
|
|
||||||
var bottomOfIcon = wrapperBox.top + wrapperBox.height
|
var bottomOfIcon = wrapperBox.top + wrapperBox.height
|
||||||
@@ -924,13 +823,12 @@ export default {
|
|||||||
this.createMoreMenu()
|
this.createMoreMenu()
|
||||||
},
|
},
|
||||||
async clickReadEBook() {
|
async clickReadEBook() {
|
||||||
const axios = this.$axios || this.$nuxt.$axios
|
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||||
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
|
||||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
if (!libraryItem) return
|
if (!libraryItem) return
|
||||||
this.store.commit('showEReader', { libraryItem, keepProgress: true })
|
this.store.commit('showEReader', libraryItem)
|
||||||
},
|
},
|
||||||
selectBtnClick(evt) {
|
selectBtnClick(evt) {
|
||||||
if (this.processingBatch) return
|
if (this.processingBatch) return
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||||
|
|
||||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
<div v-if="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||||
|
|
||||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||||
@@ -81,20 +81,13 @@ export default {
|
|||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
switch (this.orderBy) {
|
if (this.orderBy === 'addedAt') {
|
||||||
case 'addedAt':
|
// return this.addedAt
|
||||||
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
||||||
case 'totalDuration':
|
} else if (this.orderBy === 'totalDuration') {
|
||||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
||||||
case 'lastBookUpdated':
|
|
||||||
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
|
|
||||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
|
||||||
case 'lastBookAdded':
|
|
||||||
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
|
|
||||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
},
|
},
|
||||||
books() {
|
books() {
|
||||||
return this.series ? this.series.books || [] : []
|
return this.series ? this.series.books || [] : []
|
||||||
@@ -115,14 +108,6 @@ export default {
|
|||||||
seriesBooksFinished() {
|
seriesBooksFinished() {
|
||||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||||
},
|
},
|
||||||
hasSeriesBookInProgress() {
|
|
||||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
|
||||||
},
|
|
||||||
seriesPercentInProgress() {
|
|
||||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
|
||||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
|
||||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
|
||||||
},
|
|
||||||
isSeriesFinished() {
|
isSeriesFinished() {
|
||||||
return this.books.length === this.seriesBooksFinished.length
|
return this.books.length === this.seriesBooksFinished.length
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
|
||||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
|
||||||
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Narrator name & num books overlay -->
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
narrator: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
width: Number,
|
|
||||||
height: Number,
|
|
||||||
sizeMultiplier: {
|
|
||||||
type: Number,
|
|
||||||
default: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
name() {
|
|
||||||
return this.narrator?.name || ''
|
|
||||||
},
|
|
||||||
numBooks() {
|
|
||||||
return this.narrator?.numBooks || this.narrator?.books?.length || 0
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
|
||||||
<div class="w-10 h-10 flex items-center justify-center">
|
|
||||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
|
||||||
<p class="truncate text-sm">{{ narrator }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
narrator: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.narratorSearchCardContent {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(narrator, index) in narrators">
|
|
||||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="publishedYear" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ publishedYear }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="publisher" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicAlbum" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicAlbum }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicAlbumArtist }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicTrackPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ musicDiscPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="podcastType" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="capitalize">
|
|
||||||
{{ podcastType }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5" v-if="genres.length">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(genre, index) in genres">
|
|
||||||
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5" v-if="tags.length">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
|
||||||
<template v-for="(tag, index) in tags">
|
|
||||||
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
|
||||||
><span :key="index" v-if="index < tags.length - 1">, </span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ durationPretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex py-0.5">
|
|
||||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ sizePretty }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
libraryId() {
|
|
||||||
return this.libraryItem.libraryId
|
|
||||||
},
|
|
||||||
isPodcast() {
|
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
|
||||||
},
|
|
||||||
audioFile() {
|
|
||||||
// Music track
|
|
||||||
return this.media.audioFile
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem.media || {}
|
|
||||||
},
|
|
||||||
tracks() {
|
|
||||||
return this.media.tracks || []
|
|
||||||
},
|
|
||||||
podcastEpisodes() {
|
|
||||||
return this.media.episodes || []
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
|
||||||
publishedYear() {
|
|
||||||
return this.mediaMetadata.publishedYear
|
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
return this.mediaMetadata.genres || []
|
|
||||||
},
|
|
||||||
tags() {
|
|
||||||
return this.media.tags || []
|
|
||||||
},
|
|
||||||
podcastAuthor() {
|
|
||||||
return this.mediaMetadata.author || ''
|
|
||||||
},
|
|
||||||
authors() {
|
|
||||||
return this.mediaMetadata.authors || []
|
|
||||||
},
|
|
||||||
publisher() {
|
|
||||||
return this.mediaMetadata.publisher || ''
|
|
||||||
},
|
|
||||||
musicArtists() {
|
|
||||||
return this.mediaMetadata.artists || []
|
|
||||||
},
|
|
||||||
musicAlbum() {
|
|
||||||
return this.mediaMetadata.album || ''
|
|
||||||
},
|
|
||||||
musicAlbumArtist() {
|
|
||||||
return this.mediaMetadata.albumArtist || ''
|
|
||||||
},
|
|
||||||
musicTrackPretty() {
|
|
||||||
if (!this.mediaMetadata.trackNumber) return null
|
|
||||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
|
||||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
|
||||||
},
|
|
||||||
musicDiscPretty() {
|
|
||||||
if (!this.mediaMetadata.discNumber) return null
|
|
||||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
|
||||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
|
||||||
},
|
|
||||||
narrators() {
|
|
||||||
return this.mediaMetadata.narrators || []
|
|
||||||
},
|
|
||||||
durationPretty() {
|
|
||||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
|
||||||
|
|
||||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
|
||||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
|
||||||
return this.$elapsedPretty(this.duration)
|
|
||||||
},
|
|
||||||
duration() {
|
|
||||||
if (!this.tracks.length && !this.audioFile) return 0
|
|
||||||
return this.media.duration
|
|
||||||
},
|
|
||||||
totalPodcastDuration() {
|
|
||||||
if (!this.podcastEpisodes.length) return 0
|
|
||||||
let totalDuration = 0
|
|
||||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
|
||||||
return totalDuration
|
|
||||||
},
|
|
||||||
sizePretty() {
|
|
||||||
return this.$bytesPretty(this.media.size)
|
|
||||||
},
|
|
||||||
podcastType() {
|
|
||||||
return this.mediaMetadata.type
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs">{{ selectedText }}</span>
|
<span class="block truncate text-xs">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -14,17 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-icons text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -63,15 +63,6 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
|
||||||
<template v-for="narrator in narratorResults">
|
|
||||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
|
||||||
<cards-narrator-search-card :narrator="narrator.name" />
|
|
||||||
</nuxt-link>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +84,6 @@ export default {
|
|||||||
authorResults: [],
|
authorResults: [],
|
||||||
seriesResults: [],
|
seriesResults: [],
|
||||||
tagResults: [],
|
tagResults: [],
|
||||||
narratorResults: [],
|
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
lastSearch: null
|
lastSearch: null
|
||||||
}
|
}
|
||||||
@@ -103,7 +93,7 @@ export default {
|
|||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
},
|
},
|
||||||
totalResults() {
|
totalResults() {
|
||||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
|
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -124,7 +114,6 @@ export default {
|
|||||||
this.authorResults = []
|
this.authorResults = []
|
||||||
this.seriesResults = []
|
this.seriesResults = []
|
||||||
this.tagResults = []
|
this.tagResults = []
|
||||||
this.narratorResults = []
|
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
this.isTyping = false
|
this.isTyping = false
|
||||||
@@ -153,7 +142,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isFetching = true
|
this.isFetching = true
|
||||||
|
|
||||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||||
console.error('Search error', error)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -166,7 +155,6 @@ export default {
|
|||||||
this.authorResults = searchResults.authors || []
|
this.authorResults = searchResults.authors || []
|
||||||
this.seriesResults = searchResults.series || []
|
this.seriesResults = searchResults.series || []
|
||||||
this.tagResults = searchResults.tags || []
|
this.tagResults = searchResults.tags || []
|
||||||
this.narratorResults = searchResults.narrators || []
|
|
||||||
|
|
||||||
this.isFetching = false
|
this.isFetching = false
|
||||||
if (!this.showMenu) {
|
if (!this.showMenu) {
|
||||||
@@ -197,8 +185,8 @@ export default {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
.globalSearchMenu {
|
.globalSearchMenu {
|
||||||
max-height: calc(100vh - 75px);
|
max-height: 80vh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -9,35 +9,31 @@
|
|||||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-icons text-2xl">arrow_right</span>
|
<span class="material-icons text-2xl">arrow_right</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-icons text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-icons text-2xl">arrow_left</span>
|
<span class="material-icons text-2xl">arrow_left</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal block truncate">Back</span>
|
<span class="font-normal ml-3 block truncate">Back</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||||
@@ -45,15 +41,16 @@
|
|||||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- selected checkmark icon -->
|
|
||||||
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
|
||||||
<span class="material-icons text-base text-yellow-400">check</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -75,8 +72,9 @@ export default {
|
|||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showMenu(newVal) {
|
showMenu(newVal) {
|
||||||
if (newVal) {
|
if (!newVal) {
|
||||||
this.sublist = this.selectedItemSublist
|
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||||
|
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -124,11 +122,6 @@ export default {
|
|||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelPublisher,
|
|
||||||
value: 'publishers',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
@@ -172,11 +165,6 @@ export default {
|
|||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelPublisher,
|
|
||||||
value: 'publishers',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelLanguage,
|
text: this.$strings.LabelLanguage,
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
@@ -197,16 +185,6 @@ export default {
|
|||||||
value: 'tracks',
|
value: 'tracks',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
text: this.$strings.LabelEbooks,
|
|
||||||
value: 'ebooks',
|
|
||||||
sublist: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAbridged,
|
|
||||||
value: 'abridged',
|
|
||||||
sublist: false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
@@ -272,25 +250,21 @@ export default {
|
|||||||
return this.bookItems
|
return this.bookItems
|
||||||
},
|
},
|
||||||
selectedItemSublist() {
|
selectedItemSublist() {
|
||||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||||
},
|
},
|
||||||
selectedText() {
|
selectedText() {
|
||||||
if (!this.selected) return ''
|
if (!this.selected) return ''
|
||||||
const parts = this.selected.split('.')
|
var parts = this.selected.split('.')
|
||||||
const filterName = this.selectItems.find((i) => i.value === parts[0])
|
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||||
let filterValue = null
|
var filterValue = null
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
const decoded = this.$decode(parts[1])
|
var decoded = this.$decode(parts[1])
|
||||||
if (parts[0] === 'authors') {
|
if (decoded.startsWith('aut_')) {
|
||||||
const author = this.authors.find((au) => au.id == decoded)
|
var author = this.authors.find((au) => au.id == decoded)
|
||||||
if (author) filterValue = author.name
|
if (author) filterValue = author.name
|
||||||
} else if (parts[0] === 'series') {
|
} else if (decoded.startsWith('ser_')) {
|
||||||
if (decoded === 'no-series') {
|
var series = this.series.find((se) => se.id == decoded)
|
||||||
filterValue = this.$strings.MessageNoSeries
|
|
||||||
} else {
|
|
||||||
const series = this.series.find((se) => se.id == decoded)
|
|
||||||
if (series) filterValue = series.name
|
if (series) filterValue = series.name
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
filterValue = decoded
|
filterValue = decoded
|
||||||
}
|
}
|
||||||
@@ -323,9 +297,6 @@ export default {
|
|||||||
languages() {
|
languages() {
|
||||||
return this.filterData.languages || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
publishers() {
|
|
||||||
return this.filterData.publishers || []
|
|
||||||
},
|
|
||||||
progress() {
|
progress() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -348,10 +319,6 @@ export default {
|
|||||||
},
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
id: 'none',
|
|
||||||
name: this.$strings.LabelTracksNone
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'single',
|
id: 'single',
|
||||||
name: this.$strings.LabelTracksSingleTrack
|
name: this.$strings.LabelTracksSingleTrack
|
||||||
@@ -362,18 +329,6 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
ebooks() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'ebook',
|
|
||||||
name: this.$strings.LabelHasEbook
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'supplementary',
|
|
||||||
name: this.$strings.LabelHasSupplementaryEbook
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
missing() {
|
missing() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -431,7 +386,7 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
sublistItems() {
|
sublistItems() {
|
||||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
return (this[this.sublist] || []).map((item) => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
return {
|
return {
|
||||||
text: item,
|
text: item,
|
||||||
@@ -444,13 +399,6 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (this.sublist === 'series') {
|
|
||||||
sublistItems.unshift({
|
|
||||||
text: this.$strings.MessageNoSeries,
|
|
||||||
value: this.$encode('no-series')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return sublistItems
|
|
||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
@@ -475,7 +423,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const val = option.value
|
var val = option.value
|
||||||
if (this.selected === val) {
|
if (this.selected === val) {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
return
|
return
|
||||||
@@ -487,9 +435,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.libraryFilterMenu {
|
|
||||||
max-height: calc(100vh - 125px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in selectItems">
|
<template v-for="item in selectItems">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||||
<span class="flex items-center justify-between">
|
<span class="flex items-center justify-between">
|
||||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" />
|
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||||
|
|
||||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
@@ -44,7 +43,6 @@ export default {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
expandOnClick: Boolean,
|
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -134,11 +132,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickCover() {
|
|
||||||
if (this.expandOnClick && this.libraryItem) {
|
|
||||||
this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setCoverBg() {
|
setCoverBg() {
|
||||||
if (this.$refs.coverBg) {
|
if (this.$refs.coverBg) {
|
||||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
|
|
||||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||||
<img v-if="width > 100" src="/Logo.png" class="mb-2" :style="{ height: 40 * sizeMultiplier + 'px' }" />
|
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||||
<p class="text-center text-error" :style="{ fontSize: invalidCoverFontSize + 'rem' }">Invalid Cover</p>
|
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,9 +58,6 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
return this.width / 120
|
return this.width / 120
|
||||||
},
|
},
|
||||||
invalidCoverFontSize() {
|
|
||||||
return Math.max(this.sizeMultiplier * 0.8, 0.5)
|
|
||||||
},
|
|
||||||
placeholderCoverPadding() {
|
placeholderCoverPadding() {
|
||||||
return 0.8 * this.sizeMultiplier
|
return 0.8 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<div class="w-full p-8">
|
<div class="w-full p-8">
|
||||||
<div class="flex py-2">
|
<div class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
@@ -14,17 +14,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||||
<ui-text-input-with-label v-else v-model="newUser.email" :label="$strings.LabelEmail" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!isEditingRoot" class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="w-1/2 px-2">
|
|
||||||
<ui-text-input-with-label v-model="newUser.email" :label="$strings.LabelEmail" />
|
|
||||||
</div>
|
|
||||||
<div class="px-2 w-52">
|
<div class="px-2 w-52">
|
||||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="flex-grow" /> -->
|
<div class="flex-grow" />
|
||||||
<div class="flex items-center pt-4 px-2">
|
<div class="flex items-center pt-4 px-2">
|
||||||
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||||
@@ -100,13 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||||
<div class="flex items-center">
|
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
|
||||||
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
|
||||||
<div class="flex items-center pt-4 px-2">
|
|
||||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
|
||||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,9 +185,6 @@ export default {
|
|||||||
value: t
|
value: t
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
tagsSelectionText() {
|
|
||||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -206,11 +193,8 @@ export default {
|
|||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (val) {
|
if (val && this.newUser.itemTagsAccessible.length) {
|
||||||
if (this.newUser.itemTagsSelected?.length) {
|
this.newUser.itemTagsAccessible = []
|
||||||
this.newUser.itemTagsSelected = []
|
|
||||||
}
|
|
||||||
this.newUser.permissions.selectedTagsNotAccessible = false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetchAllTags() {
|
fetchAllTags() {
|
||||||
@@ -242,7 +226,7 @@ export default {
|
|||||||
this.$toast.error('Must select at least one library')
|
this.$toast.error('Must select at least one library')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||||
this.$toast.error('Must select at least one tag')
|
this.$toast.error('Must select at least one tag')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -261,6 +245,7 @@ export default {
|
|||||||
if (account.type === 'root' && !account.isActive) return
|
if (account.type === 'root' && !account.isActive) return
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
console.log('Calling update', account)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/users/${this.account.id}`, account)
|
.$patch(`/api/users/${this.account.id}`, account)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -322,29 +307,26 @@ export default {
|
|||||||
delete: type === 'admin',
|
delete: type === 'admin',
|
||||||
upload: type === 'admin',
|
upload: type === 'admin',
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true,
|
accessAllTags: true
|
||||||
selectedTagsNotAccessible: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchAllTags()
|
this.fetchAllTags()
|
||||||
this.isNew = !this.account
|
|
||||||
|
|
||||||
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
email: this.account.email,
|
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions },
|
permissions: { ...this.account.permissions },
|
||||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: null,
|
username: null,
|
||||||
email: null,
|
|
||||||
password: null,
|
password: null,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -354,11 +336,9 @@ export default {
|
|||||||
delete: false,
|
delete: false,
|
||||||
upload: false,
|
upload: false,
|
||||||
accessAllLibraries: true,
|
accessAllLibraries: true,
|
||||||
accessAllTags: true,
|
accessAllTags: true
|
||||||
selectedTagsNotAccessible: false
|
|
||||||
},
|
},
|
||||||
librariesAccessible: [],
|
librariesAccessible: []
|
||||||
itemTagsSelected: []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
|
||||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
|
||||||
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
|
||||||
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
|
||||||
|
|
||||||
<template v-if="!ffprobeData">
|
|
||||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row text-sm">
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelSize }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelDuration }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
|
||||||
<p>{{ audioFile.format }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChapters }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelEmbeddedCover }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full sm:w-1/2">
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelCodec }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.codec }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelChannels }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelBitrate }}
|
|
||||||
</p>
|
|
||||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
|
||||||
<p>{{ audioFile.timeBase }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="audioFile.language" class="flex mb-1">
|
|
||||||
<p class="w-32 text-black-50">
|
|
||||||
{{ $strings.LabelLanguage }}
|
|
||||||
</p>
|
|
||||||
<p>{{ audioFile.language || '' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
|
||||||
|
|
||||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
|
||||||
|
|
||||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
|
||||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
|
||||||
{{ key.replace('tag', '') }}
|
|
||||||
</p>
|
|
||||||
<p>{{ value }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="w-full">
|
|
||||||
<div class="relative">
|
|
||||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
|
||||||
|
|
||||||
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
|
||||||
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
audioFile: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
libraryItemId: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
probingFile: false,
|
|
||||||
ffprobeData: null,
|
|
||||||
copiedToClipboard: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.ffprobeData = null
|
|
||||||
this.copiedToClipboard = false
|
|
||||||
this.probingFile = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
metadata() {
|
|
||||||
return this.audioFile?.metadata || {}
|
|
||||||
},
|
|
||||||
metaTags() {
|
|
||||||
return this.audioFile?.metaTags || {}
|
|
||||||
},
|
|
||||||
userIsAdminOrUp() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
prettyFfprobeData() {
|
|
||||||
if (!this.ffprobeData) return ''
|
|
||||||
return JSON.stringify(this.ffprobeData, null, 2)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getFFProbeData() {
|
|
||||||
this.probingFile = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
|
||||||
.then((data) => {
|
|
||||||
console.log('Got ffprobe data', data)
|
|
||||||
this.ffprobeData = data
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to get ffprobe data', error)
|
|
||||||
this.$toast.error('FFProbe failed')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.probingFile = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async copyFfprobeData() {
|
|
||||||
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
<p class="chapter-title truncate text-sm md:text-base">
|
<p class="chapter-title truncate text-sm md:text-base">
|
||||||
{{ chap.title }}
|
{{ chap.title }}
|
||||||
</p>
|
</p>
|
||||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||||
<span class="flex-grow" />
|
<span class="flex-grow" />
|
||||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||||
|
|
||||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +28,7 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
}
|
||||||
playbackRate: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -48,15 +47,11 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_playbackRate() {
|
|
||||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
|
||||||
return this.playbackRate
|
|
||||||
},
|
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter ? this.currentChapter.id : null
|
||||||
},
|
},
|
||||||
currentChapterStart() {
|
currentChapterStart() {
|
||||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
return this.currentChapter ? this.currentChapter.start : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -66,11 +61,13 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
if (this.$refs.container) {
|
var container = this.$refs.container
|
||||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
if (container) {
|
||||||
|
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
const containerHeight = this.$refs.container.clientHeight
|
var offsetTop = currChapterEl.offsetTop
|
||||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
var containerHeight = container.clientHeight
|
||||||
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', { action })
|
this.$emit('action', action)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||||
@@ -50,19 +50,19 @@
|
|||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.libraryId }}
|
{{ _session.libraryId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center -mx-1 mb-1">
|
<div class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.libraryItemId }}
|
{{ _session.libraryItemId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||||
<div class="px-1 text-xs">
|
<div class="px-1">
|
||||||
{{ _session.episodeId }}
|
{{ _session.episodeId }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/3">
|
<div class="w-full md:w-1/3">
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||||
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
<p class="mb-1">{{ _session.userId }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
@@ -98,8 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -158,9 +157,6 @@ export default {
|
|||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.state.serverSettings.timeFormat
|
||||||
},
|
|
||||||
isOpenSession() {
|
|
||||||
return !!this._session.open
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -192,24 +188,6 @@ export default {
|
|||||||
var errMsg = error.response ? error.response.data || '' : ''
|
var errMsg = error.response ? error.response.data || '' : ''
|
||||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||||
})
|
})
|
||||||
},
|
|
||||||
closeSessionClick() {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/session/${this._session.id}/close`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Session closed')
|
|
||||||
this.show = false
|
|
||||||
this.$emit('closedSession')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to close session', error)
|
|
||||||
const errMsg = error.response?.data || ''
|
|
||||||
this.$toast.error(errMsg || 'Failed to close open session')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||||
</button>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0">
|
|
||||||
<div class="w-full h-full" @click="show = false">
|
|
||||||
<img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.globals.showRawCoverPreviewModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedLibraryItemId() {
|
|
||||||
return this.$store.state.globals.selectedLibraryItemId
|
|
||||||
},
|
|
||||||
rawCoverUrl() {
|
|
||||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -9,14 +9,10 @@
|
|||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div v-if="!timerSet" class="w-full">
|
<div v-if="!timerSet" class="w-full">
|
||||||
<template v-for="time in sleepTimes">
|
<template v-for="time in sleepTimes">
|
||||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time)">
|
||||||
<p class="text-xl text-center">{{ time.text }}</p>
|
<p class="text-xl text-center">{{ time.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
|
||||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
|
||||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full p-4">
|
<div v-else class="w-full p-4">
|
||||||
<div class="mb-4 flex items-center justify-center">
|
<div class="mb-4 flex items-center justify-center">
|
||||||
@@ -52,28 +48,19 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
customTime: null,
|
|
||||||
sleepTimes: [
|
sleepTimes: [
|
||||||
|
{
|
||||||
|
seconds: 10,
|
||||||
|
text: '10 seconds'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
seconds: 60 * 5,
|
seconds: 60 * 5,
|
||||||
text: '5 minutes'
|
text: '5 minutes'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
seconds: 60 * 15,
|
|
||||||
text: '15 minutes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seconds: 60 * 20,
|
|
||||||
text: '20 minutes'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
seconds: 60 * 30,
|
seconds: 60 * 30,
|
||||||
text: '30 minutes'
|
text: '30 minutes'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
seconds: 60 * 45,
|
|
||||||
text: '45 minutes'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
seconds: 60 * 60,
|
seconds: 60 * 60,
|
||||||
text: '60 minutes'
|
text: '60 minutes'
|
||||||
@@ -85,6 +72,10 @@ export default {
|
|||||||
{
|
{
|
||||||
seconds: 60 * 120,
|
seconds: 60 * 120,
|
||||||
text: '2 hours'
|
text: '2 hours'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 60 * 180,
|
||||||
|
text: '3 hours'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -106,17 +97,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
submitCustomTime() {
|
setTime(time) {
|
||||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
this.$emit('set', time.seconds)
|
||||||
this.customTime = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
|
||||||
this.setTime(timeInSeconds)
|
|
||||||
},
|
|
||||||
setTime(seconds) {
|
|
||||||
this.$emit('set', seconds)
|
|
||||||
},
|
},
|
||||||
increment(amount) {
|
increment(amount) {
|
||||||
this.$emit('increment', amount)
|
this.$emit('increment', amount)
|
||||||
|
|||||||
@@ -5,23 +5,18 @@
|
|||||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-40 p-2">
|
<div class="w-40 p-2">
|
||||||
<div class="w-full h-45 relative">
|
<div class="w-full h-45 relative">
|
||||||
<covers-author-image :author="author" />
|
<covers-author-image :author="author" />
|
||||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2">
|
|
||||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
|
||||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form v-if="author" @submit.prevent="submitForm">
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-3/4 p-2">
|
<div class="w-3/4 p-2">
|
||||||
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
|
||||||
@@ -30,24 +25,22 @@
|
|||||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="p-2">
|
<div class="p-2">
|
||||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||||
</div> -->
|
</div>
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-2 px-2">
|
<div class="flex pt-2 px-2">
|
||||||
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
|
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
|
||||||
|
|
||||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -58,9 +51,9 @@ export default {
|
|||||||
authorCopy: {
|
authorCopy: {
|
||||||
name: '',
|
name: '',
|
||||||
asin: '',
|
asin: '',
|
||||||
description: ''
|
description: '',
|
||||||
|
imagePath: ''
|
||||||
},
|
},
|
||||||
imageUrl: '',
|
|
||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,51 +85,17 @@ export default {
|
|||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.$strings.HeaderUpdateAuthor
|
return this.$strings.HeaderUpdateAuthor
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.$store.state.libraries.currentLibraryId
|
|
||||||
},
|
|
||||||
libraryProvider() {
|
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
init() {
|
init() {
|
||||||
this.imageUrl = ''
|
|
||||||
this.authorCopy.name = this.author.name
|
this.authorCopy.name = this.author.name
|
||||||
this.authorCopy.asin = this.author.asin
|
this.authorCopy.asin = this.author.asin
|
||||||
this.authorCopy.description = this.author.description
|
this.authorCopy.description = this.author.description
|
||||||
},
|
this.authorCopy.imagePath = this.author.imagePath
|
||||||
removeClick() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/authors/${this.authorId}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Author removed')
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove author', error)
|
|
||||||
this.$toast.error('Failed to remove author')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
var keysToCheck = ['name', 'asin', 'description']
|
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
keysToCheck.forEach((key) => {
|
keysToCheck.forEach((key) => {
|
||||||
if (this.authorCopy[key] !== this.author[key]) {
|
if (this.authorCopy[key] !== this.author[key]) {
|
||||||
@@ -165,46 +124,21 @@ export default {
|
|||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
removeCover() {
|
async removeCover() {
|
||||||
|
var updatePayload = {
|
||||||
|
imagePath: null
|
||||||
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||||
.$delete(`/api/authors/${this.authorId}/image`)
|
|
||||||
.then((data) => {
|
|
||||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
|
||||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
.finally(() => {
|
if (result && result.updated) {
|
||||||
this.processing = false
|
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||||
})
|
this.$store.commit('globals/showEditAuthorModal', result.author)
|
||||||
},
|
|
||||||
submitUploadCover() {
|
|
||||||
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
|
|
||||||
this.$toast.error('Invalid image url')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
const updatePayload = {
|
|
||||||
url: this.imageUrl
|
|
||||||
}
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
|
|
||||||
.then((data) => {
|
|
||||||
this.imageUrl = ''
|
|
||||||
this.$toast.success('Author image updated')
|
|
||||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.$toast.error(error.response.data || 'Failed to remove author image')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
@@ -217,11 +151,6 @@ export default {
|
|||||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
else payload.q = this.authorCopy.name
|
else payload.q = this.authorCopy.name
|
||||||
|
|
||||||
payload.region = 'us'
|
|
||||||
if (this.libraryProvider.startsWith('audible.')) {
|
|
||||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<template v-if="!showImageUploader">
|
<template v-if="!showImageUploader">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex">
|
||||||
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
<div>
|
||||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-4">
|
<div class="flex-grow px-4">
|
||||||
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<ui-btn color="success">Upload</ui-btn>
|
<ui-btn color="success">Upload</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
|
||||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<form @submit.prevent="submitForm">
|
|
||||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
|
||||||
<div class="w-full px-3 py-5 md:p-12">
|
|
||||||
<div class="flex items-center -mx-1 mb-4">
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center -mx-1 mb-4">
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-dropdown v-model="newDevice.availabilityOption" :label="$strings.LabelDeviceIsAvailableTo" :items="userAvailabilityOptions" @input="availabilityOptionChanged" />
|
|
||||||
</div>
|
|
||||||
<div class="w-full md:w-1/2 px-1">
|
|
||||||
<ui-multi-select-dropdown v-if="newDevice.availabilityOption === 'specificUsers'" v-model="newDevice.users" :label="$strings.HeaderUsers" :items="userOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
existingDevices: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
ereaderDevice: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false,
|
|
||||||
newDevice: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
availabilityOption: 'adminAndUp',
|
|
||||||
users: []
|
|
||||||
},
|
|
||||||
users: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return !this.ereaderDevice ? 'Create Device' : 'Update Device'
|
|
||||||
},
|
|
||||||
userAvailabilityOptions() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAdminUsersOnly,
|
|
||||||
value: 'adminOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAllUsersExcludingGuests,
|
|
||||||
value: 'userOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelAllUsersIncludingGuests,
|
|
||||||
value: 'guestOrUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelSelectUsers,
|
|
||||||
value: 'specificUsers'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
userOptions() {
|
|
||||||
return this.users.map((u) => ({ text: u.username, value: u.id }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
availabilityOptionChanged(option) {
|
|
||||||
if (option === 'specificUsers' && !this.users.length) {
|
|
||||||
this.loadUsers()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async loadUsers() {
|
|
||||||
this.processing = true
|
|
||||||
this.users = await this.$axios
|
|
||||||
.$get('/api/users')
|
|
||||||
.then((res) => {
|
|
||||||
return res.users.sort((a, b) => {
|
|
||||||
return a.createdAt - b.createdAt
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitForm() {
|
|
||||||
this.$refs.ereaderNameInput.blur()
|
|
||||||
this.$refs.ereaderEmailInput.blur()
|
|
||||||
|
|
||||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
|
||||||
this.$toast.error('Name and email required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) {
|
|
||||||
this.$toast.error('Must select at least one user')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.newDevice.availabilityOption !== 'specificUsers') {
|
|
||||||
this.newDevice.users = []
|
|
||||||
}
|
|
||||||
|
|
||||||
this.newDevice.name = this.newDevice.name.trim()
|
|
||||||
this.newDevice.email = this.newDevice.email.trim()
|
|
||||||
|
|
||||||
if (!this.ereaderDevice) {
|
|
||||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error('Ereader device with that name already exists')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitCreate()
|
|
||||||
} else {
|
|
||||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
|
||||||
this.$toast.error('Ereader device with that name already exists')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.submitUpdate()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitUpdate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...existingDevicesWithoutThisOne,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/emails/ereader-devices`, payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices)
|
|
||||||
this.$toast.success('Device updated')
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update device', error)
|
|
||||||
this.$toast.error('Failed to update device')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
submitCreate() {
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ereaderDevices: [
|
|
||||||
...this.existingDevices,
|
|
||||||
{
|
|
||||||
...this.newDevice
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$post('/api/emails/ereader-devices', payload)
|
|
||||||
.then((data) => {
|
|
||||||
this.$emit('update', data.ereaderDevices || [])
|
|
||||||
this.$toast.success('Device added')
|
|
||||||
this.show = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to add device', error)
|
|
||||||
this.$toast.error('Failed to add device')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
if (this.ereaderDevice) {
|
|
||||||
this.newDevice.name = this.ereaderDevice.name
|
|
||||||
this.newDevice.email = this.ereaderDevice.email
|
|
||||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
|
||||||
this.newDevice.users = this.ereaderDevice.users || []
|
|
||||||
|
|
||||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
|
|
||||||
this.loadUsers()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.newDevice.name = ''
|
|
||||||
this.newDevice.email = ''
|
|
||||||
this.newDevice.availabilityOption = 'adminOrUp'
|
|
||||||
this.newDevice.users = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -74,9 +74,6 @@ export default {
|
|||||||
this.$store.commit('setEditModalTab', val)
|
this.$store.commit('setEditModalTab', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
height() {
|
|
||||||
return Math.min(this.availableHeight, 650)
|
|
||||||
},
|
|
||||||
tabs() {
|
tabs() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -127,6 +124,9 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
@@ -136,26 +136,14 @@ export default {
|
|||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
selectedLibraryItem() {
|
|
||||||
return this.$store.state.selectedLibraryItem || {}
|
|
||||||
},
|
|
||||||
selectedLibraryItemId() {
|
|
||||||
return this.selectedLibraryItem.id
|
|
||||||
},
|
|
||||||
media() {
|
|
||||||
return this.libraryItem?.media || {}
|
|
||||||
},
|
|
||||||
mediaMetadata() {
|
|
||||||
return this.media.metadata || {}
|
|
||||||
},
|
|
||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
return this.tabs.filter((tab) => {
|
||||||
|
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||||
|
|
||||||
if (tab.id === 'tools' && this.isMissing) return false
|
if (tab.id === 'tools' && this.isMissing) return false
|
||||||
if (tab.id === 'chapters' && this.isEBookOnly) return false
|
|
||||||
|
|
||||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||||
@@ -163,6 +151,9 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
height() {
|
||||||
|
return Math.min(this.availableHeight, 650)
|
||||||
|
},
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
@@ -170,11 +161,20 @@ export default {
|
|||||||
isMissing() {
|
isMissing() {
|
||||||
return this.selectedLibraryItem.isMissing
|
return this.selectedLibraryItem.isMissing
|
||||||
},
|
},
|
||||||
isEBookOnly() {
|
selectedLibraryItem() {
|
||||||
return this.media.ebookFile && !this.media.tracks?.length
|
return this.$store.state.selectedLibraryItem || {}
|
||||||
|
},
|
||||||
|
selectedLibraryItemId() {
|
||||||
|
return this.selectedLibraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem?.mediaType || null
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||||
<div class="flex flex-wrap mb-4">
|
<div class="flex flex-wrap">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
|
||||||
<!-- book cover overlay -->
|
<!-- book cover overlay -->
|
||||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||||
<div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
||||||
<span class="material-icons text-2xl">delete</span>
|
<span class="material-icons text-2xl">delete</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -16,31 +15,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
<ui-file-input ref="fileInput" @change="fileUploadSelected"
|
||||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
|
||||||
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
|
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
|
||||||
</ui-file-input>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
|
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||||
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-2 sm:ml-3 w-24">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||||
<div class="flex items-center justify-center py-2">
|
<div class="flex items-center justify-center py-2">
|
||||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||||
<template v-for="localCoverFile in localCovers">
|
<template v-for="cover in localCovers">
|
||||||
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||||
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,13 +48,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitSearchForm">
|
<form @submit.prevent="submitSearchForm">
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
<div class="flex items-center justify-start -mx-1 h-20">
|
||||||
<div class="w-48 px-1">
|
<div class="w-40 px-1">
|
||||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-72 px-1">
|
<div class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
|
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
@@ -65,7 +63,7 @@
|
|||||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||||
<template v-for="cover in coversFound">
|
<template v-for="cover in coversFound">
|
||||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||||
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-preview-cover :src="cover" :width="80" show-open-new-tab :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -129,7 +127,7 @@ export default {
|
|||||||
},
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
return this.$store.state.scanners.providers
|
||||||
},
|
},
|
||||||
searchTitleLabel() {
|
searchTitleLabel() {
|
||||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||||
@@ -140,19 +138,16 @@ export default {
|
|||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id || null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
|
||||||
libraryItemUpdatedAt() {
|
|
||||||
return this.libraryItem?.updatedAt || null
|
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem?.mediaType || null
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType == 'podcast'
|
return this.mediaType == 'podcast'
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
coverPath() {
|
coverPath() {
|
||||||
return this.media.coverPath
|
return this.media.coverPath
|
||||||
@@ -161,14 +156,11 @@ export default {
|
|||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
libraryFiles() {
|
libraryFiles() {
|
||||||
return this.libraryItem?.libraryFiles || []
|
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
|
||||||
},
|
},
|
||||||
userCanUpload() {
|
userCanUpload() {
|
||||||
return this.$store.getters['user/getUserCanUpload']
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
},
|
},
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
@@ -176,8 +168,8 @@ export default {
|
|||||||
return this.libraryFiles
|
return this.libraryFiles
|
||||||
.filter((f) => f.fileType === 'image')
|
.filter((f) => f.fileType === 'image')
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const _file = { ...file }
|
var _file = { ...file }
|
||||||
_file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
|
_file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
|
||||||
return _file
|
return _file
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -226,63 +218,82 @@ export default {
|
|||||||
this.coversFound = []
|
this.coversFound = []
|
||||||
this.hasSearched = false
|
this.hasSearched = false
|
||||||
}
|
}
|
||||||
this.imageUrl = ''
|
this.imageUrl = this.media.coverPath || ''
|
||||||
this.searchTitle = this.mediaMetadata.title || ''
|
this.searchTitle = this.mediaMetadata.title || ''
|
||||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||||
if (this.isPodcast) this.provider = 'itunes'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||||
},
|
},
|
||||||
removeCover() {
|
removeCover() {
|
||||||
if (!this.coverPath) {
|
if (!this.media.coverPath) {
|
||||||
|
this.imageUrl = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.isProcessing = true
|
this.updateCover('')
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
|
||||||
.then(() => {})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove cover', error)
|
|
||||||
if (error.response?.data) {
|
|
||||||
this.$toast.error(error.response.data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
this.updateCover(this.imageUrl)
|
this.updateCover(this.imageUrl)
|
||||||
},
|
},
|
||||||
async updateCover(cover) {
|
async updateCover(cover) {
|
||||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
if (cover === this.coverPath) {
|
||||||
this.$toast.error('Invalid URL')
|
console.warn('Cover has not changed..', cover)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
this.$axios
|
var success = false
|
||||||
.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover })
|
|
||||||
.then(() => {
|
if (!cover) {
|
||||||
this.imageUrl = ''
|
// Remove cover
|
||||||
this.$toast.success('Update Successful')
|
success = await this.$axios
|
||||||
})
|
.$delete(`/api/items/${this.libraryItemId}/cover`)
|
||||||
|
.then(() => true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update cover', error)
|
console.error('Failed to remove cover', error)
|
||||||
this.$toast.error(error.response?.data || 'Failed to update cover')
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
.finally(() => {
|
} else if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||||
|
// Download cover from url and use
|
||||||
|
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
|
||||||
|
console.error('Failed to download cover from url', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Update local cover url
|
||||||
|
const updatePayload = {
|
||||||
|
cover
|
||||||
|
}
|
||||||
|
success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed to update', error)
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
this.$toast.error(error.response.data)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
this.$toast.success('Update Successful')
|
||||||
|
// this.$emit('close')
|
||||||
|
} else {
|
||||||
|
this.imageUrl = this.media.coverPath || ''
|
||||||
|
}
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
})
|
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
persistProvider() {
|
persistProvider() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('book-cover-provider', this.provider)
|
localStorage.setItem('book-provider', this.provider)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
@@ -305,19 +316,7 @@ export default {
|
|||||||
this.hasSearched = true
|
this.hasSearched = true
|
||||||
},
|
},
|
||||||
setCover(coverFile) {
|
setCover(coverFile) {
|
||||||
this.isProcessing = true
|
this.updateCover(coverFile.metadata.path)
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path })
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Update Successful')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to set local cover', error)
|
|
||||||
this.$toast.error(error.response?.data || 'Failed to set cover')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.isProcessing = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,19 @@
|
|||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
|
||||||
|
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
|
||||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<!-- desktop -->
|
<!-- desktop -->
|
||||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||||
@@ -74,15 +77,18 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
userCanDelete() {
|
||||||
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||||
},
|
},
|
||||||
libraryProvider() {
|
libraryProvider() {
|
||||||
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
|
||||||
},
|
},
|
||||||
isLibraryScanning() {
|
libraryScan() {
|
||||||
if (!this.libraryId) return null
|
if (!this.libraryId) return null
|
||||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId)
|
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -178,6 +184,23 @@ export default {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
removeItem() {
|
||||||
|
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||||
|
this.isProcessing = true
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/items/${this.libraryItemId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Item removed')
|
||||||
|
this.$toast.success('Item Removed')
|
||||||
|
this.$emit('close')
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Remove item failed', error)
|
||||||
|
this.isProcessing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
checkIsScrollable() {
|
checkIsScrollable() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
var formWrapper = document.getElementById('formWrapper')
|
var formWrapper = document.getElementById('formWrapper')
|
||||||
|
|||||||
@@ -20,14 +20,18 @@
|
|||||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
||||||
<table v-else class="text-sm tracksTable">
|
<table v-else class="text-sm tracksTable">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-center w-20 min-w-20">{{ $strings.LabelEpisode }}</th>
|
<th class="text-left">Sort #</th>
|
||||||
<th class="text-left">{{ $strings.LabelEpisodeTitle }}</th>
|
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
|
||||||
<th class="text-center w-28">{{ $strings.LabelEpisodeDuration }}</th>
|
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
|
||||||
<th class="text-center w-28">{{ $strings.LabelEpisodeSize }}</th>
|
<th class="text-center w-28">{{ $strings.EpisodeDuration }}</th>
|
||||||
|
<th class="text-center w-28">{{ $strings.EpisodeSize }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="episode in episodes" :key="episode.id">
|
<tr v-for="episode in episodes" :key="episode.id">
|
||||||
<td class="text-center w-20 min-w-20">
|
<td class="text-left">
|
||||||
<p>{{ episode.episode }}</p>
|
<p class="px-4">{{ episode.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ episode.episode }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ episode.title }}
|
{{ episode.title }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
|
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tracks: []
|
tracks: [],
|
||||||
|
showFullPath: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -29,6 +30,9 @@ export default {
|
|||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
libraryFiles() {
|
||||||
|
return this.libraryItem.libraryFiles || []
|
||||||
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
|
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
|
||||||
<template v-for="(res, index) in searchResults">
|
<template v-for="(res, index) in searchResults">
|
||||||
<cards-book-match-card :key="index" :book="res" :current-book-duration="currentBookDuration" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
<cards-book-match-card :key="index" :book="res" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||||
@@ -34,26 +34,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||||
<form @submit.prevent="submitMatchUpdate">
|
<form @submit.prevent="submitMatchUpdate">
|
||||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
|
||||||
<div class="flex flex-grow items-center py-2">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||||
</div>
|
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||||
|
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||||
<div class="flex py-2">
|
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||||
<div>
|
|
||||||
<p class="text-center text-gray-200">New</p>
|
|
||||||
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
|
||||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="media.coverPath">
|
|
||||||
<p class="text-center text-gray-200">Current</p>
|
|
||||||
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
|
||||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
@@ -115,7 +103,7 @@
|
|||||||
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4">
|
<div class="flex-grow ml-4">
|
||||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
<ui-multi-select v-model="selectedMatch.genres" :items="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||||
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
|
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,20 +164,13 @@
|
|||||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center py-2">
|
||||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
<div class="flex-grow ml-4">
|
||||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
|
||||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
|
||||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
|
||||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
|
||||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end py-2">
|
<div class="flex items-center justify-end py-2">
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
@@ -235,7 +216,6 @@ export default {
|
|||||||
explicit: true,
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
abridged: true,
|
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
itunesPageUrl: true,
|
itunesPageUrl: true,
|
||||||
itunesId: true,
|
itunesId: true,
|
||||||
@@ -290,26 +270,16 @@ export default {
|
|||||||
return this.$strings.LabelSearchTitle
|
return this.$strings.LabelSearchTitle
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
currentBookDuration() {
|
|
||||||
if (this.isPodcast) return 0
|
|
||||||
return this.media.duration || 0
|
|
||||||
},
|
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem?.mediaType || null
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType == 'podcast'
|
return this.mediaType == 'podcast'
|
||||||
},
|
|
||||||
genres() {
|
|
||||||
const filterData = this.$store.state.libraries.filterData || {}
|
|
||||||
const currentGenres = filterData.genres || []
|
|
||||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
|
||||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -329,9 +299,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
if (this.isPodcast) return `term=${this.searchTitle}`
|
||||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
|
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
@@ -390,7 +360,6 @@ export default {
|
|||||||
explicit: true,
|
explicit: true,
|
||||||
asin: true,
|
asin: true,
|
||||||
isbn: true,
|
isbn: true,
|
||||||
abridged: true,
|
|
||||||
// Podcast specific
|
// Podcast specific
|
||||||
itunesPageUrl: true,
|
itunesPageUrl: true,
|
||||||
itunesId: true,
|
itunesId: true,
|
||||||
@@ -507,6 +476,7 @@ export default {
|
|||||||
} else if (key === 'narrator') {
|
} else if (key === 'narrator') {
|
||||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
} else if (key === 'genres') {
|
} else if (key === 'genres') {
|
||||||
|
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||||
} else if (key === 'tags') {
|
} else if (key === 'tags') {
|
||||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||||
@@ -584,7 +554,6 @@ export default {
|
|||||||
.matchListWrapper {
|
.matchListWrapper {
|
||||||
height: calc(100% - 124px);
|
height: calc(100% - 124px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.matchListWrapper {
|
.matchListWrapper {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split to mp3 -->
|
<!-- Split to mp3 -->
|
||||||
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
|
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
|
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
<!-- Embed Metadata -->
|
<!-- Embed Metadata -->
|
||||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||||
@@ -46,20 +46,8 @@
|
|||||||
>{{ $strings.ButtonOpenManager }}
|
>{{ $strings.ButtonOpenManager }}
|
||||||
<span class="material-icons text-lg ml-2">launch</span>
|
<span class="material-icons text-lg ml-2">launch</span>
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- queued alert -->
|
|
||||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
|
||||||
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
|
||||||
</widgets-alert>
|
|
||||||
|
|
||||||
<!-- processing alert -->
|
|
||||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
|
||||||
<p class="text-lg">Currently embedding metadata</p>
|
|
||||||
</widgets-alert>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||||
@@ -79,11 +67,14 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id || null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
},
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem?.media || {}
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
},
|
},
|
||||||
mediaTracks() {
|
mediaTracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
@@ -101,49 +92,9 @@ export default {
|
|||||||
showMp3Split() {
|
showMp3Split() {
|
||||||
if (!this.mediaTracks.length) return false
|
if (!this.mediaTracks.length) return false
|
||||||
return this.isSingleM4b && this.chapters.length
|
return this.isSingleM4b && this.chapters.length
|
||||||
},
|
|
||||||
queuedEmbedLIds() {
|
|
||||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
|
||||||
},
|
|
||||||
isMetadataEmbedQueued() {
|
|
||||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
|
||||||
},
|
|
||||||
tasks() {
|
|
||||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
|
||||||
},
|
|
||||||
embedTask() {
|
|
||||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
|
||||||
},
|
|
||||||
encodeTask() {
|
|
||||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
|
||||||
},
|
|
||||||
isEmbedTaskRunning() {
|
|
||||||
return this.embedTask && !this.embedTask?.isFinished
|
|
||||||
},
|
|
||||||
isEncodeTaskRunning() {
|
|
||||||
return this.encodeTask && !this.encodeTask?.isFinished
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
quickEmbed() {
|
mounted() {}
|
||||||
const payload = {
|
|
||||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
|
|
||||||
.then(() => {
|
|
||||||
console.log('Audio metadata encode started')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Audio metadata encode failed', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
||||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
|
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||||
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-1 px-2 items-center w-full">
|
<div class="flex py-1 px-2 items-center w-full">
|
||||||
@@ -67,6 +67,10 @@ export default {
|
|||||||
value: 'podcast',
|
value: 'podcast',
|
||||||
text: this.$strings.LabelPodcasts
|
text: this.$strings.LabelPodcasts
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// value: 'music',
|
||||||
|
// text: 'Music'
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
folderPaths() {
|
folderPaths() {
|
||||||
@@ -106,11 +110,6 @@ export default {
|
|||||||
formUpdated() {
|
formUpdated() {
|
||||||
this.$emit('update', this.getLibraryData())
|
this.$emit('update', this.getLibraryData())
|
||||||
},
|
},
|
||||||
existingFolderInputBlurred(folder) {
|
|
||||||
if (!folder.fullPath) {
|
|
||||||
this.removeFolder(folder)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
newFolderInputBlurred() {
|
newFolderInputBlurred() {
|
||||||
if (this.newFolderPath) {
|
if (this.newFolderPath) {
|
||||||
this.folders.push({ fullPath: this.newFolderPath })
|
this.folders.push({ fullPath: this.newFolderPath })
|
||||||
@@ -150,7 +149,6 @@ export default {
|
|||||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||||
this.icon = this.library ? this.library.icon : 'default'
|
this.icon = this.library ? this.library.icon : 'default'
|
||||||
this.mediaType = this.library ? this.library.mediaType : 'book'
|
this.mediaType = this.library ? this.library.mediaType : 'book'
|
||||||
|
|
||||||
this.showDirectoryPicker = false
|
this.showDirectoryPicker = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" name="edit-library" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||||
|
|
||||||
<div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
<ui-btn @click="submit">{{ buttonText }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,12 +54,6 @@ export default {
|
|||||||
buttonText() {
|
buttonText() {
|
||||||
return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate
|
return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate
|
||||||
},
|
},
|
||||||
mediaType() {
|
|
||||||
return this.libraryCopy?.mediaType
|
|
||||||
},
|
|
||||||
libraryId() {
|
|
||||||
return this.library?.id
|
|
||||||
},
|
|
||||||
tabs() {
|
tabs() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -72,26 +66,12 @@ export default {
|
|||||||
title: this.$strings.HeaderSettings,
|
title: this.$strings.HeaderSettings,
|
||||||
component: 'modals-libraries-library-settings'
|
component: 'modals-libraries-library-settings'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'scanner',
|
|
||||||
title: this.$strings.HeaderSettingsScanner,
|
|
||||||
component: 'modals-libraries-library-scanner-settings'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'schedule',
|
id: 'schedule',
|
||||||
title: this.$strings.HeaderSchedule,
|
title: this.$strings.HeaderSchedule,
|
||||||
component: 'modals-libraries-schedule-scan'
|
component: 'modals-libraries-schedule-scan'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tools',
|
|
||||||
title: this.$strings.HeaderTools,
|
|
||||||
component: 'modals-libraries-library-tools'
|
|
||||||
}
|
}
|
||||||
].filter((tab) => {
|
]
|
||||||
// Do not show tools tab for new libraries
|
|
||||||
if (tab.id === 'tools' && !this.library) return false
|
|
||||||
return tab.id !== 'scanner' || this.mediaType === 'book'
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
tabName() {
|
tabName() {
|
||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
@@ -125,9 +105,7 @@ export default {
|
|||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
autoScanCronExpression: null,
|
autoScanCronExpression: null
|
||||||
hideSingleBookSeries: false,
|
|
||||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -142,7 +120,7 @@ export default {
|
|||||||
for (const key in this.libraryCopy) {
|
for (const key in this.libraryCopy) {
|
||||||
if (library[key] !== undefined) {
|
if (library[key] !== undefined) {
|
||||||
if (key === 'folders') {
|
if (key === 'folders') {
|
||||||
this.libraryCopy.folders = library.folders.map((f) => ({ ...f })).filter((f) => !!f.fullPath?.trim())
|
this.libraryCopy.folders = library.folders.map((f) => ({ ...f }))
|
||||||
} else if (key === 'settings') {
|
} else if (key === 'settings') {
|
||||||
for (const settingKey in library.settings) {
|
for (const settingKey in library.settings) {
|
||||||
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
this.libraryCopy.settings[settingKey] = library.settings[settingKey]
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2>
|
|
||||||
<ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between md:justify-start mb-4">
|
|
||||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
|
||||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
|
||||||
<span class="material-icons text-xl w-5">help_outline</span>
|
|
||||||
</a>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
|
||||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
|
||||||
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
|
|
||||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
|
|
||||||
<div class="text-center py-1 w-8 min-w-8">
|
|
||||||
{{ source.include ? getSourceIndex(source.id) : '' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow inline-flex justify-between px-4 py-3">
|
|
||||||
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-2 opacity-100">
|
|
||||||
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</transition-group>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
draggable
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
library: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
processing: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
drag: false,
|
|
||||||
dragOptions: {
|
|
||||||
animation: 200,
|
|
||||||
group: 'description',
|
|
||||||
ghostClass: 'ghost'
|
|
||||||
},
|
|
||||||
metadataSourceData: {
|
|
||||||
folderStructure: {
|
|
||||||
id: 'folderStructure',
|
|
||||||
name: 'Folder structure',
|
|
||||||
include: true
|
|
||||||
},
|
|
||||||
audioMetatags: {
|
|
||||||
id: 'audioMetatags',
|
|
||||||
name: 'Audio file meta tags',
|
|
||||||
include: true
|
|
||||||
},
|
|
||||||
nfoFile: {
|
|
||||||
id: 'nfoFile',
|
|
||||||
name: 'NFO file',
|
|
||||||
include: true
|
|
||||||
},
|
|
||||||
txtFiles: {
|
|
||||||
id: 'txtFiles',
|
|
||||||
name: 'desc.txt & reader.txt files',
|
|
||||||
include: true
|
|
||||||
},
|
|
||||||
opfFile: {
|
|
||||||
id: 'opfFile',
|
|
||||||
name: 'OPF file',
|
|
||||||
include: true
|
|
||||||
},
|
|
||||||
absMetadata: {
|
|
||||||
id: 'absMetadata',
|
|
||||||
name: 'Audiobookshelf metadata file',
|
|
||||||
include: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
metadataSourceMapped: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
librarySettings() {
|
|
||||||
return this.library.settings || {}
|
|
||||||
},
|
|
||||||
mediaType() {
|
|
||||||
return this.library.mediaType
|
|
||||||
},
|
|
||||||
isBookLibrary() {
|
|
||||||
return this.mediaType === 'book'
|
|
||||||
},
|
|
||||||
firstActiveSourceIndex() {
|
|
||||||
return this.metadataSourceMapped.findIndex((source) => source.include)
|
|
||||||
},
|
|
||||||
lastActiveSourceIndex() {
|
|
||||||
return this.metadataSourceMapped.findLastIndex((source) => source.include)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getSourceIndex(source) {
|
|
||||||
const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()
|
|
||||||
return activeSources.findIndex((s) => s === source) + 1
|
|
||||||
},
|
|
||||||
resetToDefault() {
|
|
||||||
this.metadataSourceMapped = []
|
|
||||||
for (const key in this.metadataSourceData) {
|
|
||||||
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
|
|
||||||
}
|
|
||||||
this.metadataSourceMapped.reverse()
|
|
||||||
|
|
||||||
this.$emit('update', this.getLibraryData())
|
|
||||||
},
|
|
||||||
getLibraryData() {
|
|
||||||
const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
|
|
||||||
metadataSourceIds.reverse()
|
|
||||||
return {
|
|
||||||
settings: {
|
|
||||||
metadataPrecedence: metadataSourceIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
includeToggled(source) {
|
|
||||||
this.updated()
|
|
||||||
},
|
|
||||||
draggableUpdate() {
|
|
||||||
this.updated()
|
|
||||||
},
|
|
||||||
updated() {
|
|
||||||
this.$emit('update', this.getLibraryData())
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
|
||||||
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
|
||||||
|
|
||||||
for (const sourceKey in this.metadataSourceData) {
|
|
||||||
if (!metadataPrecedence.includes(sourceKey)) {
|
|
||||||
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
|
|
||||||
this.metadataSourceMapped.unshift(unusedSourceData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.metadataSourceMapped.reverse()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||||
<div class="flex items-center py-3">
|
<div class="flex items-center py-2">
|
||||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||||
<p class="pl-4 text-base">
|
<p class="pl-4 text-base">
|
||||||
@@ -11,44 +11,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="enableWatcher" @input="formUpdated" />
|
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" @input="formUpdated" />
|
||||||
<ui-toggle-switch v-else disabled :value="false" />
|
<ui-toggle-switch v-else disabled :value="false" />
|
||||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsEnableWatcherForLibrary }}</p>
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsDisableWatcherForLibrary }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isBookLibrary" class="flex items-center py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
|
||||||
<p class="pl-4 text-base">
|
|
||||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
|
||||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
<div v-if="isBookLibrary" class="py-3">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isBookLibrary" class="py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isBookLibrary" class="py-3">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
|
|
||||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
|
||||||
<p class="pl-4 text-base">
|
|
||||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
|
||||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
|
||||||
</p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -65,11 +45,9 @@ export default {
|
|||||||
return {
|
return {
|
||||||
provider: null,
|
provider: null,
|
||||||
useSquareBookCovers: false,
|
useSquareBookCovers: false,
|
||||||
enableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false
|
||||||
audiobooksOnly: false,
|
|
||||||
hideSingleBookSeries: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -82,9 +60,6 @@ export default {
|
|||||||
mediaType() {
|
mediaType() {
|
||||||
return this.library.mediaType
|
return this.library.mediaType
|
||||||
},
|
},
|
||||||
isBookLibrary() {
|
|
||||||
return this.mediaType === 'book'
|
|
||||||
},
|
|
||||||
providers() {
|
providers() {
|
||||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
@@ -95,11 +70,9 @@ export default {
|
|||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
|
||||||
disableWatcher: !this.enableWatcher,
|
disableWatcher: !!this.disableWatcher,
|
||||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||||
audiobooksOnly: !!this.audiobooksOnly,
|
|
||||||
hideSingleBookSeries: !!this.hideSingleBookSeries
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -108,11 +81,9 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
this.useSquareBookCovers = this.librarySettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
this.enableWatcher = !this.librarySettings.disableWatcher
|
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
|
||||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full h-full px-1 md:px-2 py-1 mb-4">
|
|
||||||
<div class="w-full border border-black-200 p-4 my-8">
|
|
||||||
<div class="flex flex-wrap items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-lg">Remove metadata files in library item folders</p>
|
|
||||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div>
|
|
||||||
<ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn>
|
|
||||||
<ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
library: {
|
|
||||||
type: Object,
|
|
||||||
default: () => null
|
|
||||||
},
|
|
||||||
libraryId: String,
|
|
||||||
processing: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
librarySettings() {
|
|
||||||
return this.library.settings || {}
|
|
||||||
},
|
|
||||||
mediaType() {
|
|
||||||
return this.library.mediaType
|
|
||||||
},
|
|
||||||
isBookLibrary() {
|
|
||||||
return this.mediaType === 'book'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
removeAllMetadataClick(ext) {
|
|
||||||
const payload = {
|
|
||||||
message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`,
|
|
||||||
persistent: true,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.removeAllMetadataInLibrary(ext)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
removeAllMetadataInLibrary(ext) {
|
|
||||||
this.$emit('update:processing', true)
|
|
||||||
this.$axios
|
|
||||||
.$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`)
|
|
||||||
.then((data) => {
|
|
||||||
if (!data.found) {
|
|
||||||
this.$toast.info(`No metadata.${ext} files were found in library`)
|
|
||||||
} else if (!data.removed) {
|
|
||||||
this.$toast.success(`No metadata.${ext} files removed`)
|
|
||||||
} else {
|
|
||||||
this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove metadata files', error)
|
|
||||||
this.$toast.error('Failed to remove metadata files')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.$emit('update:processing', false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="w-full px-3 py-5 md:p-12">
|
<div class="w-full px-3 py-5 md:p-12">
|
||||||
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
|
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
|
||||||
|
|
||||||
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
|
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
|
||||||
|
|
||||||
@@ -103,8 +103,6 @@ export default {
|
|||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
this.$refs.urlsInput?.forceBlur()
|
|
||||||
|
|
||||||
if (!this.newNotification.urls.length) {
|
if (!this.newNotification.urls.length) {
|
||||||
this.$toast.error('Must enter an Apprise URL')
|
this.$toast.error('Must enter an Apprise URL')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
<div v-if="episodesCleaned.length" class="w-full py-3 mx-auto flex">
|
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex flex-grow">
|
<form @submit.prevent="submit" class="flex flex-grow">
|
||||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||||
</form>
|
</form>
|
||||||
@@ -16,12 +16,12 @@
|
|||||||
v-for="(episode, index) in episodesList"
|
v-for="(episode, index) in episodesList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative"
|
class="relative"
|
||||||
:class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
:class="itemEpisodeMap[episode.enclosure.url?.split('?')[0]] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||||
@click="toggleSelectEpisode(episode)"
|
@click="toggleSelectEpisode(index, episode)"
|
||||||
>
|
>
|
||||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||||
<span v-if="getIsEpisodeDownloaded(episode)" class="material-icons text-success text-xl">download_done</span>
|
<span v-if="itemEpisodeMap[episode.enclosure.url?.split('?')[0]]" class="material-icons text-success text-xl">download_done</span>
|
||||||
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||||
</div>
|
</div>
|
||||||
<div class="px-8 py-2">
|
<div class="px-8 py-2">
|
||||||
<div class="flex items-center font-semibold text-gray-200">
|
<div class="flex items-center font-semibold text-gray-200">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||||
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||||
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
episodesCleaned: [],
|
|
||||||
selectedEpisodes: {},
|
selectedEpisodes: {},
|
||||||
selectAll: false,
|
selectAll: false,
|
||||||
search: null,
|
search: null,
|
||||||
@@ -93,109 +92,75 @@ export default {
|
|||||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||||
},
|
},
|
||||||
allDownloaded() {
|
allDownloaded() {
|
||||||
return !this.episodesCleaned.some((episode) => !this.getIsEpisodeDownloaded(episode))
|
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]])
|
||||||
},
|
},
|
||||||
episodesSelected() {
|
episodesSelected() {
|
||||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||||
},
|
},
|
||||||
buttonText() {
|
buttonText() {
|
||||||
if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
|
if (!this.episodesSelected.length) return 'No Episodes Selected'
|
||||||
if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
|
return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
|
||||||
return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
|
|
||||||
},
|
},
|
||||||
itemEpisodes() {
|
itemEpisodes() {
|
||||||
return this.libraryItem?.media.episodes || []
|
if (!this.libraryItem) return []
|
||||||
|
return this.libraryItem.media.episodes || []
|
||||||
|
},
|
||||||
|
itemEpisodeMap() {
|
||||||
|
var map = {}
|
||||||
|
this.itemEpisodes.forEach((item) => {
|
||||||
|
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
|
||||||
|
})
|
||||||
|
return map
|
||||||
},
|
},
|
||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodesCleaned.filter((episode) => {
|
return this.episodes.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
|
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||||
})
|
})
|
||||||
},
|
|
||||||
selectAllLabel() {
|
|
||||||
if (this.episodesList.length === this.episodesCleaned.length) {
|
|
||||||
return this.$strings.LabelSelectAllEpisodes
|
|
||||||
}
|
|
||||||
const episodesNotDownloaded = this.episodesList.filter((ep) => !this.getIsEpisodeDownloaded(ep)).length
|
|
||||||
return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getIsEpisodeDownloaded(episode) {
|
|
||||||
return this.itemEpisodes.some((downloadedEpisode) => {
|
|
||||||
if (episode.guid && downloadedEpisode.guid === episode.guid) return true
|
|
||||||
if (!downloadedEpisode.enclosure?.url) return false
|
|
||||||
return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl
|
|
||||||
})
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed.
|
|
||||||
* Fallback to checking the clean url
|
|
||||||
* @see https://github.com/advplyr/audiobookshelf/issues/2207
|
|
||||||
*
|
|
||||||
* RSS feed episode url is used for matching with existing downloaded episodes.
|
|
||||||
* Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
|
|
||||||
* These need to be removed in order to detect the same episode each time the feed is pulled.
|
|
||||||
*
|
|
||||||
* An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
|
|
||||||
* @see https://github.com/advplyr/audiobookshelf/issues/1896
|
|
||||||
*
|
|
||||||
* @param {string} url - rss feed episode url
|
|
||||||
* @returns {string} rss feed episode url without dynamic query strings
|
|
||||||
*/
|
|
||||||
getCleanEpisodeUrl(url) {
|
|
||||||
let queryString = url.split('?')[1]
|
|
||||||
if (!queryString) return url
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(queryString)
|
|
||||||
for (const p of Array.from(searchParams.keys())) {
|
|
||||||
if (p !== 'id') searchParams.delete(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchParams.toString()) return url
|
|
||||||
return `${url}?${searchParams.toString()}`
|
|
||||||
},
|
|
||||||
inputUpdate() {
|
inputUpdate() {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
this.searchTimeout = setTimeout(() => {
|
this.searchTimeout = setTimeout(() => {
|
||||||
if (!this.search?.trim()) {
|
if (!this.search || !this.search.trim()) {
|
||||||
this.searchText = ''
|
this.searchText = ''
|
||||||
this.checkSetIsSelectedAll()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.searchText = this.search.toLowerCase().trim()
|
this.searchText = this.search.toLowerCase().trim()
|
||||||
this.checkSetIsSelectedAll()
|
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
toggleSelectAll(val) {
|
toggleSelectAll(val) {
|
||||||
for (const episode of this.episodesList) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false
|
const episode = this.episodes[i]
|
||||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) this.selectedEpisodes[String(i)] = false
|
||||||
|
else this.$set(this.selectedEpisodes, String(i), val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
checkSetIsSelectedAll() {
|
checkSetIsSelectedAll() {
|
||||||
for (const episode of this.episodesList) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) {
|
const episode = this.episodes[i]
|
||||||
|
if (!this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]] && !this.selectedEpisodes[String(i)]) {
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.selectAll = true
|
this.selectAll = true
|
||||||
},
|
},
|
||||||
toggleSelectEpisode(episode) {
|
toggleSelectEpisode(index, episode) {
|
||||||
if (this.getIsEpisodeDownloaded(episode)) return
|
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
||||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||||
this.checkSetIsSelectedAll()
|
this.checkSetIsSelectedAll()
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
let episodesToDownload = []
|
var episodesToDownload = []
|
||||||
if (this.episodesSelected.length) {
|
if (this.episodesSelected.length) {
|
||||||
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
|
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadSize = JSON.stringify(episodesToDownload).length
|
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||||
const sizeInMb = payloadSize / 1024 / 1024
|
var sizeInMb = payloadSize / 1024 / 1024
|
||||||
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||||
console.log('Request size', sizeInMb)
|
console.log('Request size', sizeInMb)
|
||||||
if (sizeInMb > 4.99) {
|
if (sizeInMb > 4.99) {
|
||||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||||
@@ -210,24 +175,17 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||||
console.error('Failed to download episodes', error)
|
console.error('Failed to download episodes', error)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
this.$toast.error(error.response?.data || 'Failed to download episodes')
|
this.$toast.error(errorMsg)
|
||||||
|
|
||||||
this.selectedEpisodes = {}
|
this.selectedEpisodes = {}
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.episodesCleaned = this.episodes
|
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||||
.filter((ep) => ep.enclosure?.url)
|
|
||||||
.map((_ep) => {
|
|
||||||
return {
|
|
||||||
..._ep,
|
|
||||||
cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
|
||||||
this.selectAll = false
|
this.selectAll = false
|
||||||
this.selectedEpisodes = {}
|
this.selectedEpisodes = {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,9 @@
|
|||||||
<!-- mobile -->
|
<!-- mobile -->
|
||||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="enclosureUrl" class="pb-4 pt-6">
|
<div v-if="enclosureUrl" class="py-4">
|
||||||
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
|
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||||
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
|
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
||||||
</ui-text-input-with-label>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-4">
|
<div v-else class="py-4">
|
||||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
serverAddress: window.origin,
|
serverAddress: window.origin,
|
||||||
slug: this.newFeedSlug,
|
slug: this.newFeedSlug,
|
||||||
@@ -153,9 +151,6 @@ export default {
|
|||||||
const errorMsg = error.response ? error.response.data : null
|
const errorMsg = error.response ? error.response.data : null
|
||||||
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
copyToClipboard(str) {
|
copyToClipboard(str) {
|
||||||
this.$copyToClipboard(str, this)
|
this.$copyToClipboard(str, this)
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" name="rss-feed-view-modal" :processing="processing" :width="700" :height="'unset'">
|
|
||||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
|
||||||
<div v-if="feed" class="w-full">
|
|
||||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
|
||||||
|
|
||||||
<div class="w-full relative">
|
|
||||||
<ui-text-input v-model="feed.feedUrl" readonly />
|
|
||||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(feed.feedUrl)">content_copy</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="feed.meta" class="mt-5">
|
|
||||||
<div class="flex py-0.5">
|
|
||||||
<div class="w-48">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{ feed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="feed.meta.ownerName" class="flex py-0.5">
|
|
||||||
<div class="w-48">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{ feed.meta.ownerName }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="feed.meta.ownerEmail" class="flex py-0.5">
|
|
||||||
<div class="w-48">
|
|
||||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{ feed.meta.ownerEmail }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- -->
|
|
||||||
<div class="episodesTable mt-2">
|
|
||||||
<div class="bg-primary bg-opacity-40 h-12 header">
|
|
||||||
{{ $strings.LabelEpisodeTitle }}
|
|
||||||
</div>
|
|
||||||
<div class="scroller">
|
|
||||||
<div v-for="episode in feed.episodes" :key="episode.id" class="h-8 text-xs truncate">
|
|
||||||
{{ episode.title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
feed: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_feed() {
|
|
||||||
return this.feed || {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
copyToClipboard(str) {
|
|
||||||
this.$copyToClipboard(str, this)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.episodesTable {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
border: 1px solid #474747;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodesTable div.header {
|
|
||||||
background-color: #272727;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodesTable .scroller {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodesTable .scroller div {
|
|
||||||
background-color: #373838;
|
|
||||||
padding: 4px 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
height: 32px;
|
|
||||||
flex: 0 0 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episodesTable .scroller div:nth-child(even) {
|
|
||||||
background-color: #2f2f2f;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@@ -38,8 +38,7 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
}
|
||||||
playbackRate: Number
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,10 +63,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
_playbackRate() {
|
|
||||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
|
||||||
return this.playbackRate
|
|
||||||
},
|
|
||||||
currentChapterDuration() {
|
currentChapterDuration() {
|
||||||
if (!this.currentChapter) return 0
|
if (!this.currentChapter) return 0
|
||||||
return this.currentChapter.end - this.currentChapter.start
|
return this.currentChapter.end - this.currentChapter.start
|
||||||
@@ -86,8 +81,8 @@ export default {
|
|||||||
clickTrack(e) {
|
clickTrack(e) {
|
||||||
if (this.loading) return
|
if (this.loading) return
|
||||||
|
|
||||||
const offsetX = e.offsetX
|
var offsetX = e.offsetX
|
||||||
const perc = offsetX / this.trackWidth
|
var perc = offsetX / this.trackWidth
|
||||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
const time = baseTime + perc * duration
|
const time = baseTime + perc * duration
|
||||||
@@ -116,7 +111,7 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
const widthReady = Math.round(this.trackWidth * this.percentReady)
|
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||||
if (this.readyTrackWidth === widthReady) return
|
if (this.readyTrackWidth === widthReady) return
|
||||||
this.readyTrackWidth = widthReady
|
this.readyTrackWidth = widthReady
|
||||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||||
@@ -129,7 +124,7 @@ export default {
|
|||||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
|
|
||||||
const ptWidth = Math.round((time / duration) * this.trackWidth)
|
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||||
if (this.playedTrackWidth === ptWidth) {
|
if (this.playedTrackWidth === ptWidth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -138,7 +133,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setChapterTicks() {
|
setChapterTicks() {
|
||||||
this.chapterTicks = this.chapters.map((chap) => {
|
this.chapterTicks = this.chapters.map((chap) => {
|
||||||
const perc = chap.start / this.duration
|
var perc = chap.start / this.duration
|
||||||
return {
|
return {
|
||||||
title: chap.title,
|
title: chap.title,
|
||||||
left: perc * this.trackWidth
|
left: perc * this.trackWidth
|
||||||
@@ -146,7 +141,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
mousemoveTrack(e) {
|
mousemoveTrack(e) {
|
||||||
const offsetX = e.offsetX
|
var offsetX = e.offsetX
|
||||||
|
|
||||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||||
@@ -172,7 +167,7 @@ export default {
|
|||||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||||
}
|
}
|
||||||
if (this.$refs.hoverTimestampText) {
|
if (this.$refs.hoverTimestampText) {
|
||||||
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
|
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||||
|
|
||||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||||
if (chapter && chapter.title) {
|
if (chapter && chapter.title) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
|
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,11 +92,6 @@ export default {
|
|||||||
useChapterTrack: false
|
useChapterTrack: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
playbackRate() {
|
|
||||||
this.updateTimestamp()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
sleepTimerRemainingString() {
|
sleepTimerRemainingString() {
|
||||||
var rounded = Math.round(this.sleepTimerRemaining)
|
var rounded = Math.round(this.sleepTimerRemaining)
|
||||||
@@ -218,14 +213,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
increasePlaybackRate() {
|
increasePlaybackRate() {
|
||||||
if (this.playbackRate >= 10) return
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
this.setPlaybackRate(this.playbackRate)
|
if (currentRateIndex >= rates.length - 1) return
|
||||||
|
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
},
|
},
|
||||||
decreasePlaybackRate() {
|
decreasePlaybackRate() {
|
||||||
if (this.playbackRate <= 0.5) return
|
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||||
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||||
this.setPlaybackRate(this.playbackRate)
|
if (currentRateIndex <= 0) return
|
||||||
|
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||||
|
this.playbackRateChanged(this.playbackRate)
|
||||||
},
|
},
|
||||||
setPlaybackRate(playbackRate) {
|
setPlaybackRate(playbackRate) {
|
||||||
this.$emit('setPlaybackRate', playbackRate)
|
this.$emit('setPlaybackRate', playbackRate)
|
||||||
@@ -290,13 +289,14 @@ export default {
|
|||||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||||
},
|
},
|
||||||
updateTimestamp() {
|
updateTimestamp() {
|
||||||
const ts = this.$refs.currentTimestamp
|
var ts = this.$refs.currentTimestamp
|
||||||
if (!ts) {
|
if (!ts) {
|
||||||
console.error('No timestamp el')
|
console.error('No timestamp el')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||||
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
var currTimeClean = this.$secondsToTimestamp(time)
|
||||||
|
ts.innerText = currTimeClean
|
||||||
},
|
},
|
||||||
setBufferTime(bufferTime) {
|
setBufferTime(bufferTime) {
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||||
@@ -312,7 +312,7 @@ export default {
|
|||||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||||
|
|
||||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||||
this.setPlaybackRate(this.playbackRate)
|
this.$emit('setPlaybackRate', this.playbackRate)
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||||
|
|||||||
@@ -3,14 +3,11 @@
|
|||||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||||
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
|
||||||
|
|
||||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
|
||||||
|
|
||||||
<div class="flex px-1 items-center">
|
<div class="flex px-1 items-center">
|
||||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
|
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
|
||||||
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,8 +21,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
el: null,
|
el: null,
|
||||||
content: null,
|
content: null
|
||||||
checkboxValue: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -61,18 +57,6 @@ export default {
|
|||||||
persistent() {
|
persistent() {
|
||||||
return !!this.confirmPromptOptions.persistent
|
return !!this.confirmPromptOptions.persistent
|
||||||
},
|
},
|
||||||
checkboxLabel() {
|
|
||||||
return this.confirmPromptOptions.checkboxLabel
|
|
||||||
},
|
|
||||||
yesButtonText() {
|
|
||||||
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
|
|
||||||
},
|
|
||||||
yesButtonColor() {
|
|
||||||
return this.confirmPromptOptions.yesButtonColor || 'success'
|
|
||||||
},
|
|
||||||
checkboxDefaultValue() {
|
|
||||||
return !!this.confirmPromptOptions.checkboxDefaultValue
|
|
||||||
},
|
|
||||||
isYesNo() {
|
isYesNo() {
|
||||||
return this.type === 'yesNo'
|
return this.type === 'yesNo'
|
||||||
},
|
},
|
||||||
@@ -100,11 +84,10 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
confirm() {
|
confirm() {
|
||||||
if (this.callback) this.callback(true, this.checkboxValue)
|
if (this.callback) this.callback(true)
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
setShow() {
|
setShow() {
|
||||||
this.checkboxValue = this.checkboxDefaultValue
|
|
||||||
this.$eventBus.$emit('showing-prompt', true)
|
this.$eventBus.$emit('showing-prompt', true)
|
||||||
document.body.appendChild(this.el)
|
document.body.appendChild(this.el)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
|
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||||
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
|
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
||||||
<p class="text-sm truncate">{{ file }}</p>
|
<p class="text-sm truncate">{{ file }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
<strong>{{ key }}</strong
|
<strong>{{ key }}</strong
|
||||||
@@ -14,38 +14,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
|
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||||
<span class="material-icons text-xl">download</span>
|
|
||||||
</a>
|
|
||||||
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
|
|
||||||
<span class="material-icons text-xl">more</span>
|
<span class="material-icons text-xl">more</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
|
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||||
<span class="material-icons text-xl">menu</span>
|
<span class="material-icons text-xl">menu</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden w-full h-full relative">
|
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2">
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex justify-center">
|
<div class="h-full flex justify-center">
|
||||||
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
|
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -60,13 +61,7 @@ Archive.init({
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
url: String
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
playerOpen: Boolean,
|
|
||||||
keepProgress: Boolean,
|
|
||||||
fileId: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,7 +71,6 @@ export default {
|
|||||||
mainImg: null,
|
mainImg: null,
|
||||||
page: 0,
|
page: 0,
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
pageMenuWidth: 256,
|
|
||||||
showPageMenu: false,
|
showPageMenu: false,
|
||||||
showInfoMenu: false,
|
showInfoMenu: false,
|
||||||
loadTimeout: null,
|
loadTimeout: null,
|
||||||
@@ -93,79 +87,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem?.id
|
|
||||||
},
|
|
||||||
ebookUrl() {
|
|
||||||
if (this.fileId) {
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
|
||||||
}
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
|
||||||
},
|
|
||||||
comicMetadataKeys() {
|
comicMetadataKeys() {
|
||||||
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages
|
return this.page < this.numPages - 1
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 1
|
return this.page > 0
|
||||||
},
|
|
||||||
userMediaProgress() {
|
|
||||||
if (!this.libraryItemId) return
|
|
||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
|
||||||
},
|
|
||||||
savedPage() {
|
|
||||||
if (!this.keepProgress) return 0
|
|
||||||
|
|
||||||
// Validate ebookLocation is a number
|
|
||||||
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
|
|
||||||
return Number(this.userMediaProgress.ebookLocation)
|
|
||||||
},
|
|
||||||
cleanedPageNames() {
|
|
||||||
return (
|
|
||||||
this.pages?.map((p) => {
|
|
||||||
if (p.length > 50) {
|
|
||||||
let firstHalf = p.slice(0, 22)
|
|
||||||
let lastHalf = p.slice(p.length - 23)
|
|
||||||
return `${firstHalf} ... ${lastHalf}`
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}) || []
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickShowPageMenu() {
|
|
||||||
this.showInfoMenu = false
|
|
||||||
this.showPageMenu = !this.showPageMenu
|
|
||||||
},
|
|
||||||
clickShowInfoMenu() {
|
|
||||||
this.showPageMenu = false
|
|
||||||
this.showInfoMenu = !this.showInfoMenu
|
|
||||||
},
|
|
||||||
updateProgress() {
|
|
||||||
if (!this.keepProgress) return
|
|
||||||
|
|
||||||
if (!this.numPages) {
|
|
||||||
console.error('Num pages not loaded')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.savedPage === this.page) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ebookLocation: this.page,
|
|
||||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
|
||||||
}
|
|
||||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
|
||||||
console.error('ComicReader.updateProgress failed:', error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
clickOutside() {
|
clickOutside() {
|
||||||
if (this.showPageMenu) this.showPageMenu = false
|
if (this.showPageMenu) this.showPageMenu = false
|
||||||
if (this.showInfoMenu) this.showInfoMenu = false
|
if (this.showInfoMenu) this.showInfoMenu = false
|
||||||
@@ -178,15 +110,12 @@ export default {
|
|||||||
if (!this.canGoPrev) return
|
if (!this.canGoPrev) return
|
||||||
this.setPage(this.page - 1)
|
this.setPage(this.page - 1)
|
||||||
},
|
},
|
||||||
setPage(page) {
|
setPage(index) {
|
||||||
if (page <= 0 || page > this.numPages) {
|
if (index < 0 || index > this.numPages - 1) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.showPageMenu = false
|
var filename = this.pages[index]
|
||||||
this.showInfoMenu = false
|
this.page = index
|
||||||
const filename = this.pages[page - 1]
|
|
||||||
this.page = page
|
|
||||||
this.updateProgress()
|
|
||||||
return this.extractFile(filename)
|
return this.extractFile(filename)
|
||||||
},
|
},
|
||||||
setLoadTimeout() {
|
setLoadTimeout() {
|
||||||
@@ -216,11 +145,10 @@ export default {
|
|||||||
},
|
},
|
||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
console.log('Extracting', this.url)
|
||||||
responseType: 'blob',
|
|
||||||
headers: {
|
var buff = await this.$axios.$get(this.url, {
|
||||||
Authorization: `Bearer ${this.userToken}`
|
responseType: 'blob'
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
const originalFilesObject = await archive.getFilesObject()
|
const originalFilesObject = await archive.getFilesObject()
|
||||||
@@ -236,28 +164,9 @@ export default {
|
|||||||
|
|
||||||
this.numPages = this.pages.length
|
this.numPages = this.pages.length
|
||||||
|
|
||||||
// Calculate page menu size
|
|
||||||
const largestFilename = this.cleanedPageNames
|
|
||||||
.map((p) => p)
|
|
||||||
.sort((a, b) => a.length - b.length)
|
|
||||||
.pop()
|
|
||||||
const pEl = document.createElement('p')
|
|
||||||
pEl.innerText = largestFilename
|
|
||||||
pEl.style.fontSize = '0.875rem'
|
|
||||||
pEl.style.opacity = 0
|
|
||||||
pEl.style.position = 'absolute'
|
|
||||||
document.body.appendChild(pEl)
|
|
||||||
const textWidth = pEl.getBoundingClientRect()?.width
|
|
||||||
if (textWidth) {
|
|
||||||
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
|
|
||||||
}
|
|
||||||
pEl.remove()
|
|
||||||
|
|
||||||
if (this.pages.length) {
|
if (this.pages.length) {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
await this.setPage(0)
|
||||||
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
|
|
||||||
await this.setPage(startPage)
|
|
||||||
this.loadedFirstPage = true
|
this.loadedFirstPage = true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.error('Unable to extract pages')
|
this.$toast.error('Unable to extract pages')
|
||||||
@@ -303,8 +212,8 @@ export default {
|
|||||||
},
|
},
|
||||||
parseImageFilename(filename) {
|
parseImageFilename(filename) {
|
||||||
var basename = Path.basename(filename, Path.extname(filename))
|
var basename = Path.basename(filename, Path.extname(filename))
|
||||||
var numbersinpath = basename.match(/\d+/g)
|
var numbersinpath = basename.match(/\d{1,5}/g)
|
||||||
if (!numbersinpath?.length) {
|
if (!numbersinpath || !numbersinpath.length) {
|
||||||
return {
|
return {
|
||||||
index: -1,
|
index: -1,
|
||||||
filename
|
filename
|
||||||
@@ -340,6 +249,15 @@ export default {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pagemenu {
|
.pagemenu {
|
||||||
max-height: calc(100% - 48px);
|
max-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
.comicimg {
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.comicwrapper {
|
||||||
|
width: 100vw;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="epub-reader" class="h-full w-full">
|
<div class="h-full w-full">
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center">
|
||||||
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
|
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
|
||||||
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||||
</button>
|
</div>
|
||||||
<div id="frame" class="w-full" style="height: 80%">
|
<div id="frame" class="w-full" style="height: 650px">
|
||||||
<div id="viewer"></div>
|
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
||||||
|
|
||||||
|
<div class="py-4 flex justify-center" style="height: 50px">
|
||||||
|
<p>{{ progress }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
|
||||||
|
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
|
|
||||||
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -17,431 +21,109 @@
|
|||||||
<script>
|
<script>
|
||||||
import ePub from 'epubjs'
|
import ePub from 'epubjs'
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {object} EpubReader
|
|
||||||
* @property {ePub.Book} book
|
|
||||||
* @property {ePub.Rendition} rendition
|
|
||||||
*/
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
url: String
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
playerOpen: Boolean,
|
|
||||||
keepProgress: Boolean,
|
|
||||||
fileId: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowWidth: 0,
|
|
||||||
windowHeight: 0,
|
|
||||||
/** @type {ePub.Book} */
|
|
||||||
book: null,
|
book: null,
|
||||||
/** @type {ePub.Rendition} */
|
|
||||||
rendition: null,
|
rendition: null,
|
||||||
chapters: [],
|
chapters: [],
|
||||||
ereaderSettings: {
|
title: '',
|
||||||
theme: 'dark',
|
author: '',
|
||||||
font: 'serif',
|
progress: 0,
|
||||||
fontScale: 100,
|
hasNext: true,
|
||||||
lineSpacing: 115,
|
hasPrev: false
|
||||||
spread: 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
playerOpen() {
|
|
||||||
this.resize()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
/** @returns {string} */
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem?.id
|
|
||||||
},
|
|
||||||
hasPrev() {
|
|
||||||
return !this.rendition?.location?.atStart
|
|
||||||
},
|
|
||||||
hasNext() {
|
|
||||||
return !this.rendition?.location?.atEnd
|
|
||||||
},
|
|
||||||
userMediaProgress() {
|
|
||||||
if (!this.libraryItemId) return
|
|
||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
|
||||||
},
|
|
||||||
savedEbookLocation() {
|
|
||||||
if (!this.keepProgress) return null
|
|
||||||
if (!this.userMediaProgress?.ebookLocation) return null
|
|
||||||
// Validate ebookLocation is an epubcfi
|
|
||||||
if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
|
|
||||||
return this.userMediaProgress.ebookLocation
|
|
||||||
},
|
|
||||||
localStorageLocationsKey() {
|
|
||||||
return `ebookLocations-${this.libraryItemId}`
|
|
||||||
},
|
|
||||||
readerWidth() {
|
|
||||||
if (this.windowWidth < 640) return this.windowWidth
|
|
||||||
return this.windowWidth - 200
|
|
||||||
},
|
|
||||||
readerHeight() {
|
|
||||||
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
|
|
||||||
return this.windowHeight - 164
|
|
||||||
},
|
|
||||||
ebookUrl() {
|
|
||||||
if (this.fileId) {
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
|
||||||
}
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
|
||||||
},
|
|
||||||
themeRules() {
|
|
||||||
const isDark = this.ereaderSettings.theme === 'dark'
|
|
||||||
const fontColor = isDark ? '#fff' : '#000'
|
|
||||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
|
||||||
|
|
||||||
const lineSpacing = this.ereaderSettings.lineSpacing / 100
|
|
||||||
|
|
||||||
const fontScale = this.ereaderSettings.fontScale / 100
|
|
||||||
|
|
||||||
return {
|
|
||||||
'*': {
|
|
||||||
color: `${fontColor}!important`,
|
|
||||||
'background-color': `${backgroundColor}!important`,
|
|
||||||
'line-height': lineSpacing * fontScale + 'rem!important'
|
|
||||||
},
|
|
||||||
a: {
|
|
||||||
color: `${fontColor}!important`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
updateSettings(settings) {
|
changedChapter() {
|
||||||
this.ereaderSettings = settings
|
if (this.rendition) {
|
||||||
|
this.rendition.display(this.selectedChapter)
|
||||||
if (!this.rendition) return
|
}
|
||||||
|
|
||||||
this.applyTheme()
|
|
||||||
|
|
||||||
const fontScale = settings.fontScale || 100
|
|
||||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
|
||||||
this.rendition.themes.font(settings.font)
|
|
||||||
this.rendition.spread(settings.spread || 'auto')
|
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
if (!this.rendition?.manager) return
|
if (this.rendition) {
|
||||||
return this.rendition?.prev()
|
this.rendition.prev()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
if (!this.rendition?.manager) return
|
if (this.rendition) {
|
||||||
return this.rendition?.next()
|
this.rendition.next()
|
||||||
},
|
|
||||||
goToChapter(href) {
|
|
||||||
if (!this.rendition?.manager) return
|
|
||||||
return this.rendition?.display(href)
|
|
||||||
},
|
|
||||||
/** @returns {object} Returns the chapter that the `position` in the book is in */
|
|
||||||
findChapterFromPosition(chapters, position) {
|
|
||||||
let foundChapter
|
|
||||||
for (let i = 0; i < chapters.length; i++) {
|
|
||||||
if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) {
|
|
||||||
foundChapter = chapters[i]
|
|
||||||
if (chapters[i].subitems && chapters[i].subitems.length > 0) {
|
|
||||||
return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter)
|
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return foundChapter
|
|
||||||
},
|
},
|
||||||
/** @returns {Array} Returns an array of chapters that only includes chapters with query results */
|
keyUp() {
|
||||||
async searchBook(query) {
|
|
||||||
const chapters = structuredClone(await this.chapters)
|
|
||||||
const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item))))
|
|
||||||
const mergedResults = [].concat(...searchResults)
|
|
||||||
|
|
||||||
mergedResults.forEach((chapter) => {
|
|
||||||
chapter.start = this.book.locations.percentageFromCfi(chapter.cfi)
|
|
||||||
const foundChapter = this.findChapterFromPosition(chapters, chapter.start)
|
|
||||||
if (foundChapter) foundChapter.searchResults.push(chapter)
|
|
||||||
})
|
|
||||||
|
|
||||||
let filteredResults = chapters.filter(function f(o) {
|
|
||||||
if (o.searchResults.length) return true
|
|
||||||
if (o.subitems.length) {
|
|
||||||
return (o.subitems = o.subitems.filter(f)).length
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return filteredResults
|
|
||||||
},
|
|
||||||
keyUp(e) {
|
|
||||||
const rtl = this.book.package.metadata.direction === 'rtl'
|
|
||||||
if ((e.keyCode || e.which) == 37) {
|
if ((e.keyCode || e.which) == 37) {
|
||||||
return rtl ? this.next() : this.prev()
|
this.prev()
|
||||||
} else if ((e.keyCode || e.which) == 39) {
|
} else if ((e.keyCode || e.which) == 39) {
|
||||||
return rtl ? this.prev() : this.next()
|
this.next()
|
||||||
}
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* @param {object} payload
|
|
||||||
* @param {string} payload.ebookLocation - CFI of the current location
|
|
||||||
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
|
||||||
*/
|
|
||||||
updateProgress(payload) {
|
|
||||||
if (!this.keepProgress) return
|
|
||||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
|
||||||
console.error('EpubReader.updateProgress failed:', error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getAllEbookLocationData() {
|
|
||||||
const locations = []
|
|
||||||
let totalSize = 0 // Total in bytes
|
|
||||||
|
|
||||||
for (const key in localStorage) {
|
|
||||||
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ebookLocations = JSON.parse(localStorage[key])
|
|
||||||
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
|
||||||
|
|
||||||
ebookLocations.key = key
|
|
||||||
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
|
||||||
locations.push(ebookLocations)
|
|
||||||
totalSize += ebookLocations.size
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse ebook locations', key, error)
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by oldest lastAccessed first
|
|
||||||
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
|
||||||
|
|
||||||
return {
|
|
||||||
locations,
|
|
||||||
totalSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** @param {string} locationString */
|
|
||||||
checkSaveLocations(locationString) {
|
|
||||||
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
|
||||||
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
|
||||||
|
|
||||||
// Too large overall
|
|
||||||
if (newLocationsSize > maxSizeInBytes) {
|
|
||||||
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ebookLocationsData = this.getAllEbookLocationData()
|
|
||||||
|
|
||||||
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
|
||||||
|
|
||||||
// Remove epub locations until there is room for locations
|
|
||||||
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
|
||||||
const oldestLocation = ebookLocationsData.locations.shift()
|
|
||||||
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
|
||||||
availableSpace += oldestLocation.size
|
|
||||||
localStorage.removeItem(oldestLocation.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
|
||||||
this.saveLocations(locationString)
|
|
||||||
},
|
|
||||||
/** @param {string} locationString */
|
|
||||||
saveLocations(locationString) {
|
|
||||||
localStorage.setItem(
|
|
||||||
this.localStorageLocationsKey,
|
|
||||||
JSON.stringify({
|
|
||||||
lastAccessed: Date.now(),
|
|
||||||
locations: locationString
|
|
||||||
})
|
|
||||||
)
|
|
||||||
},
|
|
||||||
loadLocations() {
|
|
||||||
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
|
||||||
if (!locationsObjString) return null
|
|
||||||
|
|
||||||
const locationsObject = JSON.parse(locationsObjString)
|
|
||||||
|
|
||||||
// Remove invalid location objects
|
|
||||||
if (!locationsObject.locations) {
|
|
||||||
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
|
||||||
localStorage.removeItem(this.localStorageLocationsKey)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update lastAccessed
|
|
||||||
this.saveLocations(locationsObject.locations)
|
|
||||||
|
|
||||||
return locationsObject.locations
|
|
||||||
},
|
|
||||||
/** @param {string} location - CFI of the new location */
|
|
||||||
relocated(location) {
|
|
||||||
if (this.savedEbookLocation === location.start.cfi) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location.end.percentage) {
|
|
||||||
this.updateProgress({
|
|
||||||
ebookLocation: location.start.cfi,
|
|
||||||
ebookProgress: location.end.percentage
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.updateProgress({
|
|
||||||
ebookLocation: location.start.cfi
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initEpub() {
|
initEpub() {
|
||||||
/** @type {EpubReader} */
|
// var book = ePub(this.url, {
|
||||||
const reader = this
|
// requestHeaders: {
|
||||||
|
// Authorization: `Bearer ${this.userToken}`
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
var book = ePub(this.url)
|
||||||
|
this.book = book
|
||||||
|
|
||||||
/** @type {ePub.Book} */
|
this.rendition = book.renderTo('viewer', {
|
||||||
reader.book = new ePub(reader.ebookUrl, {
|
width: window.innerWidth - 200,
|
||||||
width: this.readerWidth,
|
height: 600,
|
||||||
height: this.readerHeight - 50,
|
ignoreClass: 'annotator-hl',
|
||||||
openAs: 'epub',
|
|
||||||
requestHeaders: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** @type {ePub.Rendition} */
|
|
||||||
reader.rendition = reader.book.renderTo('viewer', {
|
|
||||||
width: this.readerWidth,
|
|
||||||
height: this.readerHeight * 0.8,
|
|
||||||
spread: 'auto',
|
|
||||||
snap: true,
|
|
||||||
manager: 'continuous',
|
manager: 'continuous',
|
||||||
flow: 'paginated'
|
spread: 'always'
|
||||||
})
|
})
|
||||||
|
var displayed = this.rendition.display()
|
||||||
|
|
||||||
// load saved progress
|
book.ready
|
||||||
reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
|
.then(() => {
|
||||||
|
console.log('Book ready')
|
||||||
reader.rendition.on('rendered', () => {
|
return book.locations.generate(1600)
|
||||||
this.applyTheme()
|
|
||||||
})
|
})
|
||||||
|
.then((locations) => {
|
||||||
reader.book.ready.then(() => {
|
// console.log('Loaded locations', locations)
|
||||||
// set up event listeners
|
// Wait for book to be rendered to get current page
|
||||||
reader.rendition.on('relocated', reader.relocated)
|
displayed.then(() => {
|
||||||
reader.rendition.on('keydown', reader.keyUp)
|
// Get the current CFI
|
||||||
|
var currentLocation = this.rendition.currentLocation()
|
||||||
reader.rendition.on('touchstart', (event) => {
|
if (!currentLocation.start) {
|
||||||
this.$emit('touchstart', event)
|
console.error('No Start', currentLocation)
|
||||||
})
|
|
||||||
reader.rendition.on('touchend', (event) => {
|
|
||||||
this.$emit('touchend', event)
|
|
||||||
})
|
|
||||||
|
|
||||||
// load ebook cfi locations
|
|
||||||
const savedLocations = this.loadLocations()
|
|
||||||
if (savedLocations) {
|
|
||||||
reader.book.locations.load(savedLocations)
|
|
||||||
} else {
|
} else {
|
||||||
reader.book.locations.generate().then(() => {
|
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||||
this.checkSaveLocations(reader.book.locations.save())
|
// console.log('current page', currentPage)
|
||||||
})
|
|
||||||
}
|
|
||||||
this.getChapters()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getChapters() {
|
|
||||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
|
||||||
const toc = this.book?.navigation?.toc || []
|
|
||||||
|
|
||||||
const tocTree = []
|
|
||||||
|
|
||||||
const resolveURL = (url, relativeTo) => {
|
|
||||||
// see https://github.com/futurepress/epub.js/issues/1084
|
|
||||||
// HACK-ish: abuse the URL API a little to resolve the path
|
|
||||||
// the base needs to be a valid URL, or it will throw a TypeError,
|
|
||||||
// so we just set a random base URI and remove it later
|
|
||||||
const base = 'https://example.invalid/'
|
|
||||||
return new URL(url, base + relativeTo).href.replace(base, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath
|
|
||||||
|
|
||||||
const createTree = async (toc, parent) => {
|
|
||||||
const promises = toc.map(async (tocItem, i) => {
|
|
||||||
const href = resolveURL(tocItem.href, basePath)
|
|
||||||
const id = href.split('#')[1]
|
|
||||||
const item = this.book.spine.get(href)
|
|
||||||
await item.load(this.book.load.bind(this.book))
|
|
||||||
const el = id ? item.document.getElementById(id) : item.document.body
|
|
||||||
|
|
||||||
const cfi = item.cfiFromElement(el)
|
|
||||||
|
|
||||||
parent[i] = {
|
|
||||||
title: tocItem.label.trim(),
|
|
||||||
subitems: [],
|
|
||||||
href,
|
|
||||||
cfi,
|
|
||||||
start: this.book.locations.percentageFromCfi(cfi),
|
|
||||||
end: null, // set by flattenChapters()
|
|
||||||
id: null, // set by flattenChapters()
|
|
||||||
searchResults: []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tocItem.subitems) {
|
|
||||||
await createTree(tocItem.subitems, parent[i].subitems)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(promises)
|
|
||||||
}
|
|
||||||
return createTree(toc, tocTree).then(() => {
|
|
||||||
this.chapters = tocTree
|
|
||||||
})
|
})
|
||||||
},
|
|
||||||
flattenChapters(chapters) {
|
|
||||||
// Convert the nested epub chapters into something that looks like audiobook chapters for player-ui
|
|
||||||
const unwrap = (chapters) => {
|
|
||||||
return chapters.reduce((acc, chapter) => {
|
|
||||||
return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter]
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
let flattenedChapters = unwrap(chapters)
|
|
||||||
|
|
||||||
flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start)
|
book.loaded.navigation.then((toc) => {
|
||||||
for (let i = 0; i < flattenedChapters.length; i++) {
|
var _chapters = []
|
||||||
flattenedChapters[i].id = i
|
toc.forEach((chapter) => {
|
||||||
if (i < flattenedChapters.length - 1) {
|
_chapters.push(chapter)
|
||||||
flattenedChapters[i].end = flattenedChapters[i + 1].start
|
})
|
||||||
} else {
|
this.chapters = _chapters
|
||||||
flattenedChapters[i].end = 1
|
})
|
||||||
}
|
book.loaded.metadata.then((metadata) => {
|
||||||
}
|
this.author = metadata.creator
|
||||||
return flattenedChapters
|
this.title = metadata.title
|
||||||
},
|
})
|
||||||
resize() {
|
|
||||||
this.windowWidth = window.innerWidth
|
this.rendition.on('keyup', this.keyUp)
|
||||||
this.windowHeight = window.innerHeight
|
|
||||||
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
|
this.rendition.on('relocated', (location) => {
|
||||||
},
|
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||||
applyTheme() {
|
this.progress = Math.floor(percent * 100)
|
||||||
if (!this.rendition) return
|
|
||||||
this.rendition.getContents().forEach((c) => {
|
this.hasNext = !location.atEnd
|
||||||
c.addStylesheetRules(this.themeRules)
|
this.hasPrev = !location.atStart
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.windowWidth = window.innerWidth
|
|
||||||
this.windowHeight = window.innerHeight
|
|
||||||
window.addEventListener('resize', this.resize)
|
|
||||||
this.initEpub()
|
this.initEpub()
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
window.removeEventListener('resize', this.resize)
|
|
||||||
this.book?.destroy()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full w-full">
|
||||||
|
<div id="viewer" class="border border-gray-100 bg-white text-black shadow-md h-screen overflow-y-auto p-4" v-html="pageHtml"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
url: String,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
bookInfo: {},
|
||||||
|
page: 0,
|
||||||
|
numPages: 0,
|
||||||
|
pageHtml: '',
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
|
},
|
||||||
|
hasPrev() {
|
||||||
|
return this.page > 0
|
||||||
|
},
|
||||||
|
hasNext() {
|
||||||
|
return this.page < this.numPages - 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
prev() {
|
||||||
|
if (!this.hasPrev) return
|
||||||
|
this.page--
|
||||||
|
this.loadPage()
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
if (!this.hasNext) return
|
||||||
|
this.page++
|
||||||
|
this.loadPage()
|
||||||
|
},
|
||||||
|
keyUp() {
|
||||||
|
if ((e.keyCode || e.which) == 37) {
|
||||||
|
this.prev()
|
||||||
|
} else if ((e.keyCode || e.which) == 39) {
|
||||||
|
this.next()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadPage() {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/ebooks/${this.libraryItemId}/page/${this.page}?dev=${this.$isDev ? 1 : 0}`)
|
||||||
|
.then((html) => {
|
||||||
|
this.pageHtml = html
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load page', error)
|
||||||
|
this.$toast.error('Failed to load page')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
loadInfo() {
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/ebooks/${this.libraryItemId}/info?dev=${this.$isDev ? 1 : 0}`)
|
||||||
|
.then((bookInfo) => {
|
||||||
|
this.bookInfo = bookInfo
|
||||||
|
this.numPages = bookInfo.pages
|
||||||
|
this.page = 0
|
||||||
|
this.loadPage()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load page', error)
|
||||||
|
this.$toast.error('Failed to load info')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
initEpub() {
|
||||||
|
if (!this.libraryItemId) return
|
||||||
|
this.loadInfo()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.initEpub()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<div class="h-full max-h-full w-full">
|
<div class="h-full max-h-full w-full">
|
||||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||||
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,30 +15,12 @@ import defaultCss from '@/assets/ebooks/basic.js'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
url: String
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
playerOpen: Boolean,
|
|
||||||
fileId: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem?.id
|
|
||||||
},
|
|
||||||
ebookUrl() {
|
|
||||||
if (this.fileId) {
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
|
||||||
}
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
addHtmlCss() {
|
addHtmlCss() {
|
||||||
let iframe = document.getElementsByTagName('iframe')[0]
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
@@ -96,11 +78,8 @@ export default {
|
|||||||
},
|
},
|
||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
var buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
|
|||||||
@@ -11,19 +11,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
|
||||||
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
|
||||||
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,26 +30,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import pdf from '@teckel/vue-pdf'
|
import pdf from 'vue-pdf'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
pdf
|
pdf
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
url: String
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
playerOpen: Boolean,
|
|
||||||
keepProgress: Boolean,
|
|
||||||
fileId: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowWidth: 0,
|
|
||||||
windowHeight: 0,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
rotate: 0,
|
||||||
loadedRatio: 0,
|
loadedRatio: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -61,121 +48,35 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem?.id
|
|
||||||
},
|
|
||||||
fitToPageWidth() {
|
|
||||||
return this.pdfHeight * 0.6
|
|
||||||
},
|
|
||||||
pdfWidth() {
|
pdfWidth() {
|
||||||
return this.fitToPageWidth * this.scale
|
return this.pdfHeight * 0.6667
|
||||||
},
|
},
|
||||||
pdfHeight() {
|
pdfHeight() {
|
||||||
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
|
return window.innerHeight - 120
|
||||||
return this.windowHeight - 284
|
|
||||||
},
|
|
||||||
maxScale() {
|
|
||||||
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
|
|
||||||
},
|
},
|
||||||
canGoNext() {
|
canGoNext() {
|
||||||
return this.page < this.numPages
|
return this.page < this.numPages
|
||||||
},
|
},
|
||||||
canGoPrev() {
|
canGoPrev() {
|
||||||
return this.page > 1
|
return this.page > 1
|
||||||
},
|
|
||||||
canScaleUp() {
|
|
||||||
return this.scale < this.maxScale
|
|
||||||
},
|
|
||||||
canScaleDown() {
|
|
||||||
return this.scale > 1
|
|
||||||
},
|
|
||||||
userMediaProgress() {
|
|
||||||
if (!this.libraryItemId) return
|
|
||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
|
||||||
},
|
|
||||||
savedPage() {
|
|
||||||
if (!this.keepProgress) return 0
|
|
||||||
|
|
||||||
// Validate ebookLocation is a number
|
|
||||||
if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
|
|
||||||
return Number(this.userMediaProgress.ebookLocation)
|
|
||||||
},
|
|
||||||
ebookUrl() {
|
|
||||||
if (this.fileId) {
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
|
||||||
}
|
|
||||||
return `/api/items/${this.libraryItemId}/ebook`
|
|
||||||
},
|
|
||||||
pdfDocInitParams() {
|
|
||||||
return {
|
|
||||||
url: this.ebookUrl,
|
|
||||||
httpHeaders: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
zoomIn() {
|
|
||||||
this.scale += 0.1
|
|
||||||
},
|
|
||||||
zoomOut() {
|
|
||||||
this.scale -= 0.1
|
|
||||||
},
|
|
||||||
updateProgress() {
|
|
||||||
if (!this.keepProgress) return
|
|
||||||
if (!this.numPages) {
|
|
||||||
console.error('Num pages not loaded')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ebookLocation: this.page,
|
|
||||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
|
||||||
}
|
|
||||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
|
||||||
console.error('EpubReader.updateProgress failed:', error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
loadedEvt() {
|
|
||||||
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
|
|
||||||
this.page = this.savedPage
|
|
||||||
}
|
|
||||||
},
|
|
||||||
progressEvt(progress) {
|
|
||||||
this.loadedRatio = progress
|
|
||||||
},
|
|
||||||
numPagesLoaded(e) {
|
numPagesLoaded(e) {
|
||||||
this.numPages = e
|
this.numPages = e
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
if (this.page <= 1) return
|
if (this.page <= 1) return
|
||||||
this.page--
|
this.page--
|
||||||
this.updateProgress()
|
|
||||||
},
|
},
|
||||||
next() {
|
next() {
|
||||||
if (this.page >= this.numPages) return
|
if (this.page >= this.numPages) return
|
||||||
this.page++
|
this.page++
|
||||||
this.updateProgress()
|
|
||||||
},
|
},
|
||||||
error(err) {
|
error(err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
|
||||||
resize() {
|
|
||||||
this.windowWidth = window.innerWidth
|
|
||||||
this.windowHeight = window.innerHeight
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {}
|
||||||
this.windowWidth = window.innerWidth
|
|
||||||
this.windowHeight = window.innerHeight
|
|
||||||
window.addEventListener('resize', this.resize)
|
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
window.removeEventListener('resize', this.resize)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -1,138 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
||||||
<div class="absolute top-4 left-4 z-20 flex items-center">
|
|
||||||
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
|
||||||
<span class="material-icons text-2xl">menu</span>
|
|
||||||
</button>
|
|
||||||
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
|
|
||||||
<span class="material-icons text-1.5xl">settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
|
||||||
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
|
|
||||||
<span style="font-weight: 600">{{ abTitle }}</span>
|
|
||||||
<span v-if="abAuthor" class="hidden md:inline"> – </span>
|
|
||||||
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute top-4 right-4 z-20">
|
<div class="absolute top-4 right-4 z-20">
|
||||||
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
|
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
|
||||||
<span class="material-icons text-2xl">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
|
<div class="absolute top-4 left-4">
|
||||||
|
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
|
||||||
<!-- TOC side nav -->
|
<p v-if="abAuthor">by {{ abAuthor }}</p>
|
||||||
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
|
||||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent>
|
|
||||||
<div class="flex flex-col p-4 h-full">
|
|
||||||
<div class="flex items-center mb-2">
|
|
||||||
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
|
||||||
<span class="material-icons text-2xl">arrow_back</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="searchBook" @click.stop.prevent>
|
|
||||||
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" />
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="overflow-y-auto">
|
|
||||||
<div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center">
|
|
||||||
<p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul>
|
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
||||||
<li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1">
|
|
||||||
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a>
|
|
||||||
<div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4">
|
|
||||||
<a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-if="chapter.subitems.length">
|
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
||||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
|
||||||
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a>
|
|
||||||
<div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4">
|
|
||||||
<a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ereader settings modal -->
|
|
||||||
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
|
|
||||||
<template #outer>
|
|
||||||
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
|
||||||
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-40">
|
|
||||||
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
|
|
||||||
</div>
|
|
||||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems.theme" @input="settingsUpdated" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-40">
|
|
||||||
<p class="text-lg">{{ $strings.LabelFontFamily }}:</p>
|
|
||||||
</div>
|
|
||||||
<ui-toggle-btns v-model="ereaderSettings.font" :items="themeItems.font" @input="settingsUpdated" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-40">
|
|
||||||
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
|
|
||||||
</div>
|
|
||||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-40">
|
|
||||||
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
|
|
||||||
</div>
|
|
||||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-40">
|
|
||||||
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
|
||||||
</div>
|
|
||||||
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {}
|
||||||
touchstartX: 0,
|
|
||||||
touchstartY: 0,
|
|
||||||
touchendX: 0,
|
|
||||||
touchendY: 0,
|
|
||||||
touchstartTime: 0,
|
|
||||||
touchIdentifier: null,
|
|
||||||
chapters: [],
|
|
||||||
isSearching: false,
|
|
||||||
searchResults: [],
|
|
||||||
searchQuery: '',
|
|
||||||
tocOpen: false,
|
|
||||||
showSettings: false,
|
|
||||||
ereaderSettings: {
|
|
||||||
theme: 'dark',
|
|
||||||
font: 'serif',
|
|
||||||
fontScale: 100,
|
|
||||||
lineSpacing: 115,
|
|
||||||
spread: 'auto'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
show(newVal) {
|
show(newVal) {
|
||||||
@@ -150,59 +36,14 @@ export default {
|
|||||||
this.$store.commit('setShowEReader', val)
|
this.$store.commit('setShowEReader', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ereaderTheme() {
|
|
||||||
if (this.isEpub) return this.ereaderSettings.theme
|
|
||||||
return 'dark'
|
|
||||||
},
|
|
||||||
spreadItems() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLayoutSinglePage,
|
|
||||||
value: 'none'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelLayoutSplitPage,
|
|
||||||
value: 'auto'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
themeItems() {
|
|
||||||
return {
|
|
||||||
theme: [
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelThemeDark,
|
|
||||||
value: 'dark'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.$strings.LabelThemeLight,
|
|
||||||
value: 'light'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
font: [
|
|
||||||
{
|
|
||||||
text: 'Sans',
|
|
||||||
value: 'sans-serif'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Serif',
|
|
||||||
value: 'serif'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
componentName() {
|
componentName() {
|
||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub' && this.$isDev) return 'readers-epub-reader2'
|
||||||
|
else if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
streamLibraryItem() {
|
|
||||||
return this.$store.state.streamLibraryItem
|
|
||||||
},
|
|
||||||
hasSettings() {
|
|
||||||
return this.isEpub
|
|
||||||
},
|
|
||||||
abTitle() {
|
abTitle() {
|
||||||
return this.mediaMetadata.title
|
return this.mediaMetadata.title
|
||||||
},
|
},
|
||||||
@@ -225,18 +66,10 @@ export default {
|
|||||||
return this.selectedLibraryItem.folderId
|
return this.selectedLibraryItem.folderId
|
||||||
},
|
},
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
// ebook file id is passed when reading a supplementary ebook
|
|
||||||
if (this.ebookFileId) {
|
|
||||||
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
|
||||||
}
|
|
||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
ebookFormat() {
|
ebookFormat() {
|
||||||
if (!this.ebookFile) return null
|
if (!this.ebookFile) return null
|
||||||
// Use file extension for supplementary ebook
|
|
||||||
if (!this.ebookFile.ebookFormat) {
|
|
||||||
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
|
|
||||||
}
|
|
||||||
return this.ebookFile.ebookFormat
|
return this.ebookFile.ebookFormat
|
||||||
},
|
},
|
||||||
ebookType() {
|
ebookType() {
|
||||||
@@ -258,140 +91,49 @@ export default {
|
|||||||
isComic() {
|
isComic() {
|
||||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||||
},
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
if (!this.ebookFile) return null
|
||||||
|
let filepath = ''
|
||||||
|
if (this.selectedLibraryItem.isFile) {
|
||||||
|
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
||||||
|
} else {
|
||||||
|
const itemRelPath = this.selectedLibraryItem.relPath
|
||||||
|
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||||
|
const relPath = this.ebookFile.metadata.relPath
|
||||||
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
|
||||||
|
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||||
|
}
|
||||||
|
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
||||||
|
},
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
|
||||||
keepProgress() {
|
|
||||||
return this.$store.state.ereaderKeepProgress
|
|
||||||
},
|
|
||||||
ebookFileId() {
|
|
||||||
return this.$store.state.ereaderFileId
|
|
||||||
},
|
|
||||||
isDarkTheme() {
|
|
||||||
return this.ereaderSettings.theme === 'dark'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goToChapter(uri) {
|
|
||||||
this.toggleToC()
|
|
||||||
this.$refs.readerComponent.goToChapter(uri)
|
|
||||||
},
|
|
||||||
readerMounted() {
|
|
||||||
if (this.isEpub) {
|
|
||||||
this.loadEreaderSettings()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
settingsUpdated() {
|
|
||||||
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
|
||||||
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
|
||||||
},
|
|
||||||
toggleToC() {
|
|
||||||
this.tocOpen = !this.tocOpen
|
|
||||||
this.chapters = this.$refs.readerComponent.chapters
|
|
||||||
},
|
|
||||||
openSettings() {
|
|
||||||
this.showSettings = true
|
|
||||||
},
|
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
|
console.log('Reader hotkey', action)
|
||||||
if (!this.$refs.readerComponent) return
|
if (!this.$refs.readerComponent) return
|
||||||
|
|
||||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||||
this.next()
|
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||||
this.prev()
|
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||||
this.close()
|
this.close()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async searchBook() {
|
|
||||||
if (this.searchQuery.length > 1) {
|
|
||||||
this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery)
|
|
||||||
this.isSearching = true
|
|
||||||
} else {
|
|
||||||
this.isSearching = false
|
|
||||||
this.searchResults = []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
next() {
|
|
||||||
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
|
||||||
},
|
|
||||||
prev() {
|
|
||||||
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
|
||||||
},
|
|
||||||
handleGesture() {
|
|
||||||
// Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
|
|
||||||
const touchTimeMs = Date.now() - this.touchstartTime
|
|
||||||
if (touchTimeMs >= 1000) {
|
|
||||||
console.log('Touch too long', touchTimeMs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
|
||||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
|
||||||
const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
|
|
||||||
if (touchDistance < 60) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.touchendX < this.touchstartX) {
|
|
||||||
this.next()
|
|
||||||
}
|
|
||||||
if (this.touchendX > this.touchstartX) {
|
|
||||||
this.prev()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
touchstart(e) {
|
|
||||||
// Ignore rapid touch
|
|
||||||
if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.touchstartX = e.touches[0].screenX
|
|
||||||
this.touchstartY = e.touches[0].screenY
|
|
||||||
this.touchstartTime = Date.now()
|
|
||||||
this.touchIdentifier = e.touches[0].identifier
|
|
||||||
},
|
|
||||||
touchend(e) {
|
|
||||||
if (this.touchIdentifier !== e.changedTouches[0].identifier) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.touchendX = e.changedTouches[0].screenX
|
|
||||||
this.touchendY = e.changedTouches[0].screenY
|
|
||||||
this.handleGesture()
|
|
||||||
},
|
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||||
document.body.addEventListener('touchstart', this.touchstart)
|
|
||||||
document.body.addEventListener('touchend', this.touchend)
|
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
this.$eventBus.$off('reader-hotkey', this.hotkey)
|
this.$eventBus.$off('reader-hotkey', this.hotkey)
|
||||||
document.body.removeEventListener('touchstart', this.touchstart)
|
|
||||||
document.body.removeEventListener('touchend', this.touchend)
|
|
||||||
},
|
|
||||||
loadEreaderSettings() {
|
|
||||||
try {
|
|
||||||
const settings = localStorage.getItem('ereaderSettings')
|
|
||||||
if (settings) {
|
|
||||||
this.ereaderSettings = JSON.parse(settings)
|
|
||||||
this.settingsUpdated()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load ereader settings', error)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.registerListeners()
|
this.registerListeners()
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
this.unregisterListeners()
|
this.unregisterListeners()
|
||||||
this.isSearching = false
|
|
||||||
this.searchQuery = ''
|
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -405,15 +147,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#reader {
|
/* @import url(@/assets/calibre/basic.css); */
|
||||||
height: 100%;
|
.ebook-viewer {
|
||||||
}
|
height: calc(100% - 96px);
|
||||||
#reader.reader-player-open {
|
|
||||||
height: calc(100% - 164px);
|
|
||||||
}
|
|
||||||
@media (max-height: 400px) {
|
|
||||||
#reader.reader-player-open {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -235,6 +235,7 @@ export default {
|
|||||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
console.log('Data', this.data)
|
||||||
|
|
||||||
this.monthLabels = []
|
this.monthLabels = []
|
||||||
var lastMonth = null
|
var lastMonth = null
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isBookLibrary" class="flex px-4">
|
<div class="flex px-4">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -58,32 +58,26 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentLibraryMediaType() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
|
||||||
},
|
|
||||||
isBookLibrary() {
|
|
||||||
return this.currentLibraryMediaType === 'book'
|
|
||||||
},
|
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalItems() {
|
totalItems() {
|
||||||
return this.libraryStats?.totalItems || 0
|
return this.libraryStats ? this.libraryStats.totalItems : 0
|
||||||
},
|
},
|
||||||
totalAuthors() {
|
totalAuthors() {
|
||||||
return this.libraryStats?.totalAuthors || 0
|
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||||
},
|
},
|
||||||
numAudioTracks() {
|
numAudioTracks() {
|
||||||
return this.libraryStats?.numAudioTracks || 0
|
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||||
},
|
},
|
||||||
totalDuration() {
|
totalDuration() {
|
||||||
return this.libraryStats?.totalDuration || 0
|
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||||
},
|
},
|
||||||
totalHours() {
|
totalHours() {
|
||||||
return Math.round(this.totalDuration / (60 * 60))
|
return Math.round(this.totalDuration / (60 * 60))
|
||||||
},
|
},
|
||||||
totalSizePretty() {
|
totalSizePretty() {
|
||||||
var totalSize = this.libraryStats?.totalSize || 0
|
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||||
return this.$bytesPretty(totalSize, 1)
|
return this.$bytesPretty(totalSize, 1)
|
||||||
},
|
},
|
||||||
totalSizeNum() {
|
totalSizeNum() {
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr>
|
|
||||||
<td class="text-center">
|
|
||||||
<p>{{ track.index }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
|
||||||
<td v-if="!showFullPath" class="hidden lg:table-cell">
|
|
||||||
{{ track.audioFile.codec || '' }}
|
|
||||||
</td>
|
|
||||||
<td v-if="!showFullPath" class="hidden xl:table-cell">
|
|
||||||
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
|
|
||||||
</td>
|
|
||||||
<td class="hidden md:table-cell">
|
|
||||||
{{ $bytesPretty(track.metadata.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="hidden sm:table-cell">
|
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="contextMenuItems.length" class="text-center">
|
|
||||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItemId: String,
|
|
||||||
showFullPath: Boolean,
|
|
||||||
track: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
const items = []
|
|
||||||
if (this.userCanDownload) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelDownload,
|
|
||||||
action: 'download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.userCanDelete) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.ButtonDelete,
|
|
||||||
action: 'delete'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.userIsAdmin) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelMoreInfo,
|
|
||||||
action: 'more'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
downloadUrl() {
|
|
||||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'delete') {
|
|
||||||
this.deleteLibraryFile()
|
|
||||||
} else if (action === 'download') {
|
|
||||||
this.downloadLibraryFile()
|
|
||||||
} else if (action === 'more') {
|
|
||||||
this.$emit('showMore', this.track.audioFile)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteLibraryFile() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmDeleteFile,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('File deleted')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete file', error)
|
|
||||||
this.$toast.error('Failed to delete file')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
downloadLibraryFile() {
|
|
||||||
this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="w-full flex flex-row items-center justify-center">
|
<div class="w-full flex flex-row items-center justify-center">
|
||||||
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
||||||
|
|
||||||
|
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||||
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||||
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
|
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||||
|
|
||||||
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -80,9 +80,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
downloadBackup(backup) {
|
|
||||||
this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
|
|
||||||
},
|
|
||||||
confirm() {
|
confirm() {
|
||||||
this.showConfirmApply = false
|
this.showConfirmApply = false
|
||||||
|
|
||||||
@@ -94,9 +91,8 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.isBackingUp = false
|
this.isBackingUp = false
|
||||||
console.error('Failed to apply backup', error)
|
console.error('Failed', error)
|
||||||
const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
|
this.$toast.error(this.$strings.ToastBackupRestoreFailed)
|
||||||
this.$toast.error(errorMsg)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteBackupClick(backup) {
|
deleteBackupClick(backup) {
|
||||||
@@ -164,7 +160,6 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/backups')
|
.$get('/api/backups')
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$emit('loaded', data.backupLocation)
|
|
||||||
this.setBackups(data.backups || [])
|
this.setBackups(data.backups || [])
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full my-2">
|
|
||||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
|
||||||
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
|
|
||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
|
||||||
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
|
||||||
<span class="material-icons text-4xl">expand_more</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<transition name="slide">
|
|
||||||
<div class="w-full" v-show="showFiles">
|
|
||||||
<table class="text-sm tracksTable">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
|
||||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
|
||||||
<th class="text-left px-4 w-24">
|
|
||||||
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
|
|
||||||
</th>
|
|
||||||
<th v-if="showMoreColumn" class="text-center w-16"></th>
|
|
||||||
</tr>
|
|
||||||
<template v-for="file in ebookFiles">
|
|
||||||
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
|
|
||||||
</template>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItem: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showFiles: false,
|
|
||||||
showFullPath: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem.id
|
|
||||||
},
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
libraryIsAudiobooksOnly() {
|
|
||||||
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
|
||||||
},
|
|
||||||
showMoreColumn() {
|
|
||||||
return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)
|
|
||||||
},
|
|
||||||
ebookFiles() {
|
|
||||||
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
toggleFullPath() {
|
|
||||||
this.showFullPath = !this.showFullPath
|
|
||||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
|
||||||
},
|
|
||||||
readEbook(fileIno) {
|
|
||||||
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
|
|
||||||
},
|
|
||||||
clickBar() {
|
|
||||||
this.showFiles = !this.showFiles
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
if (this.userIsAdmin) {
|
|
||||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr>
|
|
||||||
<td class="px-4">
|
|
||||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ $bytesPretty(file.metadata.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="text-xs">
|
|
||||||
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
|
|
||||||
</td>
|
|
||||||
<td v-if="contextMenuItems.length" class="text-center">
|
|
||||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItemId: String,
|
|
||||||
showFullPath: Boolean,
|
|
||||||
file: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
processing: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
downloadUrl() {
|
|
||||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
|
|
||||||
},
|
|
||||||
isPrimary() {
|
|
||||||
return !this.file.isSupplementary
|
|
||||||
},
|
|
||||||
libraryIsAudiobooksOnly() {
|
|
||||||
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
const items = []
|
|
||||||
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
|
|
||||||
items.push({
|
|
||||||
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
|
|
||||||
action: 'updateStatus'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.userCanDownload) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelDownload,
|
|
||||||
action: 'download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.userCanDelete) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.ButtonDelete,
|
|
||||||
action: 'delete'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
readEbook() {
|
|
||||||
this.$emit('read', this.file.ino)
|
|
||||||
},
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'delete') {
|
|
||||||
this.deleteLibraryFile()
|
|
||||||
} else if (action === 'download') {
|
|
||||||
this.downloadLibraryFile()
|
|
||||||
} else if (action === 'updateStatus') {
|
|
||||||
this.updateEbookStatus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
updateEbookStatus() {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('Ebook updated')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update ebook', error)
|
|
||||||
this.$toast.error('Failed to update ebook')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
deleteLibraryFile() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmDeleteFile,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('File deleted')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete file', error)
|
|
||||||
this.$toast.error('Failed to delete file')
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
downloadLibraryFile() {
|
|
||||||
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||||
<span class="material-icons text-4xl">expand_more</span>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,88 +18,63 @@
|
|||||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||||
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
|
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
|
||||||
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
|
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||||
|
</tr>
|
||||||
|
<template v-for="file in files">
|
||||||
|
<tr :key="file.path">
|
||||||
|
<td class="px-4">
|
||||||
|
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(file.metadata.size) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p>{{ file.fileType }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||||
|
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in filesWithAudioFile">
|
|
||||||
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
|
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
files: {
|
||||||
type: Object,
|
type: Array,
|
||||||
default: () => {}
|
default: () => []
|
||||||
},
|
},
|
||||||
expanded: Boolean, // start expanded
|
libraryItemId: String,
|
||||||
inModal: Boolean
|
isMissing: Boolean,
|
||||||
|
expanded: Boolean // start expanded
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showFiles: false,
|
showFiles: false,
|
||||||
showFullPath: false,
|
showFullPath: false
|
||||||
showAudioFileDataModal: false,
|
|
||||||
selectedAudioFile: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
libraryItemId() {
|
|
||||||
return this.libraryItem.id
|
|
||||||
},
|
|
||||||
userToken() {
|
userToken() {
|
||||||
return this.$store.getters['user/getToken']
|
return this.$store.getters['user/getToken']
|
||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
files() {
|
|
||||||
return this.libraryItem.libraryFiles || []
|
|
||||||
},
|
|
||||||
audioFiles() {
|
|
||||||
if (this.libraryItem.mediaType === 'podcast') {
|
|
||||||
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
|
|
||||||
}
|
|
||||||
return this.libraryItem.media?.audioFiles || []
|
|
||||||
},
|
|
||||||
filesWithAudioFile() {
|
|
||||||
return this.files.map((file) => {
|
|
||||||
if (file.fileType === 'audio') {
|
|
||||||
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleFullPath() {
|
|
||||||
this.showFullPath = !this.showFullPath
|
|
||||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
|
||||||
},
|
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
},
|
|
||||||
showMore(audioFile) {
|
|
||||||
this.selectedAudioFile = audioFile
|
|
||||||
this.showAudioFileDataModal = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.userIsAdmin) {
|
|
||||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
|
||||||
}
|
|
||||||
this.showFiles = this.expanded
|
this.showFiles = this.expanded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
<template>
|
|
||||||
<tr>
|
|
||||||
<td class="px-4">
|
|
||||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ $bytesPretty(file.metadata.size) }}
|
|
||||||
</td>
|
|
||||||
<td class="text-xs">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<p>{{ file.fileType }}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td v-if="contextMenuItems.length" class="text-center">
|
|
||||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
libraryItemId: String,
|
|
||||||
showFullPath: Boolean,
|
|
||||||
file: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
inModal: Boolean
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
},
|
|
||||||
downloadUrl() {
|
|
||||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
|
|
||||||
},
|
|
||||||
contextMenuItems() {
|
|
||||||
const items = []
|
|
||||||
if (this.userCanDownload) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelDownload,
|
|
||||||
action: 'download'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.userCanDelete) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.ButtonDelete,
|
|
||||||
action: 'delete'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Currently not showing this option in the Files tab modal
|
|
||||||
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
|
|
||||||
items.push({
|
|
||||||
text: this.$strings.LabelMoreInfo,
|
|
||||||
action: 'more'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
if (action === 'delete') {
|
|
||||||
this.deleteLibraryFile()
|
|
||||||
} else if (action === 'download') {
|
|
||||||
this.downloadLibraryFile()
|
|
||||||
} else if (action === 'more') {
|
|
||||||
this.$emit('showMore', this.file.audioFile)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deleteLibraryFile() {
|
|
||||||
const payload = {
|
|
||||||
message: this.$strings.MessageConfirmDeleteFile,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
|
||||||
.then(() => {
|
|
||||||
this.$toast.success('File deleted')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete file', error)
|
|
||||||
this.$toast.error('Failed to delete file')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
downloadLibraryFile() {
|
|
||||||
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -70,10 +70,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
editItem(playlistItem) {
|
editItem(playlistItem) {
|
||||||
if (playlistItem.episode) {
|
if (playlistItem.episode) {
|
||||||
const episodeIds = this.items.map((pi) => pi.episodeId)
|
this.$store.commit('globals/setSelectedEpisode', playlist.episode)
|
||||||
this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
|
|
||||||
this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
|
|
||||||
this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
|
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
} else {
|
} else {
|
||||||
const itemIds = this.items.map((i) => i.libraryItemId)
|
const itemIds = this.items.map((i) => i.libraryItemId)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -20,20 +21,41 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="w-10">#</th>
|
<th class="w-10">#</th>
|
||||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||||
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
|
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
|
||||||
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
|
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
|
||||||
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
|
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||||
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
|
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
||||||
<th class="text-center w-16"></th>
|
<div class="flex items-center">
|
||||||
|
<p>Tone</p>
|
||||||
|
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
|
||||||
|
<span class="material-icons-outlined text-sm">information</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
|
<tr :key="track.index">
|
||||||
|
<td class="text-center">
|
||||||
|
<p>{{ track.index }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $bytesPretty(track.metadata.size) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
|
</td>
|
||||||
|
<td v-if="userCanDownload" class="text-center">
|
||||||
|
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
|
||||||
|
</td>
|
||||||
|
<td v-if="showExperimentalFeatures" class="text-center">
|
||||||
|
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,41 +77,49 @@ export default {
|
|||||||
return {
|
return {
|
||||||
showTracks: false,
|
showTracks: false,
|
||||||
showFullPath: false,
|
showFullPath: false,
|
||||||
selectedAudioFile: null,
|
toneProbing: false
|
||||||
showAudioFileDataModal: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
userCanDelete() {
|
showExperimentalFeatures() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.state.showExperimentalFeatures
|
||||||
},
|
|
||||||
userIsAdmin() {
|
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleFullPath() {
|
|
||||||
this.showFullPath = !this.showFullPath
|
|
||||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
|
||||||
},
|
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showTracks = !this.showTracks
|
this.showTracks = !this.showTracks
|
||||||
},
|
},
|
||||||
showMore(audioFile) {
|
toneProbe(index) {
|
||||||
this.selectedAudioFile = audioFile
|
this.toneProbing = true
|
||||||
this.showAudioFileDataModal = true
|
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
|
||||||
|
.then((data) => {
|
||||||
|
console.log('Tone probe data', data)
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error('Tone probe error: ' + data.error)
|
||||||
|
} else {
|
||||||
|
this.$toast.success('Tone probe successful! Check browser console')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to tone probe', error)
|
||||||
|
this.$toast.error('Tone probe failed')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.toneProbing = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {}
|
||||||
if (this.userIsAdmin) {
|
|
||||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -19,13 +19,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
<td class="hidden lg:table-cell">
|
<td class="hidden lg:table-cell">
|
||||||
<div v-if="usersOnline[user.id]?.session?.displayTitle">
|
<div v-if="usersOnline[user.id]">
|
||||||
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>
|
<p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
||||||
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>
|
<p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
|
||||||
</div>
|
|
||||||
<div v-else-if="user.latestSession?.displayTitle">
|
|
||||||
<p class="truncate text-xs">Last: {{ user.latestSession.displayTitle || '' }}</p>
|
|
||||||
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-mono hidden sm:table-cell">
|
<td class="text-xs font-mono hidden sm:table-cell">
|
||||||
@@ -52,6 +48,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -60,6 +58,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
users: [],
|
users: [],
|
||||||
|
selectedAccount: null,
|
||||||
|
showAccountModal: false,
|
||||||
isDeletingUser: false
|
isDeletingUser: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -83,12 +83,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getDeviceInfoString(deviceInfo) {
|
|
||||||
if (!deviceInfo) return ''
|
|
||||||
if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`
|
|
||||||
|
|
||||||
return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`
|
|
||||||
},
|
|
||||||
deleteUserClick(user) {
|
deleteUserClick(user) {
|
||||||
if (this.isDeletingUser) return
|
if (this.isDeletingUser) return
|
||||||
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
|
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
|
||||||
@@ -110,12 +104,17 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
clickAddUser() {
|
||||||
|
this.selectedAccount = null
|
||||||
|
this.showAccountModal = true
|
||||||
|
},
|
||||||
editUser(user) {
|
editUser(user) {
|
||||||
this.$emit('edit', user)
|
this.selectedAccount = user
|
||||||
|
this.showAccountModal = true
|
||||||
},
|
},
|
||||||
loadUsers() {
|
loadUsers() {
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/users?include=latestSession')
|
.$get('/api/users')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.users = res.users.sort((a, b) => {
|
this.users = res.users.sort((a, b) => {
|
||||||
return a.createdAt - b.createdAt
|
return a.createdAt - b.createdAt
|
||||||
|
|||||||
@@ -1,36 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
||||||
<div v-if="book" class="flex h-18 md:h-[5.5rem]">
|
<div v-if="book" class="flex h-16 md:h-20">
|
||||||
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
||||||
<div class="flex h-full items-center justify-center">
|
<div class="flex h-full items-center justify-center">
|
||||||
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
||||||
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
|
|
||||||
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||||
<span class="material-icons text-2xl">play_arrow</span>
|
<span class="material-icons text-2xl">play_arrow</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex-grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3">
|
<div class="flex-grow overflow-hidden max-w-48 md:max-w-md h-full flex items-center px-2 md:px-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="truncate max-w-48 md:max-w-md">
|
<div class="truncate max-w-48 md:max-w-md">
|
||||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
|
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
|
||||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||||
<template v-for="(author, index) in bookAuthors">
|
<template v-for="(author, index) in bookAuthors">
|
||||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="media.duration" class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
<p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,19 +96,6 @@ export default {
|
|||||||
bookDuration() {
|
bookDuration() {
|
||||||
return this.$elapsedPretty(this.media.duration)
|
return this.$elapsedPretty(this.media.duration)
|
||||||
},
|
},
|
||||||
series() {
|
|
||||||
return this.mediaMetadata.series || []
|
|
||||||
},
|
|
||||||
seriesList() {
|
|
||||||
return this.series.map((se) => {
|
|
||||||
let text = se.name
|
|
||||||
if (se.sequence) text += ` #${se.sequence}`
|
|
||||||
return {
|
|
||||||
...se,
|
|
||||||
text
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this.book.isMissing
|
return this.book.isMissing
|
||||||
},
|
},
|
||||||
@@ -135,9 +117,6 @@ export default {
|
|||||||
coverSize() {
|
coverSize() {
|
||||||
return this.$store.state.globals.isMobile ? 30 : 50
|
return this.$store.state.globals.isMobile ? 30 : 50
|
||||||
},
|
},
|
||||||
coverHeight() {
|
|
||||||
return this.coverSize * 1.6
|
|
||||||
},
|
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||||
return this.coverSize
|
return this.coverSize
|
||||||
@@ -188,6 +167,7 @@ export default {
|
|||||||
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
<ui-btn @click="clickAddLibrary">{{ $strings.ButtonAddYourFirstLibrary }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">
|
||||||
|
*<strong>{{ $strings.ButtonForceReScan }}</strong> {{ $strings.MessageForceReScanDescription }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
|
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">
|
||||||
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
**<strong>{{ $strings.ButtonMatchBooks }}</strong> {{ $strings.MessageMatchBooksDescription }}
|
||||||
</p>
|
</p>
|
||||||
@@ -42,10 +46,13 @@ export default {
|
|||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.currentLibrary?.id || null
|
return this.currentLibrary ? this.currentLibrary.id : null
|
||||||
},
|
},
|
||||||
libraries() {
|
libraries() {
|
||||||
return this.$store.getters['libraries/getSortedLibraries']()
|
return this.$store.getters['libraries/getSortedLibraries']()
|
||||||
|
},
|
||||||
|
libraryScans() {
|
||||||
|
return this.$store.state.scanners.libraryScans
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
|
<div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false">
|
||||||
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
||||||
<ui-library-icon v-if="!isScanning" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
|
<ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" />
|
||||||
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -9,14 +9,11 @@
|
|||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<!-- Scan button only shown on desktop -->
|
|
||||||
<ui-btn v-if="!isScanning && !isDeleting" color="bg" class="hidden md:block mx-2 text-xs" :padding-y="1" :padding-x="3" @click.stop="scanBtnClick">{{ this.$strings.ButtonScan }}</ui-btn>
|
|
||||||
|
|
||||||
<!-- Desktop context menu icon -->
|
<!-- Desktop context menu icon -->
|
||||||
<ui-context-menu-dropdown v-if="!isScanning && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
|
<ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" />
|
||||||
|
|
||||||
<!-- Mobile context menu icon -->
|
<!-- Mobile context menu icon -->
|
||||||
<span v-if="!isScanning && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
|
<span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span>
|
||||||
|
|
||||||
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
|
||||||
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
<svg viewBox="0 0 24 24" class="w-6 h-6">
|
||||||
@@ -51,8 +48,8 @@ export default {
|
|||||||
isHovering() {
|
isHovering() {
|
||||||
return this.mouseover && !this.dragging
|
return this.mouseover && !this.dragging
|
||||||
},
|
},
|
||||||
isScanning() {
|
libraryScan() {
|
||||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.library.id)
|
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.library.mediaType
|
return this.library.mediaType
|
||||||
@@ -74,6 +71,11 @@ export default {
|
|||||||
text: this.$strings.ButtonScan,
|
text: this.$strings.ButtonScan,
|
||||||
action: 'scan',
|
action: 'scan',
|
||||||
value: 'scan'
|
value: 'scan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonForceReScan,
|
||||||
|
action: 'force-scan',
|
||||||
|
value: 'force-scan'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
if (this.isBookLibrary) {
|
if (this.isBookLibrary) {
|
||||||
@@ -92,17 +94,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
scanBtnClick() {
|
contextMenuAction(action) {
|
||||||
this.scan()
|
|
||||||
},
|
|
||||||
contextMenuAction({ action }) {
|
|
||||||
this.showMobileMenu = false
|
this.showMobileMenu = false
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
this.editClick()
|
this.editClick()
|
||||||
} else if (action === 'scan') {
|
} else if (action === 'scan') {
|
||||||
this.scan()
|
this.scan()
|
||||||
} else if (action === 'force-rescan') {
|
} else if (action === 'force-scan') {
|
||||||
this.scan(true)
|
this.forceScan()
|
||||||
} else if (action === 'match-books') {
|
} else if (action === 'match-books') {
|
||||||
this.matchAll()
|
this.matchAll()
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
@@ -127,17 +126,37 @@ export default {
|
|||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
scan(force = false) {
|
scan() {
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force })
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to start scan', error)
|
console.error('Failed to start scan', error)
|
||||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
forceScan() {
|
||||||
|
const payload = {
|
||||||
|
message: this.$strings.MessageConfirmForceReScan,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success(this.$strings.ToastLibraryScanStarted)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
message: this.$getString('MessageConfirmDeleteLibrary', [this.library.name]),
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export default {
|
|||||||
.$patch(routepath, updatePayload)
|
.$patch(routepath, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
|
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||||
|
|
||||||
<div class="flex justify-between pt-2 max-w-xl">
|
<div class="flex justify-between pt-2 max-w-xl">
|
||||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||||
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
|
|
||||||
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
|
||||||
|
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
|
||||||
|
</button> -->
|
||||||
|
|
||||||
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
|
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
|
||||||
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
|
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -84,7 +88,7 @@ export default {
|
|||||||
return this.episode.title || ''
|
return this.episode.title || ''
|
||||||
},
|
},
|
||||||
subtitle() {
|
subtitle() {
|
||||||
return this.episode.subtitle || this.description
|
return this.episode.subtitle || ''
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.episode.description || ''
|
return this.episode.description || ''
|
||||||
@@ -183,6 +187,7 @@ export default {
|
|||||||
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.isProcessingReadUpdate = false
|
this.isProcessingReadUpdate = false
|
||||||
|
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full py-6">
|
<div class="w-full py-6">
|
||||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
|
||||||
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
|
<div class="flex items-center mb-4">
|
||||||
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
|
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
|
||||||
<div class="inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100">
|
|
||||||
<p v-if="episodesList.length === episodes.length">{{ episodes.length }}</p>
|
|
||||||
<p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow hidden md:block" />
|
<div class="flex-grow hidden md:block" />
|
||||||
<div class="flex items-center">
|
|
||||||
<template v-if="isSelectionMode">
|
<template v-if="isSelectionMode">
|
||||||
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||||
@@ -18,13 +12,12 @@
|
|||||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
|
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
|
||||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||||
<div class="flex-grow md:hidden" />
|
<div class="flex-grow md:hidden" />
|
||||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||||
<form @submit.prevent="submit" class="flex flex-grow">
|
<form @submit.prevent="submit" class="flex flex-grow">
|
||||||
@@ -58,9 +51,10 @@ export default {
|
|||||||
selectedEpisodes: [],
|
selectedEpisodes: [],
|
||||||
episodesToRemove: [],
|
episodesToRemove: [],
|
||||||
processing: false,
|
processing: false,
|
||||||
|
quickMatchingEpisodes: false,
|
||||||
search: null,
|
search: null,
|
||||||
searchTimeout: null,
|
searchTimeout: null,
|
||||||
searchText: null
|
searchText: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -77,10 +71,6 @@ export default {
|
|||||||
{
|
{
|
||||||
text: 'Quick match all episodes',
|
text: 'Quick match all episodes',
|
||||||
action: 'quick-match-episodes'
|
action: 'quick-match-episodes'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
|
|
||||||
action: 'batch-mark-as-finished'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -149,38 +139,26 @@ export default {
|
|||||||
return episodeProgress && !episodeProgress.isFinished
|
return episodeProgress && !episodeProgress.isFinished
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
let aValue = a[this.sortKey]
|
|
||||||
let bValue = b[this.sortKey]
|
|
||||||
|
|
||||||
// Sort episodes with no pub date as the oldest
|
|
||||||
if (this.sortKey === 'publishedAt') {
|
|
||||||
if (!aValue) aValue = Number.MAX_VALUE
|
|
||||||
if (!bValue) bValue = Number.MAX_VALUE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.sortDesc) {
|
if (this.sortDesc) {
|
||||||
return String(bValue).localeCompare(String(aValue), undefined, { numeric: true, sensitivity: 'base' })
|
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
}
|
}
|
||||||
return String(aValue).localeCompare(String(bValue), undefined, { numeric: true, sensitivity: 'base' })
|
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
episodesList() {
|
episodesList() {
|
||||||
return this.episodesSorted.filter((episode) => {
|
return this.episodesSorted.filter((episode) => {
|
||||||
if (!this.searchText) return true
|
if (!this.searchText) return true
|
||||||
return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
|
return (
|
||||||
|
(episode.title && episode.title.toLowerCase().includes(this.searchText)) ||
|
||||||
|
(episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
selectedIsFinished() {
|
selectedIsFinished() {
|
||||||
// Find an item that is not finished, if none then all items finished
|
// Find an item that is not finished, if none then all items finished
|
||||||
return !this.selectedEpisodes.some((episode) => {
|
return !this.selectedEpisodes.find((episode) => {
|
||||||
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
||||||
return !itemProgress?.isFinished
|
return !itemProgress || !itemProgress.isFinished
|
||||||
})
|
|
||||||
},
|
|
||||||
allEpisodesFinished() {
|
|
||||||
return !this.episodesSorted.some((episode) => {
|
|
||||||
const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
|
|
||||||
return !itemProgress?.isFinished
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
dateFormat() {
|
dateFormat() {
|
||||||
@@ -191,7 +169,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
submit() {},
|
|
||||||
inputUpdate() {
|
inputUpdate() {
|
||||||
clearTimeout(this.searchTimeout)
|
clearTimeout(this.searchTimeout)
|
||||||
this.searchTimeout = setTimeout(() => {
|
this.searchTimeout = setTimeout(() => {
|
||||||
@@ -202,36 +179,19 @@ export default {
|
|||||||
this.searchText = this.search.toLowerCase().trim()
|
this.searchText = this.search.toLowerCase().trim()
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
contextMenuAction({ action }) {
|
contextMenuAction(action) {
|
||||||
if (action === 'quick-match-episodes') {
|
if (action === 'quick-match-episodes') {
|
||||||
if (this.processing) return
|
if (this.quickMatchingEpisodes) return
|
||||||
|
|
||||||
this.quickMatchAllEpisodes()
|
this.quickMatchAllEpisodes()
|
||||||
} else if (action === 'batch-mark-as-finished') {
|
|
||||||
if (this.processing) return
|
|
||||||
|
|
||||||
this.markAllEpisodesFinished()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markAllEpisodesFinished() {
|
|
||||||
const newIsFinished = !this.allEpisodesFinished
|
|
||||||
const payload = {
|
|
||||||
message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
|
|
||||||
callback: (confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: 'yesNo'
|
|
||||||
}
|
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
|
||||||
},
|
|
||||||
quickMatchAllEpisodes() {
|
quickMatchAllEpisodes() {
|
||||||
if (!this.mediaMetadata.feedUrl) {
|
if (!this.mediaMetadata.feedUrl) {
|
||||||
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
|
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.quickMatchingEpisodes = true
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
|
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
|
||||||
@@ -251,7 +211,7 @@ export default {
|
|||||||
this.$toast.error('Failed to match episodes')
|
this.$toast.error('Failed to match episodes')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.quickMatchingEpisodes = false
|
||||||
},
|
},
|
||||||
type: 'yesNo'
|
type: 'yesNo'
|
||||||
}
|
}
|
||||||
@@ -275,19 +235,17 @@ export default {
|
|||||||
this.$store.commit('addItemToQueue', queueItem)
|
this.$store.commit('addItemToQueue', queueItem)
|
||||||
},
|
},
|
||||||
toggleBatchFinished() {
|
toggleBatchFinished() {
|
||||||
this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
|
|
||||||
},
|
|
||||||
batchUpdateEpisodesFinished(episodes, newIsFinished) {
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
var newIsFinished = !this.selectedIsFinished
|
||||||
const updateProgressPayloads = episodes.map((episode) => {
|
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
|
||||||
return {
|
return {
|
||||||
libraryItemId: this.libraryItem.id,
|
libraryItemId: this.libraryItem.id,
|
||||||
episodeId: episode.id,
|
episodeId: episode.id,
|
||||||
isFinished: newIsFinished
|
isFinished: newIsFinished
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return this.$axios
|
|
||||||
|
this.$axios
|
||||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
|
||||||
<slot />
|
<slot />
|
||||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
@@ -72,3 +72,23 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 6px;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled)::before {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
button:disabled::before {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
<div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
|
||||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="h-full w-full flex items-center justify-center">
|
|
||||||
<widgets-loading-spinner />
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 w-48 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm">
|
||||||
<template v-for="(item, index) in items">
|
<template v-for="(item, index) in items">
|
||||||
<template v-if="item.subitems">
|
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="mouseoverItemIndex === index"
|
|
||||||
:key="`subitems-${index}`"
|
|
||||||
@mouseover="mouseoverSubItemMenu(index)"
|
|
||||||
@mouseleave="mouseleaveSubItemMenu(index)"
|
|
||||||
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
|
|
||||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
|
||||||
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
|
||||||
>
|
|
||||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
|
|
||||||
<p>{{ subitem.text }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
|
||||||
<p class="text-left">{{ item.text }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -50,12 +27,7 @@ export default {
|
|||||||
iconClass: {
|
iconClass: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
}
|
||||||
menuWidth: {
|
|
||||||
type: Number,
|
|
||||||
default: 192
|
|
||||||
},
|
|
||||||
processing: Boolean
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -64,54 +36,22 @@ export default {
|
|||||||
events: ['mousedown'],
|
events: ['mousedown'],
|
||||||
isActive: true
|
isActive: true
|
||||||
},
|
},
|
||||||
submenuWidth: 144,
|
showMenu: false
|
||||||
showMenu: false,
|
|
||||||
mouseoverItemIndex: null,
|
|
||||||
isOverSubItemMenu: false,
|
|
||||||
openSubMenuLeft: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
submenuLeftPos() {
|
|
||||||
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
mouseoverSubItemMenu(index) {
|
|
||||||
this.isOverSubItemMenu = true
|
|
||||||
},
|
|
||||||
mouseleaveSubItemMenu(index) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
|
||||||
}, 1)
|
|
||||||
},
|
|
||||||
mouseoverItem(index) {
|
|
||||||
this.isOverSubItemMenu = false
|
|
||||||
this.mouseoverItemIndex = index
|
|
||||||
},
|
|
||||||
mouseleaveItem(index) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.isOverSubItemMenu) return
|
|
||||||
if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
|
|
||||||
}, 1)
|
|
||||||
},
|
|
||||||
clickShowMenu() {
|
clickShowMenu() {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
this.showMenu = !this.showMenu
|
this.showMenu = !this.showMenu
|
||||||
this.$nextTick(() => {
|
|
||||||
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
|
|
||||||
if (boundingRect) {
|
|
||||||
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
clickedOutside() {
|
clickedOutside() {
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
},
|
},
|
||||||
clickAction(action, data) {
|
clickAction(action) {
|
||||||
if (this.disabled) return
|
if (this.disabled) return
|
||||||
this.showMenu = false
|
this.showMenu = false
|
||||||
this.$emit('action', { action, data })
|
this.$emit('action', action)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="item in itemsToShow">
|
<template v-for="item in itemsToShow">
|
||||||
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" />
|
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isFocused: false,
|
isFocused: false,
|
||||||
|
// currentSearch: null,
|
||||||
|
typingTimeout: null,
|
||||||
textInput: null
|
textInput: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,6 +83,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
keydownInput() {
|
||||||
|
clearTimeout(this.typingTimeout)
|
||||||
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
// this.currentSearch = this.textInput
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
setFocus() {
|
setFocus() {
|
||||||
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
if (this.$refs.input && this.editable) this.$refs.input.focus()
|
||||||
},
|
},
|
||||||
@@ -125,9 +133,11 @@ export default {
|
|||||||
if (val && !this.items.includes(val)) {
|
if (val && !this.items.includes(val)) {
|
||||||
this.$emit('newItem', val)
|
this.$emit('newItem', val)
|
||||||
}
|
}
|
||||||
|
// this.currentSearch = null
|
||||||
},
|
},
|
||||||
clickedOption(e, item) {
|
clickedOption(e, item) {
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
|
// this.currentSearch = null
|
||||||
this.input = item
|
this.input = item
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
|
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
|
||||||
<button
|
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
|
||||||
type="button"
|
|
||||||
:disabled="disabled"
|
|
||||||
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
:aria-expanded="showMenu"
|
|
||||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
|
||||||
@click.stop.prevent="clickShowMenu"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center sm:justify-start">
|
<div class="flex items-center justify-center sm:justify-start">
|
||||||
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||||
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
||||||
@@ -16,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<transition name="menu">
|
||||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
|
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="library in librariesFiltered">
|
<template v-for="library in librariesFiltered">
|
||||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||||
<div class="flex items-center px-2">
|
<div class="flex items-center px-2">
|
||||||
@@ -102,9 +94,3 @@ export default {
|
|||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.librariesDropdownMenu {
|
|
||||||
max-height: calc(100vh - 75px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" v-click-outside="clickOutside">
|
||||||
|
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span class="block truncate">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="menu">
|
||||||
|
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||||
|
<template v-for="item in items">
|
||||||
|
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||||
|
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</nuxt-link>
|
||||||
|
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Menu'
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickOutside() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickedOption(itemValue) {
|
||||||
|
this.$emit('action', itemValue)
|
||||||
|
this.showMenu = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</div>
|
</div>
|
||||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" @paste="inputPaste" />
|
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -145,31 +145,6 @@ export default {
|
|||||||
this.menu.style.left = boundingBox.x + 'px'
|
this.menu.style.left = boundingBox.x + 'px'
|
||||||
this.menu.style.width = boundingBox.width + 'px'
|
this.menu.style.width = boundingBox.width + 'px'
|
||||||
},
|
},
|
||||||
inputPaste(evt) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const pastedText = evt.target?.value || ''
|
|
||||||
console.log('Pasted text=', pastedText)
|
|
||||||
const pastedItems = [
|
|
||||||
...new Set(
|
|
||||||
pastedText
|
|
||||||
.split(';')
|
|
||||||
.map((i) => i.trim())
|
|
||||||
.filter((i) => i)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
// Filter out items already selected
|
|
||||||
const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i.toLowerCase() === i.toLowerCase()))
|
|
||||||
if (pastedItems.length && !itemsToAdd.length) {
|
|
||||||
this.textInput = null
|
|
||||||
this.currentSearch = null
|
|
||||||
} else {
|
|
||||||
for (const itemToAdd of itemsToAdd) {
|
|
||||||
this.insertNewItem(itemToAdd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 10)
|
|
||||||
},
|
|
||||||
inputFocus() {
|
inputFocus() {
|
||||||
if (!this.menu) {
|
if (!this.menu) {
|
||||||
this.unmountMountMenu()
|
this.unmountMountMenu()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user