mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-01 12:09:43 -05:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd657f07d | ||
|
|
c3b33ea37a | ||
|
|
36bd6e649a | ||
|
|
4621c78573 | ||
|
|
c88bbf1ce4 | ||
|
|
d37b25a6f6 | ||
|
|
792268f5ee | ||
|
|
5f2d6f4d5e | ||
|
|
acf22ca4fa | ||
|
|
705aac40d7 | ||
|
|
7456052620 | ||
|
|
6cd4ec7fce | ||
|
|
93b8e11378 | ||
|
|
6161daeef0 | ||
|
|
cfcd351570 | ||
|
|
514893646a | ||
|
|
e5469cc0f8 | ||
|
|
ec6e70725c | ||
|
|
160dac109d | ||
|
|
6be741045f | ||
|
|
f41d6d5c77 | ||
|
|
a5dacd7821 | ||
|
|
8b12508b0c | ||
|
|
a394f38fe9 | ||
|
|
c4bfa266b0 | ||
|
|
96232676cb | ||
|
|
b2aab06e01 | ||
|
|
f002532c1e | ||
|
|
54663f0f01 | ||
|
|
d8df9a9dff | ||
|
|
68efd30a54 | ||
|
|
27407d49dd | ||
|
|
97d4330cda | ||
|
|
3153bdc5bb | ||
|
|
31fd75a895 | ||
|
|
b22173a631 | ||
|
|
d2e012d7b1 | ||
|
|
d4fe0be386 | ||
|
|
6d947bbc29 | ||
|
|
5187d0e55f | ||
|
|
c6253e4fd4 | ||
|
|
1ab933c8b0 | ||
|
|
e2e5dd372a | ||
|
|
aeb87c81a1 | ||
|
|
3e98b6f749 | ||
|
|
3c465994fe | ||
|
|
6cfe583535 | ||
|
|
0ad7a98fc7 | ||
|
|
ce88ebb55b | ||
|
|
c7e3f08d39 | ||
|
|
d15264832d | ||
|
|
a8d5b543d7 | ||
|
|
f2e16017f6 | ||
|
|
4d227cbade | ||
|
|
15a85299b9 | ||
|
|
d22e9e32ed | ||
|
|
8beac53f5f | ||
|
|
cbad435690 | ||
|
|
169b637720 | ||
|
|
f083d4b5f6 | ||
|
|
3451a312e9 | ||
|
|
927c1a3514 | ||
|
|
dabcad5ebd | ||
|
|
796602d1b2 | ||
|
|
302870a101 | ||
|
|
3954aa1963 | ||
|
|
2d8c840ad6 | ||
|
|
f1f02b185e | ||
|
|
13d21e90f8 | ||
|
|
dd664da871 | ||
|
|
6ff66370fe | ||
|
|
23904d57ad | ||
|
|
efdb43e2d2 | ||
|
|
67523095d6 | ||
|
|
e2d869bb19 | ||
|
|
d38e9499db | ||
|
|
c7429efe95 | ||
|
|
b925dbcc95 | ||
|
|
2a235b8324 | ||
|
|
06cc2a1b21 | ||
|
|
4bcca97b1f | ||
|
|
313b9026f1 | ||
|
|
139ee013a7 | ||
|
|
7e5ab477b2 | ||
|
|
eba37c46cb | ||
|
|
228d9cc301 | ||
|
|
85946dd1d5 | ||
|
|
b40598593d | ||
|
|
e918a46d09 | ||
|
|
8061ee29d5 | ||
|
|
e15e04f085 | ||
|
|
958d68ffa9 | ||
|
|
c8a743ccc1 | ||
|
|
09dc95f560 | ||
|
|
853858825b | ||
|
|
c962090c3a | ||
|
|
63a8e2433e | ||
|
|
f78d287b59 | ||
|
|
eaa383b6d8 | ||
|
|
113026ce13 | ||
|
|
578a946ca5 | ||
|
|
f31306eda0 | ||
|
|
c62b716a2c | ||
|
|
97ed20c683 | ||
|
|
d5c46dcbfb | ||
|
|
30934edd57 | ||
|
|
d06fd1a1b1 | ||
|
|
6bb36381f1 | ||
|
|
a1331fb3f8 | ||
|
|
17d15144eb | ||
|
|
74d26eece4 | ||
|
|
474a7d08d0 | ||
|
|
639c930779 | ||
|
|
c6323f8ad9 | ||
|
|
caea6c6371 | ||
|
|
d285845e04 | ||
|
|
5a6867e98a | ||
|
|
621444114f | ||
|
|
5591704aad | ||
|
|
cc1181b301 | ||
|
|
095f49824e | ||
|
|
b330030f50 | ||
|
|
a7d422e23f | ||
|
|
f51a31c8ca | ||
|
|
290340a385 | ||
|
|
0137f6dfeb | ||
|
|
7f27eabf3e | ||
|
|
4f7588c87d | ||
|
|
a19b6370c4 | ||
|
|
fbd7ae10d1 | ||
|
|
f94c706fc8 | ||
|
|
9de4b1069a | ||
|
|
8fbe3c3884 | ||
|
|
abf9120363 | ||
|
|
69f250cba5 | ||
|
|
2103edfcdc | ||
|
|
02ba147bd4 | ||
|
|
230b548921 | ||
|
|
f34ebdc016 | ||
|
|
69ad651671 | ||
|
|
edc919b3f5 | ||
|
|
c8c7a9ece5 | ||
|
|
8702ac1ccf | ||
|
|
33833e0a36 | ||
|
|
6b98baafdf | ||
|
|
cc285bb685 | ||
|
|
ef0243f1d7 | ||
|
|
7a7d53f92e | ||
|
|
2e070227ab | ||
|
|
195a30096f | ||
|
|
55c40658f2 | ||
|
|
db48a486e5 | ||
|
|
d869a9836e | ||
|
|
55680cbc98 | ||
|
|
9b7e6a6058 | ||
|
|
a482e5d316 | ||
|
|
5ac342defd | ||
|
|
944a5b3e92 | ||
|
|
9b9de84740 | ||
|
|
2746e61cb3 | ||
|
|
7f1d797fb2 | ||
|
|
2059c9f14a | ||
|
|
0e16a9c8de | ||
|
|
b6a33bf7bb | ||
|
|
ce88ac9f33 | ||
|
|
678dceefed | ||
|
|
8b38dda229 | ||
|
|
7373c7159b | ||
|
|
e34a39dde4 | ||
|
|
d4cd8c6db9 | ||
|
|
9e93a3c7e6 | ||
|
|
4a8bcc90ea | ||
|
|
84c12a6e7e | ||
|
|
2a513ac8b8 | ||
|
|
97687c96cd | ||
|
|
a42c13aec2 | ||
|
|
5f0f8b92d1 | ||
|
|
78ca6aa679 | ||
|
|
22e3d4a150 | ||
|
|
e3fba1fb2b | ||
|
|
4d95250990 | ||
|
|
4776368501 | ||
|
|
8b0ed2bf29 | ||
|
|
54389e3c25 | ||
|
|
bf0da1c6ec | ||
|
|
591a866f8c | ||
|
|
fc8473ed84 | ||
|
|
b19442e440 | ||
|
|
7a51e0693d | ||
|
|
21785c8e72 | ||
|
|
bdf6ccbd2d | ||
|
|
ceb163570f | ||
|
|
049ae73d74 | ||
|
|
729fdd5c9f | ||
|
|
4dac8ac16c | ||
|
|
220bbc3d2d | ||
|
|
c2a4b32192 | ||
|
|
09d0d47549 | ||
|
|
4185807da4 | ||
|
|
8abda14e0f | ||
|
|
619e5c0895 | ||
|
|
3a2594cde9 | ||
|
|
5cca2d0155 | ||
|
|
a467637cb5 | ||
|
|
1a23001955 | ||
|
|
8942dca31d | ||
|
|
2a919012b6 | ||
|
|
40b342498f | ||
|
|
e220b2818a | ||
|
|
620bf7990f | ||
|
|
0df36d2609 | ||
|
|
adfe50a841 | ||
|
|
35925ddc1b | ||
|
|
33dfb764fa | ||
|
|
49bef2c641 | ||
|
|
ac58536501 | ||
|
|
c344555be3 | ||
|
|
645bcc53c6 | ||
|
|
84dd06dfc4 | ||
|
|
0a73dd6437 | ||
|
|
2cc055a1ad | ||
|
|
d8ec3bd218 | ||
|
|
d189ec74c9 | ||
|
|
4291769b93 | ||
|
|
22900a3f67 | ||
|
|
7fa08449de | ||
|
|
4f7203fccb | ||
|
|
0eea766931 | ||
|
|
5c054aef90 | ||
|
|
a1674d5da1 | ||
|
|
91597a5454 | ||
|
|
11354a3e3f | ||
|
|
dcd4f69383 | ||
|
|
e253939c1e | ||
|
|
f25ce1c0e7 | ||
|
|
7717e57c16 | ||
|
|
2e28c9b06d | ||
|
|
4bc7cd2045 | ||
|
|
5389115120 | ||
|
|
6e99cf6570 | ||
|
|
21bdd9f9ec | ||
|
|
e3ae3f7e6a | ||
|
|
74bf917150 | ||
|
|
5666b263f5 | ||
|
|
fc8fec62a0 | ||
|
|
034d858f18 | ||
|
|
ebc9e1a888 | ||
|
|
c5a9c2bf5a | ||
|
|
3dbce8fd71 | ||
|
|
b2d299dba6 | ||
|
|
cb5d9a8287 | ||
|
|
f9530897c0 | ||
|
|
7c7e8285a4 | ||
|
|
7b3f9a1e0c | ||
|
|
399e0ea0bc | ||
|
|
a47b0bce57 | ||
|
|
4b60b4f73e | ||
|
|
d88b20addd | ||
|
|
5d12cc3f23 | ||
|
|
84fb7ce8b3 | ||
|
|
243cc672f7 | ||
|
|
663546dd77 | ||
|
|
1b79b3f42d |
4
.devcontainer/Dockerfile
Normal file
4
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get install ffmpeg gnupg2 -y
|
||||
ENV NODE_ENV=development
|
||||
12
.devcontainer/devcontainer.json
Normal file
12
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"mounts": [
|
||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"fish": "latest"
|
||||
},
|
||||
"extensions": [
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
76
.github/workflows/docker-build.yml
vendored
Normal file
76
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
# Only build when files in these directories have been changed
|
||||
paths:
|
||||
- client/**
|
||||
- server/**
|
||||
- index.js
|
||||
- package.json
|
||||
# Allows you to run workflow manually from Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: "!contains(github.event.head_commit.message, 'skip ci')"
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
|
||||
tags: |
|
||||
type=edge,branch=master
|
||||
type=semver,pattern={{version}}
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Dockerhub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to ghcr
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
@@ -2,7 +2,7 @@
|
||||
FROM node:16-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm install
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
@@ -14,6 +14,6 @@ COPY index.js index.js
|
||||
COPY package-lock.json package-lock.json
|
||||
COPY package.json package.json
|
||||
COPY server server
|
||||
RUN npm ci --production
|
||||
RUN npm ci --only=production
|
||||
EXPOSE 80
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -2,49 +2,11 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
ABS_LOG_DIR="/var/log/audiobookshelf"
|
||||
|
||||
declare -r init_type='auto'
|
||||
declare -ri no_rebuild='0'
|
||||
|
||||
add_user() {
|
||||
: "${1:?'User was not defined'}"
|
||||
declare -r user="$1"
|
||||
declare -r uid="$2"
|
||||
|
||||
if [ -z "$uid" ]; then
|
||||
declare -r uid_flags=""
|
||||
else
|
||||
declare -r uid_flags="--uid $uid"
|
||||
fi
|
||||
|
||||
declare -r group="${3:-$user}"
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
}
|
||||
|
||||
add_group() {
|
||||
: "${1:?'Group was not defined'}"
|
||||
declare -r group="$1"
|
||||
declare -r gid="$2"
|
||||
|
||||
if [ -z "$gid" ]; then
|
||||
declare -r gid_flags=""
|
||||
else
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
}
|
||||
|
||||
start_service () {
|
||||
: "${1:?'Service name was not defined'}"
|
||||
declare -r service_name="$1"
|
||||
@@ -76,13 +38,10 @@ start_service () {
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
add_group 'audiobookshelf' ''
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
mkdir -p '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
# Create log directory if not there and set ownership
|
||||
if [ ! -d "$ABS_LOG_DIR" ]; then
|
||||
mkdir -p "$ABS_LOG_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
|
||||
fi
|
||||
|
||||
start_service 'audiobookshelf'
|
||||
|
||||
@@ -2,13 +2,51 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
|
||||
|
||||
add_user() {
|
||||
: "${1:?'User was not defined'}"
|
||||
declare -r user="$1"
|
||||
declare -r uid="$2"
|
||||
|
||||
if [ -z "$uid" ]; then
|
||||
declare -r uid_flags=""
|
||||
else
|
||||
declare -r uid_flags="--uid $uid"
|
||||
fi
|
||||
|
||||
declare -r group="${3:-$user}"
|
||||
declare -r descr="${4:-No description}"
|
||||
declare -r shell="${5:-/bin/false}"
|
||||
|
||||
if ! getent passwd | grep -q "^$user:"; then
|
||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||
fi
|
||||
}
|
||||
|
||||
add_group() {
|
||||
: "${1:?'Group was not defined'}"
|
||||
declare -r group="$1"
|
||||
declare -r gid="$2"
|
||||
|
||||
if [ -z "$gid" ]; then
|
||||
declare -r gid_flags=""
|
||||
else
|
||||
declare -r gid_flags="--gid $gid"
|
||||
fi
|
||||
|
||||
if ! getent group | grep -q "^$group:" ; then
|
||||
echo "Creating system group: $group"
|
||||
groupadd $gid_flags --system $group
|
||||
fi
|
||||
}
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
@@ -16,8 +54,9 @@ install_ffmpeg() {
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
mkdir "$FFMPEG_INSTALL_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
cd "$FFMPEG_INSTALL_DIR"
|
||||
fi
|
||||
|
||||
@@ -28,83 +67,23 @@ install_ffmpeg() {
|
||||
echo "Good to go on Ffmpeg... hopefully"
|
||||
}
|
||||
|
||||
should_build_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "You already have a config file. Do you want to use it?"
|
||||
|
||||
options=("Yes" "No")
|
||||
select yn in "${options[@]}"
|
||||
do
|
||||
case $yn in
|
||||
"Yes")
|
||||
false; return
|
||||
;;
|
||||
"No")
|
||||
true; return
|
||||
;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
echo "No existing config found in $CONFIG_PATH"
|
||||
true; return
|
||||
fi
|
||||
}
|
||||
|
||||
setup_config_interactive() {
|
||||
if should_build_config; then
|
||||
echo "Okay, let's setup a new config."
|
||||
|
||||
AUDIOBOOK_PATH=""
|
||||
read -p "
|
||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
||||
|
||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
||||
fi
|
||||
|
||||
DATA_PATH=""
|
||||
read -p "
|
||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
||||
|
||||
if [[ -z "$DATA_PATH" ]]; then
|
||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
||||
fi
|
||||
|
||||
PORT=""
|
||||
read -p "
|
||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT="$DEFAULT_PORT"
|
||||
fi
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DATA_PATH/metadata
|
||||
CONFIG_PATH=$DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
echo "$config_text" > /etc/default/audiobookshelf;
|
||||
|
||||
echo "Config created"
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "Existing config found."
|
||||
cat $CONFIG_PATH
|
||||
else
|
||||
|
||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||
# Create directory and set permissions
|
||||
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||
mkdir "$DEFAULT_DATA_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||
fi
|
||||
|
||||
echo "Creating default config."
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
PORT=$DEFAULT_PORT
|
||||
@@ -118,6 +97,10 @@ setup_config() {
|
||||
fi
|
||||
}
|
||||
|
||||
add_group 'audiobookshelf' ''
|
||||
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
|
||||
@@ -48,7 +48,7 @@ Description: $DESCRIPTION"
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import './fonts.css';
|
||||
@import './transitions.css';
|
||||
@import './draggable.css';
|
||||
@import './defaultStyles.css';
|
||||
|
||||
:root {
|
||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||
@@ -12,18 +13,30 @@
|
||||
height: calc(100% - 64px);
|
||||
max-height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.page.streaming {
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#bookshelf {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
@@ -34,36 +47,25 @@
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar:horizontal {
|
||||
height: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(0,0,0,0);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
/* ::-webkit-scrollbar-track:horizontal { */
|
||||
/* background: rgb(149, 119, 90); */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
} */
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
background: #855620;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* box-shadow: 2px 14px 8px #111111aa;
|
||||
border-radius: 4px;
|
||||
} */
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
background: #704922;
|
||||
}
|
||||
|
||||
.no-scroll::-webkit-scrollbar {
|
||||
@@ -71,6 +73,13 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.no-scroll {
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
.no-spinner::-webkit-outer-spin-button,
|
||||
.no-spinner::-webkit-inner-spin-button {
|
||||
@@ -89,18 +98,23 @@ input[type=number] {
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable tr:hover {
|
||||
background-color: #474747;
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
@@ -113,13 +127,22 @@ input[type=number] {
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
|
||||
.arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.triangle-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-top: 8px solid rgb(34,127,35);
|
||||
border-right: 8px solid rgb(34,127,35);
|
||||
border-top: 8px solid rgb(34, 127, 35);
|
||||
border-right: 8px solid rgb(34, 127, 35);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
@@ -149,6 +172,7 @@ input[type=number] {
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -165,9 +189,9 @@ input[type=number] {
|
||||
Bookshelf Label
|
||||
*/
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
@@ -194,8 +218,11 @@ Bookshelf Label
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px; /* fallback */
|
||||
max-height: 32px; /* fallback */
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
55
client/assets/defaultStyles.css
Normal file
55
client/assets/defaultStyles.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
|
||||
This is for setting regular html styles for places where embedding HTML will be
|
||||
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||
|
||||
*/
|
||||
|
||||
.default-style p {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.default-style a {
|
||||
text-decoration: none;
|
||||
color: #5985ff;
|
||||
}
|
||||
|
||||
.default-style ul {
|
||||
display: block;
|
||||
list-style: circle;
|
||||
list-style-type: disc;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style ol {
|
||||
display: block;
|
||||
list-style: decimal;
|
||||
list-style-type: decimal;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style li {
|
||||
display: list-item;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
.default-style li::marker {
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-transform: none;
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
@@ -23,12 +23,13 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
@@ -40,9 +41,9 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -56,6 +57,7 @@
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
@@ -64,4 +66,274 @@
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
563
client/assets/trix.css
Normal file
563
client/assets/trix.css
Normal file
@@ -0,0 +1,563 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/*
|
||||
Trix 1.3.1
|
||||
Copyright © 2020 Basecamp, LLC
|
||||
http://trix-editor.org/*/
|
||||
trix-editor {
|
||||
border: 1px solid rgb(75, 85, 99);
|
||||
border-radius: 3px;
|
||||
background: rgb(35, 35, 35);
|
||||
margin: 0;
|
||||
padding: 0.4em 0.6em;
|
||||
min-height: 5em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
trix-toolbar * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgb(75, 85, 99);
|
||||
border-top-color: rgb(75, 85, 99);
|
||||
border-bottom-color: rgb(75, 85, 99);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 1.5vw;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button-group:not(:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button-group-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:not(:first-child) {
|
||||
border-left: 1px solid rgb(75, 85, 99);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button.trix-active {
|
||||
background: #bbb;
|
||||
color: black;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
background: rgb(35, 35, 35);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button:disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button {
|
||||
letter-spacing: -0.01em;
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon {
|
||||
font-size: inherit;
|
||||
width: 2.6em;
|
||||
height: 1.6em;
|
||||
max-width: calc(0.8em + 4vw);
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button--icon {
|
||||
height: 2em;
|
||||
max-width: calc(0.8em + 3.5vw);
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.6;
|
||||
content: "";
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
@media (max-device-width: 768px) {
|
||||
trix-toolbar .trix-button--icon::before {
|
||||
right: 6%;
|
||||
left: 6%;
|
||||
}
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon.trix-active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon:disabled::before {
|
||||
opacity: 0.125;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-attach::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M16.5%206v11.5a4%204%200%201%201-8%200V5a2.5%202.5%200%200%201%205%200v10.5a1%201%200%201%201-2%200V6H10v9.5a2.5%202.5%200%200%200%205%200V5a4%204%200%201%200-8%200v12.5a5.5%205.5%200%200%200%2011%200V6h-1.5z%22%2F%3E%3C%2Fsvg%3E);
|
||||
top: 8%;
|
||||
bottom: 4%;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-bold::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M15.6%2011.8c1-.7%201.6-1.8%201.6-2.8a4%204%200%200%200-4-4H7v14h7c2.1%200%203.7-1.7%203.7-3.8%200-1.5-.8-2.8-2.1-3.4zM10%207.5h3a1.5%201.5%200%201%201%200%203h-3v-3zm3.5%209H10v-3h3.5a1.5%201.5%200%201%201%200%203z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-italic::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M10%205v3h2.2l-3.4%208H6v3h8v-3h-2.2l3.4-8H18V5h-8z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-link::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M9.88%2013.7a4.3%204.3%200%200%201%200-6.07l3.37-3.37a4.26%204.26%200%200%201%206.07%200%204.3%204.3%200%200%201%200%206.06l-1.96%201.72a.91.91%200%201%201-1.3-1.3l1.97-1.71a2.46%202.46%200%200%200-3.48-3.48l-3.38%203.37a2.46%202.46%200%200%200%200%203.48.91.91%200%201%201-1.3%201.3z%22%2F%3E%3Cpath%20d%3D%22M4.25%2019.46a4.3%204.3%200%200%201%200-6.07l1.93-1.9a.91.91%200%201%201%201.3%201.3l-1.93%201.9a2.46%202.46%200%200%200%203.48%203.48l3.37-3.38c.96-.96.96-2.52%200-3.48a.91.91%200%201%201%201.3-1.3%204.3%204.3%200%200%201%200%206.07l-3.38%203.38a4.26%204.26%200%200%201-6.07%200z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-strike::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.73%2014l.28.14c.26.15.45.3.57.44.12.14.18.3.18.5%200%20.3-.15.56-.44.75-.3.2-.76.3-1.39.3A13.52%2013.52%200%200%201%207%2014.95v3.37a10.64%2010.64%200%200%200%204.84.88c1.26%200%202.35-.19%203.28-.56.93-.37%201.64-.9%202.14-1.57s.74-1.45.74-2.32c0-.26-.02-.51-.06-.75h-5.21zm-5.5-4c-.08-.34-.12-.7-.12-1.1%200-1.29.52-2.3%201.58-3.02%201.05-.72%202.5-1.08%204.34-1.08%201.62%200%203.28.34%204.97%201l-1.3%202.93c-1.47-.6-2.73-.9-3.8-.9-.55%200-.96.08-1.2.26-.26.17-.38.38-.38.64%200%20.27.16.52.48.74.17.12.53.3%201.05.53H7.23zM3%2013h18v-2H3v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-quote::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M6%2017h3l2-4V7H5v6h3zm8%200h3l2-4V7h-6v6h3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-heading-1::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12%209v3H9v7H6v-7H3V9h9zM8%204h14v3h-6v12h-3V7H8V4z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-code::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.2%2012L15%2015.2l1.4%201.4L21%2012l-4.6-4.6L15%208.8l3.2%203.2zM5.8%2012L9%208.8%207.6%207.4%203%2012l4.6%204.6L9%2015.2%205.8%2012z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-bullet-list::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20version%3D%221%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%204a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm0%206a2%202%200%201%200%200%204%202%202%200%200%200%200-4zm4%203h14v-2H8v2zm0-6h14v-2H8v2zm0-8v2h14V5H8z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-number-list::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M2%2017h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1%203h1.8L2%2013.1v.9h3v-1H3.2L5%2010.9V10H2v1zm5-6v2h14V5H7zm0%2014h14v-2H7v2zm0-6h14v-2H7v2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-undo::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M12.5%208c-2.6%200-5%201-6.9%202.6L2%207v9h9l-3.6-3.6A8%208%200%200%201%2020%2016l2.4-.8a10.5%2010.5%200%200%200-10-7.2z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-redo::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M18.4%2010.6a10.5%2010.5%200%200%200-16.9%204.6L4%2016a8%208%200%200%201%2012.7-3.6L13%2016h9V7l-3.6%203.6z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-decrease-nesting-level::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-8.3-.3l2.8%202.9L6%2014.2%204%2012l2-2-1.4-1.5L1%2012l.7.7zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--icon-increase-nesting-level::before {
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M3%2019h19v-2H3v2zm7-6h12v-2H10v2zm-6.9-1L1%2014.2l1.4%201.4L6%2012l-.7-.7-2.8-2.8L1%209.9%203.1%2012zM3%205v2h19V5H3z%22%2F%3E%3C%2Fsvg%3E);
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialogs {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.75em;
|
||||
padding: 15px 10px;
|
||||
background: rgb(48, 48, 48);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgb(112, 112, 112);
|
||||
border-radius: 5px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-input--dialog {
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0.5em 0.8em;
|
||||
margin: 0 10px 0 0;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #bbb;
|
||||
background-color: rgb(95, 95, 95);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-input--dialog.validate:invalid {
|
||||
box-shadow: #F00 0px 0px 1.5px 1px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-button--dialog {
|
||||
font-size: inherit;
|
||||
padding: 0.5em;
|
||||
border-bottom: none;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog--link {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields .trix-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
trix-toolbar .trix-dialog__link-fields .trix-button-group {
|
||||
flex: 0 0 content;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]:not(.attachment__caption-editor) {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]::-moz-selection,
|
||||
trix-editor [data-trix-cursor-target]::-moz-selection,
|
||||
trix-editor [data-trix-mutable] ::-moz-selection {
|
||||
background: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable]::selection,
|
||||
trix-editor [data-trix-cursor-target]::selection,
|
||||
trix-editor [data-trix-mutable] ::selection {
|
||||
background: none;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection {
|
||||
background: highlight;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection {
|
||||
background: highlight;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment.attachment--file {
|
||||
box-shadow: 0 0 0 2px highlight;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
trix-editor [data-trix-mutable].attachment img {
|
||||
box-shadow: 0 0 0 2px highlight;
|
||||
}
|
||||
|
||||
trix-editor .attachment {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-editor .attachment:hover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
trix-editor .attachment--preview .attachment__caption:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
trix-editor .attachment__progress {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
left: 5%;
|
||||
width: 90%;
|
||||
opacity: 0.9;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
trix-editor .attachment__progress[value="100"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
trix-editor .attachment__caption-editor {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
trix-editor .attachment__toolbar {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -0.9em;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
trix-editor .trix-button-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
trix-editor .trix-button {
|
||||
position: relative;
|
||||
float: left;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
font-size: 80%;
|
||||
padding: 0 0.8em;
|
||||
margin: 0;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
trix-editor .trix-button:not(:first-child) {
|
||||
border-left: 1px solid #ccc;
|
||||
}
|
||||
|
||||
trix-editor .trix-button.trix-active {
|
||||
background: #cbeefa;
|
||||
}
|
||||
|
||||
trix-editor .trix-button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove {
|
||||
text-indent: -9999px;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
width: 1.8em;
|
||||
height: 1.8em;
|
||||
line-height: 1.8em;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 2px solid highlight;
|
||||
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove::before {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.7;
|
||||
content: "";
|
||||
background-image: url(data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.4L17.6%205%2012%2010.6%206.4%205%205%206.4l5.6%205.6L5%2017.6%206.4%2019l5.6-5.6%205.6%205.6%201.4-1.4-5.6-5.6z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 90%;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove:hover {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
trix-editor .trix-button--remove:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 2em;
|
||||
transform: translate(-50%, 0);
|
||||
max-width: 90%;
|
||||
padding: 0.1em 0.6em;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata .attachment__name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: bottom;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
trix-editor .attachment__metadata .attachment__size {
|
||||
margin-left: 0.2em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trix-content {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trix-content * {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.trix-content h1 {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trix-content blockquote {
|
||||
border: 0 solid #ccc;
|
||||
border-left-width: 0.3em;
|
||||
margin-left: 0.3em;
|
||||
padding-left: 0.6em;
|
||||
}
|
||||
|
||||
.trix-content [dir=rtl] blockquote,
|
||||
.trix-content blockquote[dir=rtl] {
|
||||
border-width: 0;
|
||||
border-right-width: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
padding-right: 0.6em;
|
||||
}
|
||||
|
||||
.trix-content li {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.trix-content [dir=rtl] li {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.trix-content pre {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: top;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.5em;
|
||||
white-space: pre;
|
||||
background-color: #eee;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.trix-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.trix-content .attachment {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.trix-content .attachment a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trix-content .attachment a:hover,
|
||||
.trix-content .attachment a:visited:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.trix-content .attachment__caption {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trix-content .attachment__caption .attachment__name+.attachment__size::before {
|
||||
content: ' · ';
|
||||
}
|
||||
|
||||
.trix-content .attachment--preview {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trix-content .attachment--preview .attachment__caption {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.trix-content .attachment--file {
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
margin: 0 2px 2px 2px;
|
||||
padding: 0.4em 1em;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 .attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
||||
<nuxt-link to="/">
|
||||
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search class="hidden md:block" />
|
||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
@@ -22,15 +24,15 @@
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="System Settings" role="button">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -54,10 +56,16 @@
|
||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate && numLibraryItemsSelected < 50">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
<ui-tooltip text="Edit" direction="bottom">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
<ui-tooltip v-if="userCanDelete" text="Delete" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip text="Deselect All" direction="bottom">
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +96,11 @@ export default {
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
@@ -142,15 +147,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
@@ -166,6 +162,7 @@ export default {
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
@@ -228,4 +225,4 @@ export default {
|
||||
#appbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="loaded && !shelves.length && isRootUser && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
@@ -17,7 +13,25 @@
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||
</div>
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-episode-slider>
|
||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||
</widgets-series-slider>
|
||||
<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' }">{{ shelf.label }}</p>
|
||||
</widgets-authors-slider>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<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="bookCoverAspectRatio" />
|
||||
</template>
|
||||
@@ -44,8 +58,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
@@ -56,6 +70,12 @@ export default {
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
@@ -76,9 +96,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
@@ -91,7 +108,7 @@ export default {
|
||||
},
|
||||
async fetchCategories() {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -128,8 +145,7 @@ export default {
|
||||
type: 'series',
|
||||
entities: this.results.series.map((seriesObj) => {
|
||||
return {
|
||||
name: seriesObj.series.name,
|
||||
series: seriesObj.series,
|
||||
...seriesObj.series,
|
||||
books: seriesObj.books,
|
||||
type: 'series'
|
||||
}
|
||||
@@ -167,7 +183,15 @@ export default {
|
||||
},
|
||||
settingsUpdated(settings) {},
|
||||
scan() {
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
@@ -12,18 +17,9 @@
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
||||
<cards-author-card :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
</nuxt-link>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +39,6 @@
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||
</div>
|
||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,14 +60,7 @@ export default {
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null,
|
||||
showAuthorModal: false,
|
||||
selectedAuthor: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isSelectionMode(newVal) {
|
||||
this.updateSelectionMode(newVal)
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -94,18 +82,25 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editAuthor(author) {
|
||||
this.selectedAuthor = author
|
||||
this.showAuthorModal = true
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editBook(audiobook) {
|
||||
var bookIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', audiobook)
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
if (this.shelf.type === 'book') {
|
||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
@@ -113,10 +108,24 @@ export default {
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
} else if (this.shelf.type === 'episode') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
@@ -160,6 +169,14 @@ export default {
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -167,25 +184,13 @@ export default {
|
||||
<style>
|
||||
.categorizedBookshelfRow {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
|
||||
/* background-color: rgb(214, 116, 36); */
|
||||
background-image: var(--bookshelf-texture-img);
|
||||
/* background-position: center; */
|
||||
/* background-size: contain; */
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.categorizedBookshelfRow {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.bookshelfDividerCategorized {
|
||||
background: rgb(149, 119, 90);
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,28 @@
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<template v-if="page !== 'search' && page !== 'podcast-search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="items-center hidden md:flex">
|
||||
<div v-else class="items-center hidden md:flex w-full">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-2xl text-white">west</span>
|
||||
</div>
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }}
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="primary" small :loading="processingSeries" class="flex items-center" @click="markSeriesFinished">
|
||||
<div class="h-5 w-5">
|
||||
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="pl-2"> Mark Series {{ isSeriesFinished ? 'Not Finished' : 'Finished' }}</span></ui-btn
|
||||
>
|
||||
</div>
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
@@ -38,6 +50,8 @@
|
||||
<span class="material-icons" style="font-size: 1.4rem">view_list</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">Remove All {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<template v-else-if="page === 'search'">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -47,6 +61,10 @@
|
||||
<p>Search results for "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
</template>
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">Match All Authors</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,9 +74,16 @@ export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
selectedSeries: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
searchQuery: String,
|
||||
viewMode: String
|
||||
viewMode: String,
|
||||
authors: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -66,10 +91,19 @@ export default {
|
||||
hasInit: false,
|
||||
totalEntities: 0,
|
||||
keywordFilter: null,
|
||||
keywordTimeout: null
|
||||
keywordTimeout: null,
|
||||
processingSeries: false,
|
||||
processingIssues: false,
|
||||
processingAuthors: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
@@ -103,9 +137,97 @@ export default {
|
||||
},
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
seriesName() {
|
||||
return this.selectedSeries ? this.selectedSeries.name : null
|
||||
},
|
||||
seriesProgress() {
|
||||
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||
},
|
||||
seriesLibraryItemIds() {
|
||||
if (!this.seriesProgress) return []
|
||||
return this.seriesProgress.libraryItemIds || []
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async matchAllAuthors() {
|
||||
this.processingAuthors = true
|
||||
|
||||
for (const author of this.authors) {
|
||||
const payload = {}
|
||||
if (author.asin) payload.asin = author.asin
|
||||
else payload.q = author.name
|
||||
console.log('Payload', payload, 'author', author)
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${author.id}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
console.error(`Author ${author.name} not found`)
|
||||
this.$toast.error(`Author ${author.name} not found`)
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) console.log(`Author ${response.author.name} was updated`)
|
||||
else console.log(`Author ${response.author.name} was updated (no image found)`)
|
||||
} else {
|
||||
console.log(`No updates were made for Author ${response.author.name}`)
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, false)
|
||||
}
|
||||
this.processingAuthors = false
|
||||
},
|
||||
removeAllIssues() {
|
||||
if (confirm(`Are you sure you want to remove all library items with issues?\n\nNote: This will not delete any files`)) {
|
||||
this.processingIssues = true
|
||||
this.$axios
|
||||
.$delete(`/api/libraries/${this.currentLibraryId}/issues`)
|
||||
.then(() => {
|
||||
this.$toast.success('Removed library items with issues')
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
this.processingIssues = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove library items with issues', error)
|
||||
this.$toast.error('Failed to remove library items with issues')
|
||||
this.processingIssues = false
|
||||
})
|
||||
}
|
||||
},
|
||||
markSeriesFinished() {
|
||||
var newIsFinished = !this.isSeriesFinished
|
||||
this.processingSeries = true
|
||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
id: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Series update success')
|
||||
this.selectedSeries.progress.isFinished = newIsFinished
|
||||
this.processingSeries = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Series update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.processingSeries = false
|
||||
})
|
||||
},
|
||||
searchBackArrow() {
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
},
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
||||
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<div class="flex justify-between">
|
||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||
|
||||
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,11 +29,17 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userIsRoot() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
configRoutes() {
|
||||
if (!this.userIsRoot) {
|
||||
if (!this.userIsAdminOrUp) {
|
||||
return [
|
||||
{
|
||||
id: 'config-stats',
|
||||
@@ -38,7 +48,7 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
const configRoutes = [
|
||||
{
|
||||
id: 'config',
|
||||
title: 'Settings',
|
||||
@@ -63,18 +73,23 @@ export default {
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
path: '/config/log'
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
if (this.currentLibraryId) {
|
||||
configRoutes.push({
|
||||
id: 'config-library-stats',
|
||||
title: 'Library Stats',
|
||||
path: '/config/library-stats'
|
||||
},
|
||||
{
|
||||
})
|
||||
configRoutes.push({
|
||||
id: 'config-stats',
|
||||
title: 'Your Stats',
|
||||
path: '/config/stats'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return configRoutes
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && isRootUser && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<div class="flex">
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
</div>
|
||||
@@ -22,12 +22,6 @@
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<!-- Experimental Bookshelf Texture -->
|
||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||
<p class="text-sm py-0.5">Texture</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,6 +36,7 @@ export default {
|
||||
mixins: [bookshelfCardsHelpers],
|
||||
data() {
|
||||
return {
|
||||
routeFullPath: null,
|
||||
initialized: false,
|
||||
bookshelfHeight: 0,
|
||||
bookshelfWidth: 0,
|
||||
@@ -61,7 +56,6 @@ export default {
|
||||
totalShelves: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
isSelectionMode: false,
|
||||
isSelectAll: false,
|
||||
currentSFQueryString: null,
|
||||
pendingReset: false,
|
||||
keywordFilter: null,
|
||||
@@ -80,8 +74,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
@@ -90,9 +84,12 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
},
|
||||
emptyMessage() {
|
||||
if (this.page === 'series') return `You have no series`
|
||||
if (this.page === 'series') return 'You have no series'
|
||||
if (this.page === 'collections') return "You haven't made any collections yet"
|
||||
if (this.hasFilter) return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
if (this.hasFilter) {
|
||||
if (this.filterName === 'Issues') return 'No Issues'
|
||||
return `No Results for filter "${this.filterName}: ${this.filterValue}"`
|
||||
}
|
||||
return 'No results'
|
||||
},
|
||||
entityName() {
|
||||
@@ -124,7 +121,7 @@ export default {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
@@ -183,7 +180,10 @@ export default {
|
||||
return 6
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
||||
if (this.isAlternativeBookshelfView) {
|
||||
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
@@ -199,9 +199,6 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showBookshelfTextureModal() {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', true)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
@@ -217,7 +214,6 @@ export default {
|
||||
clearSelectedEntities() {
|
||||
this.updateBookSelectionMode(false)
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
},
|
||||
selectEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
@@ -339,7 +335,6 @@ export default {
|
||||
this.totalEntities = 0
|
||||
this.currentPage = 0
|
||||
this.isSelectionMode = false
|
||||
this.isSelectAll = false
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
@@ -409,6 +404,8 @@ export default {
|
||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -526,6 +523,15 @@ export default {
|
||||
await this.fetchEntites(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
|
||||
// Set last scroll position for this bookshelf page
|
||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||
if (path === this.routeFullPath) {
|
||||
// Exact path match with query so use scroll position
|
||||
window.bookshelf.scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
},
|
||||
executeRebuild() {
|
||||
clearTimeout(this.resizeTimeout)
|
||||
@@ -601,13 +607,25 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initListeners()
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
},
|
||||
updated() {
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
||||
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
||||
@@ -618,6 +636,11 @@ export default {
|
||||
beforeDestroy() {
|
||||
this.destroyEntityComponents()
|
||||
this.removeListeners()
|
||||
|
||||
// Set bookshelf scroll position for specific bookshelf page and query
|
||||
if (window.bookshelf) {
|
||||
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<!-- 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" />
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
@@ -52,7 +55,7 @@
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :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'">
|
||||
<icons-podcast-svg class="w-6 h-6" />
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.9rem">Search</p>
|
||||
@@ -70,6 +73,12 @@
|
||||
<p class="text-xs font-mono pb-0.5">{{ numIssues }}</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
|
||||
<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="font-mono text-xs text-center text-gray-300 leading-3 mb-1">v{{ $config.version }}</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,8 +88,21 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
},
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isShowingBookshelfToolbar() {
|
||||
if (!this.$route.name) return false
|
||||
return this.$route.name.startsWith('library')
|
||||
},
|
||||
offsetTop() {
|
||||
return 64
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
@@ -112,12 +134,30 @@ export default {
|
||||
showLibrary() {
|
||||
return this.libraryBookshelfPage && this.paramId === '' && !this.showingIssues
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
showingIssues() {
|
||||
if (!this.$route.query) return false
|
||||
return this.libraryBookshelfPage && this.$route.query.filter === 'issues'
|
||||
},
|
||||
numIssues() {
|
||||
return this.$store.state.libraries.issues || 0
|
||||
},
|
||||
versionData() {
|
||||
return this.$store.state.versionData || {}
|
||||
},
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
latestVersion() {
|
||||
return this.versionData.latestVersion
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<div id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start pl-24 mb-6 md:mb-0">
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-24'">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||
</div>
|
||||
@@ -25,7 +26,7 @@
|
||||
<div class="flex-grow" />
|
||||
<span class="material-icons px-2 py-1 md:p-4 cursor-pointer" @click="closePlayer">close</span>
|
||||
</div>
|
||||
<audio-player
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:paused="!isPlaying"
|
||||
@@ -69,13 +70,11 @@ export default {
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
@@ -147,6 +146,7 @@ export default {
|
||||
setPlaying(isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
setSleepTimer(seconds) {
|
||||
this.sleepTimerSet = true
|
||||
@@ -204,6 +204,7 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
@@ -238,6 +239,71 @@ export default {
|
||||
this.playerHandler.closePlayer()
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
},
|
||||
mediaSessionPlay() {
|
||||
console.log('Media session play')
|
||||
this.playerHandler.play()
|
||||
},
|
||||
mediaSessionPause() {
|
||||
console.log('Media session pause')
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
mediaSessionStop() {
|
||||
console.log('Media session stop')
|
||||
this.playerHandler.pause()
|
||||
},
|
||||
mediaSessionSeekBackward() {
|
||||
console.log('Media session seek backward')
|
||||
this.playerHandler.jumpBackward()
|
||||
},
|
||||
mediaSessionSeekForward() {
|
||||
console.log('Media session seek forward')
|
||||
this.playerHandler.jumpForward()
|
||||
},
|
||||
mediaSessionSeekTo(e) {
|
||||
console.log('Media session seek to', e)
|
||||
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||
this.playerHandler.seek(e.seekTime)
|
||||
}
|
||||
},
|
||||
updateMediaSessionPlaybackState() {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||
}
|
||||
},
|
||||
setMediaSession() {
|
||||
if (!this.streamLibraryItem) {
|
||||
console.error('setMediaSession: No library item set')
|
||||
return
|
||||
}
|
||||
|
||||
if ('mediaSession' in navigator) {
|
||||
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||
const artwork = [
|
||||
{
|
||||
src: coverImageSrc
|
||||
}
|
||||
]
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: this.title,
|
||||
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||
album: this.mediaMetadata.seriesName || '',
|
||||
artwork
|
||||
})
|
||||
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
// navigator.mediaSession.setActionHandler('previoustrack')
|
||||
// navigator.mediaSession.setActionHandler('nexttrack')
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
@@ -253,7 +319,7 @@ export default {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session)
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
@@ -310,8 +376,7 @@ export default {
|
||||
libraryItem,
|
||||
episodeId
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
<template>
|
||||
<div @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</div>
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -63,20 +65,30 @@ export default {
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
},
|
||||
asin() {
|
||||
return this._author.asin || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseout() {
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||
const payload = {}
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
@@ -89,8 +101,16 @@ export default {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.searching = false
|
||||
},
|
||||
setSearching(isSearching) {
|
||||
this.searching = isSearching
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$eventBus.$on(`searching-author-${this.authorId}`, this.setSearching)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -109,19 +109,14 @@ export default {
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mouseoverCard() {
|
||||
this.isHovering = true
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
||||
},
|
||||
mouseleaveCard() {
|
||||
this.isHovering = false
|
||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
||||
},
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library and podcasts -->
|
||||
<div v-if="!booksInSeries && !isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
<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 ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<!-- More Menu Icon -->
|
||||
<div ref="moreIcon" v-show="!isSelectionMode" 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,13 +78,18 @@
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- Volume number -->
|
||||
<div v-if="seriesSequence && showSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-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' }">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +111,6 @@ export default {
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
@@ -142,9 +147,16 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
enableEReader() {
|
||||
return this.store.getters['getServerSetting']('enableEReader')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
isFile() {
|
||||
// Library item is not in a folder
|
||||
return this._libraryItem.isFile
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
@@ -167,7 +179,7 @@ export default {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
series() {
|
||||
// Only included when filtering by series or collapse series
|
||||
// Only included when filtering by series or collapse series or Continue Series shelf on home page
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
@@ -190,6 +202,17 @@ export default {
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
return this._libraryItem.recentEpisode
|
||||
},
|
||||
recentEpisodeNumber() {
|
||||
if (!this.recentEpisode) return null
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this._libraryItem.collapsedSeries
|
||||
@@ -227,8 +250,11 @@ export default {
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
displayLineTwo() {
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
}
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
@@ -236,11 +262,18 @@ export default {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === '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 === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
// Only used on home page currently listening podcast shelf
|
||||
if (!this.recentEpisode) return null
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
@@ -250,19 +283,20 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -270,22 +304,27 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._libraryItem.hasMissingParts
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._libraryItem.hasInvalidParts
|
||||
numInvalidAudioFiles() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numInvalidAudioFiles
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) return 'Item has no audio tracks & ebook'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
var txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
if (this.numInvalidAudioFiles) {
|
||||
if (txt) txt += ' '
|
||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
@@ -310,10 +349,23 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
userIsAdminOrUp() {
|
||||
return this.store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.recentEpisode) {
|
||||
return [
|
||||
{
|
||||
func: 'editPodcast',
|
||||
text: 'Edit Podcast'
|
||||
},
|
||||
{
|
||||
func: 'toggleFinished',
|
||||
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
var items = []
|
||||
if (!this.isPodcast) {
|
||||
items = [
|
||||
@@ -337,7 +389,7 @@ export default {
|
||||
text: 'Match'
|
||||
})
|
||||
}
|
||||
if (this.userIsRoot) {
|
||||
if (this.userIsAdminOrUp && !this.isFile) {
|
||||
items.push({
|
||||
func: 'rescan',
|
||||
text: 'Re-Scan'
|
||||
@@ -378,8 +430,12 @@ export default {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
isAuthorBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
}
|
||||
@@ -389,7 +445,34 @@ export default {
|
||||
this.isSelectionMode = val
|
||||
if (!val) this.selected = false
|
||||
},
|
||||
setEntity(libraryItem) {
|
||||
setEntity(_libraryItem) {
|
||||
var libraryItem = _libraryItem
|
||||
|
||||
// this code block is only necessary when showing a selected series with sequence #
|
||||
// it will update the selected series so we get realtime updates for series sequence changes
|
||||
if (this.series) {
|
||||
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||
libraryItem = {
|
||||
..._libraryItem,
|
||||
media: {
|
||||
..._libraryItem.media,
|
||||
metadata: {
|
||||
..._libraryItem.media.metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
if (mediaMetadata.series) {
|
||||
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||
if (newSeries) {
|
||||
// update selected series
|
||||
libraryItem.media.metadata.series = newSeries
|
||||
this.libraryItem = libraryItem
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
@@ -406,6 +489,9 @@ export default {
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
if (this.recentEpisode) {
|
||||
return this.$emit('edit', { libraryItem: this.libraryItem, episode: this.recentEpisode })
|
||||
}
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
@@ -413,10 +499,14 @@ export default {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
|
||||
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
|
||||
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
|
||||
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.$patch(apiEndpoint, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
@@ -427,6 +517,9 @@ export default {
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
editPodcast() {
|
||||
this.$emit('editPodcast', this.libraryItem)
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
@@ -529,7 +622,8 @@ export default {
|
||||
play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.recentEpisode ? this.recentEpisode.id : null
|
||||
})
|
||||
},
|
||||
mouseover() {
|
||||
|
||||
@@ -5,20 +5,18 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<!-- <div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
||||
</div> -->
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
</div> -->
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +26,11 @@ export default {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -58,6 +60,10 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,6 +31,10 @@ export default {
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
seriesMount: {
|
||||
type: Object,
|
||||
@@ -61,6 +68,9 @@ export default {
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
},
|
||||
addedAt() {
|
||||
return this.series ? this.series.addedAt : 0
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
.map((libraryItem) => {
|
||||
@@ -86,6 +96,10 @@ export default {
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||
return !!validCovers.length
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
71
client/components/cards/PodcastFeedSummaryCard.vue
Normal file
71
client/components/cards/PodcastFeedSummaryCard.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="w-full p-2 border border-white border-opacity-10 rounded">
|
||||
<div class="flex">
|
||||
<div class="w-16 min-w-16">
|
||||
<div class="w-full h-16 bg-primary">
|
||||
<img v-if="image" :src="image" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p class="text-gray-400 text-xxs pt-1 text-center">{{ numEpisodes }} Episodes</p>
|
||||
</div>
|
||||
<div class="flex-grow pl-2" :style="{ maxWidth: detailsWidth + 'px' }">
|
||||
<p class="mb-1">{{ title }}</p>
|
||||
<p class="text-xs mb-1 text-gray-300">{{ author }}</p>
|
||||
<p class="text-xs mb-2 text-gray-200">{{ description }}</p>
|
||||
<p class="text-xs truncate text-blue-200">
|
||||
Folder: <span class="font-mono">{{ folderPath }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
feed: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryFolderPath: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
width: 900
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.metadata.title || 'No Title'
|
||||
},
|
||||
image() {
|
||||
return this.metadata.imageUrl
|
||||
},
|
||||
description() {
|
||||
return this.metadata.description || ''
|
||||
},
|
||||
author() {
|
||||
return this.metadata.author || ''
|
||||
},
|
||||
metadata() {
|
||||
return this.feed || {}
|
||||
},
|
||||
numEpisodes() {
|
||||
return this.feed.numEpisodes || 0
|
||||
},
|
||||
folderPath() {
|
||||
if (!this.libraryFolderPath) return ''
|
||||
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
|
||||
},
|
||||
detailsWidth() {
|
||||
return this.width - 85
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
updated() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
},
|
||||
mounted() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<!-- <div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div> -->
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
authorName: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
isProcessing: false,
|
||||
provider: 'audnexus',
|
||||
providers: [
|
||||
{
|
||||
text: 'Audnexus',
|
||||
value: 'audnexus'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
authorName: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.searchAuthor = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
return `q=${this.searchAuthor}`
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchAuthor) {
|
||||
this.$toast.warning('Author name is required')
|
||||
return
|
||||
}
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (result) {
|
||||
this.$emit('match', result)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -33,20 +33,20 @@ export default {
|
||||
showMenu: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Current',
|
||||
value: 'index'
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
},
|
||||
{
|
||||
text: 'Title',
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
text: 'Season',
|
||||
value: 'season'
|
||||
},
|
||||
{
|
||||
text: 'Pub Date',
|
||||
value: 'publishedAt'
|
||||
text: 'Episode',
|
||||
value: 'episode'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -166,26 +171,26 @@ export default {
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0]);
|
||||
var filterValue = null;
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
var filterValue = null
|
||||
if (parts.length > 1) {
|
||||
var decoded = this.$decode(parts[1])
|
||||
if (decoded.startsWith('aut_')) {
|
||||
var author = this.authors.find((au) => au.id == decoded)
|
||||
if (author) filterValue = author.name;
|
||||
if (author) filterValue = author.name
|
||||
} else if (decoded.startsWith('ser_')) {
|
||||
var series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
} else {
|
||||
filterValue = decoded;
|
||||
filterValue = decoded
|
||||
}
|
||||
}
|
||||
if (filterName && filterValue) {
|
||||
return `${filterName.text}: ${filterValue}`;
|
||||
return `${filterName.text}: ${filterValue}`
|
||||
} else if (filterName) {
|
||||
return filterName.text;
|
||||
return filterName.text
|
||||
} else if (filterValue) {
|
||||
return filterValue;
|
||||
return filterValue
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
@@ -212,7 +217,7 @@ export default {
|
||||
return ['Finished', 'In Progress', 'Not Started']
|
||||
},
|
||||
missing() {
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Volume Number', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language', ]
|
||||
return ['ASIN', 'ISBN', 'Subtitle', 'Author', 'Publish Year', 'Series', 'Description', 'Genres', 'Tags', 'Narrator', 'Publisher', 'Language']
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<p v-if="bookResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.id}`">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
@@ -31,7 +31,7 @@
|
||||
<p v-if="podcastResults.length" class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Podcasts</p>
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
|
||||
<nuxt-link :to="`/item/${item.id}`">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
|
||||
@@ -52,6 +52,10 @@ export default {
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
@@ -78,6 +82,10 @@ export default {
|
||||
text: 'Size',
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: '# of Episodes',
|
||||
value: 'media.numTracks'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
value: 'birthtimeMs'
|
||||
@@ -131,6 +139,9 @@ export default {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
this.selected = val
|
||||
if (val == 'media.metadata.title' || val == 'media.metadata.author' || val == 'media.metadata.authorName' || val == 'media.metadata.authorNameLF') {
|
||||
this.selectedDesc = false
|
||||
}
|
||||
}
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
|
||||
@@ -44,6 +44,14 @@ export default {
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
},
|
||||
width: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isInit = false
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -51,9 +59,6 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
@@ -8,20 +8,20 @@
|
||||
<form @submit.prevent="submitForm">
|
||||
<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="flex py-2 -mx-2">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="px-2">
|
||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
||||
<div v-show="!isEditingRoot" class="flex py-2">
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :items="accountTypes" @input="userTypeUpdated" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
</div>
|
||||
@@ -65,6 +65,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Access Explicit Content</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Access All Libraries</p>
|
||||
@@ -86,13 +95,13 @@
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" label="Tags Accessible to User" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
<div class="flex pt-4 px-2">
|
||||
<ui-btn v-if="isEditingRoot" to="/account">Change Root Password</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
@@ -116,7 +125,20 @@ export default {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin'],
|
||||
accountTypes: [
|
||||
{
|
||||
text: 'Guest',
|
||||
value: 'guest'
|
||||
},
|
||||
{
|
||||
text: 'User',
|
||||
value: 'user'
|
||||
},
|
||||
{
|
||||
text: 'Admin',
|
||||
value: 'admin'
|
||||
}
|
||||
],
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
}
|
||||
@@ -124,6 +146,7 @@ export default {
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
console.log('accoutn modal show change', newVal)
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
@@ -140,7 +163,7 @@ export default {
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
||||
return this.isNew ? 'Add New Account' : `Update ${(this.account || {}).username}`
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
@@ -161,10 +184,12 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (!val && !this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = this.libraries.map((l) => l.id)
|
||||
} else if (val && this.newUser.itemTagsAccessible.length) {
|
||||
if (val && this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = []
|
||||
}
|
||||
},
|
||||
@@ -197,6 +222,10 @@ export default {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||
this.$toast.error('Must select at least one tag')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="textures" :width="'40vw'" :height="'unset'" :bg-opacity="10" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Bookshelf Texture</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-4 w-full max-w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300" @mousedown.prevent @mouseup.prevent @mousemove.prevent>
|
||||
<h1 class="text-2xl mb-2">Select a bookshelf texture (For testing only)</h1>
|
||||
<div class="overflow-y-hidden overflow-x-auto">
|
||||
<div class="flex -mx-1">
|
||||
<template v-for="texture in textures">
|
||||
<div :key="texture" class="relative mx-1" style="height: 180px; width: 180px; min-width: 180px" @mousedown.prevent @mouseup.prevent>
|
||||
<img :src="texture" class="h-full object-cover cursor-pointer" @click="setTexture(texture)" />
|
||||
<div v-if="texture === selectedBookshelfTexture" class="absolute top-0 left-0 flex items-center justify-center w-full h-full bg-black bg-opacity-10">
|
||||
<span class="material-icons text-4xl text-success">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
textures: ['/textures/wood_default.jpg', '/textures/wood1.png', '/textures/wood2.png', '/textures/wood3.png', '/textures/wood4.png', '/textures/leather1.jpg'],
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showBookshelfTextureModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowBookshelfTextureModal', val)
|
||||
}
|
||||
},
|
||||
selectedBookshelfTexture() {
|
||||
return this.$store.state.selectedBookshelfTexture
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {},
|
||||
setTexture(img) {
|
||||
this.$store.dispatch('setBookshelfTexture', img)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
120
client/components/modals/EditSeriesInputInnerModal.vue
Normal file
120
client/components/modals/EditSeriesInputInnerModal.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 51" @click="clickClose">
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<div ref="content" class="text-white">
|
||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
selectedSeries: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
existingSeriesNames: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.$nextTick(this.setShow)
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitSeriesForm() {
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
|
||||
this.$emit('submit')
|
||||
},
|
||||
clickClose() {
|
||||
this.show = false
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
setShow() {
|
||||
if (!this.el || !this.content) {
|
||||
this.init()
|
||||
}
|
||||
if (!this.el || !this.content) {
|
||||
return
|
||||
}
|
||||
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', true)
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
setHide() {
|
||||
if (this.content) this.content.style.transform = 'scale(0)'
|
||||
if (this.el) this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
|
||||
this.$store.commit('setInnerModalOpen', false)
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
},
|
||||
init() {
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
if (this.content && this.el) {
|
||||
this.el.classList.remove('hidden')
|
||||
this.el.classList.add('flex')
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
150
client/components/modals/ListeningSessionModal.vue
Normal file
150
client/components/modals/ListeningSessionModal.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="listening-session-modal" :width="700" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Session {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="w-full md:w-2/3">
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2">Details</p>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Started At</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Updated At</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Listened for</div>
|
||||
<div class="px-1">
|
||||
{{ $elapsedPrettyExtended(_session.timeListening) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Start Time</div>
|
||||
<div class="px-1">
|
||||
{{ $secondsToTimestamp(_session.startTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Last Time</div>
|
||||
<div class="px-1">
|
||||
{{ $secondsToTimestamp(_session.currentTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Item</p>
|
||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Library Id</div>
|
||||
<div class="px-1">
|
||||
{{ _session.libraryId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Library Item Id</div>
|
||||
<div class="px-1">
|
||||
{{ _session.libraryItemId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Episode Id</div>
|
||||
<div class="px-1">
|
||||
{{ _session.episodeId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Media Type</div>
|
||||
<div class="px-1">
|
||||
{{ _session.mediaType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">Duration</div>
|
||||
<div class="px-1">
|
||||
{{ $elapsedPretty(_session.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">User</p>
|
||||
<p class="mb-1">{{ _session.userId }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Media Player</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">Device</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK Version: {{ deviceInfo.sdkVersion }}</p>
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">Type: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
session: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_session() {
|
||||
return this.session || {}
|
||||
},
|
||||
deviceInfo() {
|
||||
return this._session.deviceInfo || {}
|
||||
},
|
||||
osDisplayName() {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||
},
|
||||
playMethodName() {
|
||||
const playMethod = this._session.playMethod
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
else if (playMethod === this.$constants.PlayMethod.TRANSCODE) return 'Transcode'
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -93,16 +93,18 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
clickBg(ev) {
|
||||
if (!this.show) return
|
||||
if (this.preventClickoutside) {
|
||||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing && this.persistent) return
|
||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (this.$store.state.innerModalOpen) return
|
||||
if (action === this.$hotkeys.Modal.CLOSE) {
|
||||
this.show = false
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
</div>
|
||||
</template>
|
||||
<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 @submit.prevent="submitForm">
|
||||
<form v-if="author" @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<div v-show="!processing" 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,13 +43,13 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
// props: {
|
||||
// value: Boolean,
|
||||
// author: {
|
||||
// type: Object,
|
||||
// default: () => {}
|
||||
// }
|
||||
// },
|
||||
data() {
|
||||
return {
|
||||
authorCopy: {
|
||||
@@ -73,12 +73,15 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showEditAuthorModal
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||
}
|
||||
},
|
||||
author() {
|
||||
return this.$store.state.globals.selectedAuthor
|
||||
},
|
||||
authorId() {
|
||||
if (!this.author) return ''
|
||||
return this.author.id
|
||||
@@ -112,8 +115,10 @@ export default {
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
if (result.updated) this.$toast.success('Author updated')
|
||||
else this.$toast.info('No updates were needed')
|
||||
if (result.updated) {
|
||||
this.$toast.success('Author updated')
|
||||
this.show = false
|
||||
} else this.$toast.info('No updates were needed')
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -134,12 +139,17 @@ export default {
|
||||
this.processing = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
if (!this.authorCopy.name) {
|
||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||
this.$toast.error('Must enter an author name')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
||||
|
||||
const payload = {}
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -62,10 +62,9 @@ export default {
|
||||
component: 'modals-item-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
title: 'Merge',
|
||||
component: 'modals-item-tabs-merge',
|
||||
experimental: true
|
||||
id: 'manage',
|
||||
title: 'Manage',
|
||||
component: 'modals-item-tabs-manage'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -123,12 +122,12 @@ export default {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.id === 'merge' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||
if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false
|
||||
if (this.mediaType == 'podcast' && tab.id == 'chapters') return false
|
||||
if (this.mediaType == 'book' && tab.id == 'episodes') return false
|
||||
|
||||
if ((tab.id === 'merge' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'merge' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
if (tab.id === 'match' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
@@ -181,15 +180,18 @@ export default {
|
||||
if (this.currentBookshelfIndex - 1 < 0) return
|
||||
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
|
||||
this.processing = true
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}`).catch((error) => {
|
||||
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevBook) {
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: prevBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = prevBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', prevBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', prevBookId)
|
||||
}
|
||||
@@ -198,15 +200,18 @@ export default {
|
||||
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
|
||||
this.processing = true
|
||||
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}`).catch((error) => {
|
||||
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextBook) {
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: nextBook, tab: this.selectedTab })
|
||||
this.$nextTick(this.init)
|
||||
this.unregisterListeners()
|
||||
this.libraryItem = nextBook
|
||||
this.selectedTab = 'details'
|
||||
this.$store.commit('setSelectedLibraryItem', nextBook)
|
||||
this.$nextTick(this.registerListeners)
|
||||
} else {
|
||||
console.error('Book not found', nextBookId)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
||||
<p>Audiobook Chapters</p>
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">No Chapters</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||
</div>
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,6 +27,9 @@ export default {
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
||||
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
||||
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||
|
||||
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,8 +53,11 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
isFile() {
|
||||
return !!this.libraryItem && this.libraryItem.isFile
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isMissing() {
|
||||
return !!this.libraryItem && !!this.libraryItem.isMissing
|
||||
@@ -139,19 +146,23 @@ export default {
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.save()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async save() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
if (!this.$refs.itemDetailsEdit) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||
if (!updatedDetails.hasChanges) {
|
||||
this.$toast.info('No changes were made')
|
||||
return
|
||||
return false
|
||||
}
|
||||
this.updateDetails(updatedDetails)
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
@@ -163,11 +174,12 @@ export default {
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success('Item details updated')
|
||||
// this.$emit('close')
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info('No updates were necessary')
|
||||
}
|
||||
}
|
||||
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`)) {
|
||||
@@ -221,8 +233,8 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
#formWrapper {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<!-- <div class="flex items-center mb-4">
|
||||
<p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check for new episodes</ui-btn>
|
||||
</div> -->
|
||||
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
|
||||
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
|
||||
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
|
||||
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="episodes.length" class="w-full p-4 bg-primary">
|
||||
<p>Podcast Episodes</p>
|
||||
@@ -51,10 +51,23 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
checkingNewEpisodes: false
|
||||
checkingNewEpisodes: false,
|
||||
lastEpisodeCheckInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
lastEpisodeCheck: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
autoDownloadEpisodes() {
|
||||
return !!this.media.autoDownloadEpisodes
|
||||
},
|
||||
@@ -72,8 +85,22 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkForNewEpisodes() {
|
||||
async checkForNewEpisodes() {
|
||||
if (this.$refs.lastCheckInput) {
|
||||
this.$refs.lastCheckInput.blur()
|
||||
}
|
||||
this.checkingNewEpisodes = true
|
||||
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
|
||||
|
||||
// If last episode check changed then update it first
|
||||
if (lastEpisodeCheck && lastEpisodeCheck !== this.lastEpisodeCheck) {
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, { lastEpisodeCheck }).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
console.log('updateResult', updateResult)
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
|
||||
.then((response) => {
|
||||
@@ -91,7 +118,13 @@ export default {
|
||||
this.$toast.error(errorMsg)
|
||||
this.checkingNewEpisodes = false
|
||||
})
|
||||
},
|
||||
setLastEpisodeCheckInput() {
|
||||
this.lastEpisodeCheckInput = this.lastEpisodeCheck ? this.$formatDate(this.lastEpisodeCheck, "yyyy-MM-dd'T'HH:mm") : null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setLastEpisodeCheckInput()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<template v-for="audiobook in audiobooks">
|
||||
<tables-tracks-table :key="audiobook.id" :title="`Audiobook Tracks (${audiobook.name})`" :audiobook-id="audiobook.id" :tracks="audiobook.tracks" class="mb-4" />
|
||||
</template>
|
||||
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,12 +47,6 @@ export default {
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
},
|
||||
audiobooks() {
|
||||
return this.media.audiobooks || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.media.ebooks || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
<p class="text-lg">Make M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
@@ -24,13 +25,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-left text-base mb-4 py-4">
|
||||
<!-- Split to mp3 -->
|
||||
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Split M4B to MP3's</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Generate multiple MP3's split by chapters with embedded metadata, cover image, and chapters. <br /><span class="text-warning">*</span> Does not delete existing audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="abmergeStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="abmergeStatus !== $constants.DownloadStatus.READY" :loading="abmergeStatus === $constants.DownloadStatus.PENDING" :disabled="true" @click="startAudiobookMerge">Not yet implemented</ui-btn>
|
||||
<div v-else>
|
||||
<div class="flex">
|
||||
<ui-btn @click="downloadWithProgress(abmergeDownload)">Download</ui-btn>
|
||||
<ui-icon-btn small icon="delete" bg-color="error" class="ml-2" @click="removeDownload" />
|
||||
</div>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(abmergeDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">Embed Metadata</p>
|
||||
<p class="max-w-sm text-sm pt-2 text-gray-300">Embed metadata into audio files including cover image and chapters. <br /><span class="text-warning">*</span> Modifies audio files.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/item/${libraryItemId}/manage`" class="flex items-center"
|
||||
>Open Manager
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="showM4bDownload" class="text-left text-base mb-4 py-4">
|
||||
<span class="text-error">* <strong>Experimental</strong></span
|
||||
> - M4b merge can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted. Download will timeout after 20 minutes.
|
||||
</p>
|
||||
|
||||
<p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p>
|
||||
<p v-else-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||
<!-- <p v-if="isSingleM4b" class="text-lg text-center my-8">Audiobook is already a single m4b!</p> -->
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">No audio tracks to merge</p>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
@@ -70,6 +113,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
@@ -97,9 +143,16 @@ export default {
|
||||
isSingleM4b() {
|
||||
return this.mediaTracks.length === 1 && this.mediaTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
showM4bDownload() {
|
||||
if (this.libraryItem.isMissing || !this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b && this.mediaTracks.length > 0
|
||||
if (!this.mediaTracks.length) return false
|
||||
return !this.isSingleM4b
|
||||
},
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-4 py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
@@ -87,7 +87,7 @@
|
||||
<div v-if="selectedMatch.series" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" label="Series" />
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.seriesName || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +95,27 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.volumeNumber" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.volumeNumber" :disabled="!selectedMatchUsage.volumeNumber" label="Volume Number" class="flex-grow ml-4" />
|
||||
</div>
|
||||
<div v-if="selectedMatch.genres" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" label="Genres" />
|
||||
<p v-if="mediaMetadata.genresList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.genresList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" label="Tags" />
|
||||
<p v-if="mediaMetadata.tagsList" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.tagsList || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" label="Language" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">Currently: {{ mediaMetadata.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatch.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" />
|
||||
<div class="flex-grow ml-4">
|
||||
@@ -177,6 +198,10 @@ export default {
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
// Podcast specific
|
||||
@@ -204,6 +229,22 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
seriesItems: {
|
||||
get() {
|
||||
return this.selectedMatch.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
console.log('set series items', val)
|
||||
this.selectedMatch.series = val
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -294,6 +335,10 @@ export default {
|
||||
publishedYear: true,
|
||||
series: true,
|
||||
volumeNumber: true,
|
||||
genres: true,
|
||||
tags: true,
|
||||
language: true,
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
// Podcast specific
|
||||
@@ -320,36 +365,69 @@ export default {
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
selectMatch(match) {
|
||||
if (match && match.series) {
|
||||
match.series = match.series.map((se) => {
|
||||
return {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
displayName: se.volumeNumber ? `${se.series} #${se.volumeNumber}` : se.series,
|
||||
name: se.series,
|
||||
sequence: se.volumeNumber || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.selectedMatch = match
|
||||
},
|
||||
buildMatchUpdatePayload() {
|
||||
var updatePayload = {}
|
||||
updatePayload.metadata = {}
|
||||
|
||||
var volumeNumber = this.selectedMatchUsage.volumeNumber ? this.selectedMatch.volumeNumber || null : null
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] && this.selectedMatch[key]) {
|
||||
if (key === 'series') {
|
||||
var seriesItem = {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key],
|
||||
sequence: volumeNumber
|
||||
var seriesPayload = []
|
||||
if (!Array.isArray(this.selectedMatch[key])) {
|
||||
seriesPayload.push({
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key],
|
||||
sequence: volumeNumber
|
||||
})
|
||||
} else {
|
||||
this.selectedMatch[key].forEach((seriesItem) =>
|
||||
seriesPayload.push({
|
||||
id: seriesItem.id,
|
||||
name: seriesItem.name,
|
||||
sequence: seriesItem.sequence
|
||||
})
|
||||
)
|
||||
}
|
||||
updatePayload.series = [seriesItem]
|
||||
|
||||
updatePayload.metadata.series = seriesPayload
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authorItem = {
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: this.selectedMatch[key]
|
||||
}
|
||||
updatePayload.authors = [authorItem]
|
||||
if (!Array.isArray(this.selectedMatch[key])) this.selectedMatch[key] = [this.selectedMatch[key]]
|
||||
var authorPayload = []
|
||||
this.selectedMatch[key].forEach((authorName) =>
|
||||
authorPayload.push({
|
||||
id: `new-${Math.floor(Math.random() * 10000)}`,
|
||||
name: authorName
|
||||
})
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.narrators = [this.selectedMatch[key]]
|
||||
updatePayload.metadata.narrators = [this.selectedMatch[key]]
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = this.selectedMatch[key].split(',')
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',')
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.itunesId = Number(this.selectedMatch[key])
|
||||
} else if (key !== 'volumeNumber') {
|
||||
updatePayload[key] = this.selectedMatch[key]
|
||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||
} else {
|
||||
updatePayload.metadata[key] = this.selectedMatch[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatePayload
|
||||
},
|
||||
async submitMatchUpdate() {
|
||||
@@ -359,9 +437,9 @@ export default {
|
||||
}
|
||||
this.isProcessing = true
|
||||
|
||||
if (updatePayload.cover) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
var coverPayload = {
|
||||
url: updatePayload.cover
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
@@ -373,13 +451,11 @@ export default {
|
||||
this.$toast.error('Item Cover Failed to Update')
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.cover
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var mediaUpdatePayload = {
|
||||
metadata: updatePayload
|
||||
}
|
||||
var mediaUpdatePayload = updatePayload
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
|
||||
@@ -28,10 +28,9 @@
|
||||
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||
</div>
|
||||
|
||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
||||
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">Browse for Folder</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,6 +76,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
browseForFolder() {
|
||||
this.showDirectoryPicker = true
|
||||
},
|
||||
getLibraryData() {
|
||||
return {
|
||||
name: this.name,
|
||||
|
||||
@@ -93,7 +93,9 @@ export default {
|
||||
icon: 'database',
|
||||
mediaType: 'book',
|
||||
settings: {
|
||||
disableWatcher: false
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -191,6 +193,11 @@ export default {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||
if (!this.$store.state.libraries.currentLibraryId) {
|
||||
console.log('Setting initially library id', res.id)
|
||||
// First library added
|
||||
this.$store.dispatch('libraries/fetch', res.id)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +35,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
provider: null,
|
||||
disableWatcher: false
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -45,7 +59,9 @@ export default {
|
||||
getLibraryData() {
|
||||
return {
|
||||
settings: {
|
||||
disableWatcher: !!this.disableWatcher
|
||||
disableWatcher: !!this.disableWatcher,
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -54,6 +70,8 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.disableWatcher = !!this.librarySettings.disableWatcher
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="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" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.season" label="Season" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" label="Episode" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" />
|
||||
</div>
|
||||
<div class="w-1/3 p-1">
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
@@ -22,8 +25,8 @@
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" />
|
||||
</div>
|
||||
<div class="w-full p-1">
|
||||
<ui-textarea-with-label v-model="newEpisode.description" label="Description" :rows="8" />
|
||||
<div class="w-full p-1 default-style">
|
||||
<ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
@@ -35,21 +38,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newEpisode: {
|
||||
season: null,
|
||||
episode: null,
|
||||
episodeType: null,
|
||||
title: null,
|
||||
@@ -72,12 +65,18 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showEditPodcastEpisode
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
@@ -97,6 +96,7 @@ export default {
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.newEpisode.season = this.episode.season || ''
|
||||
this.newEpisode.episode = this.episode.episode || ''
|
||||
this.newEpisode.episodeType = this.episode.episodeType || ''
|
||||
this.newEpisode.title = this.episode.title || ''
|
||||
|
||||
@@ -124,7 +124,13 @@ export default {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
}
|
||||
|
||||
console.log('Podcast payload', episodesToDownload)
|
||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||
var sizeInMb = payloadSize / 1024 / 1024
|
||||
var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
@@ -142,9 +148,11 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) { // Do not include episodes already downloaded
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
// Do not include episodes already downloaded
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,42 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1200" :height="'unset'" :processing="processing">
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</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 class="flex flex-wrap">
|
||||
<div class="w-full md:w-1/2 p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
<div class="flex flex-wrap">
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="p-1 w-full">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
</div>
|
||||
<div class="w-full p-4">
|
||||
<p class="text-lg font-semibold mb-2">Details</p>
|
||||
|
||||
<div v-if="podcast.imageUrl" class="p-1 w-full">
|
||||
<img :src="podcast.imageUrl" class="h-16 w-16 object-contain" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.title" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-4">
|
||||
<p class="text-lg font-semibold mb-2">Episodes</p>
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div class="relative">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p class="font-semibold text-gray-200">Select all episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(episode, index) in episodes" :key="index" class="relative cursor-pointer" :class="selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25 hover:bg-opacity-40' : 'bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<ui-checkbox v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" label="Feed URL" readonly />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" label="Genres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" label="Description" :rows="3" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" @input="folderUpdated" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 p-2">
|
||||
<ui-text-input-with-label v-model="fullPath" label="Podcast Path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +45,7 @@
|
||||
<div class="px-4">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" label="Auto Download Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
<ui-btn color="success" :disabled="disableSubmit" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<ui-btn color="success" @click="submit">Add Podcast</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -104,8 +83,7 @@ export default {
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
},
|
||||
selectedEpisodes: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -127,16 +105,6 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectAll: {
|
||||
get() {
|
||||
return this.episodesSelected.length == this.episodes.length
|
||||
},
|
||||
set(val) {
|
||||
for (const key in this.selectedEpisodes) {
|
||||
this.selectedEpisodes[key] = val
|
||||
}
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this._podcastData.title
|
||||
},
|
||||
@@ -166,17 +134,6 @@ export default {
|
||||
if (!this.podcastFeedData) return []
|
||||
return this.podcastFeedData.episodes || []
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
},
|
||||
disableSubmit() {
|
||||
return !this.episodesSelected.length && !this.podcast.autoDownloadEpisodes
|
||||
},
|
||||
buttonText() {
|
||||
if (!this.episodesSelected.length) return 'Add Podcast'
|
||||
if (this.episodesSelected.length == 1) return 'Add Podcast & Download 1 Episode'
|
||||
return `Add Podcast & Download ${this.episodesSelected.length} Episodes`
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
@@ -194,17 +151,9 @@ export default {
|
||||
this.fullPath = ''
|
||||
return
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
||||
},
|
||||
toggleSelectEpisode(index) {
|
||||
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
if (this.episodesSelected.length) {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
}
|
||||
|
||||
const podcastPayload = {
|
||||
path: this.fullPath,
|
||||
folderId: this.selectedFolderId,
|
||||
@@ -224,8 +173,7 @@ export default {
|
||||
language: this.podcast.language
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
},
|
||||
episodesToDownload
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
@@ -260,10 +208,6 @@ export default {
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
|
||||
168
client/components/modals/podcast/OpmlFeedsModal.vue
Normal file
168
client/components/modals/podcast/OpmlFeedsModal.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div class="w-full p-4">
|
||||
<div class="flex items-center -mx-2 mb-2">
|
||||
<div class="w-full md:w-2/3 p-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" label="Folder" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/3 p-2 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="text-sm font-semibold pl-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold mb-2">Podcasts to Add</p>
|
||||
|
||||
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||
<template v-for="(feed, index) in feedMetadata">
|
||||
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">Add Podcasts</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
feeds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
autoDownloadEpisodes: false,
|
||||
feedMetadata: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return 'OPML Feeds'
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
folders() {
|
||||
if (!this.currentLibrary) return []
|
||||
return this.currentLibrary.folders || []
|
||||
},
|
||||
folderItems() {
|
||||
return this.folders.map((fold) => {
|
||||
return {
|
||||
value: fold.id,
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toFeedMetadata(feed) {
|
||||
var metadata = feed.metadata
|
||||
return {
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...metadata.categories],
|
||||
feedUrl: metadata.feedUrl,
|
||||
imageUrl: metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
this.processing = true
|
||||
var newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
return {
|
||||
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
...metadata
|
||||
},
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('New feed payloads', newFeedPayloads)
|
||||
|
||||
for (const podcastPayload of newFeedPayloads) {
|
||||
await this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then(() => {
|
||||
this.$toast.success(`${podcastPayload.media.metadata.title}: Podcast created successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to create podcast'
|
||||
console.error('Failed to create podcast', podcastPayload, error)
|
||||
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||
})
|
||||
}
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
90
client/components/modals/podcast/RemoveEpisode.vue
Normal file
90
client/components/modals/podcast/RemoveEpisode.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<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 class="mb-4">
|
||||
<p class="text-lg text-gray-200 mb-4">
|
||||
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||
>?
|
||||
</p>
|
||||
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
||||
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hardDeleteFile: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
if (newVal) this.hardDeleteFile = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return 'Remove Episode'
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
episodeTitle() {
|
||||
return this.episode ? this.episode.title : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.processing = true
|
||||
|
||||
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Podcast episode removed')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
75
client/components/modals/podcast/ViewEpisode.vue
Normal file
75
client/components/modals/podcast/ViewEpisode.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Episode</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
<div class="flex mb-4">
|
||||
<div class="w-12 h-12">
|
||||
<covers-book-cover :library-item="libraryItem" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-base mb-1">{{ podcastTitle }}</p>
|
||||
<p class="text-xs text-gray-300">{{ podcastAuthor }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" class="default-style" v-html="description" />
|
||||
<p v-else class="mb-2">No description</p>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showViewPodcastEpisodeModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowViewPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode || {}
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode.id
|
||||
},
|
||||
title() {
|
||||
return this.episode.title || 'No Episode Title'
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
podcastTitle() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
166
client/components/modals/rssfeed/ViewModal.vue
Normal file
166
client/components/modals/rssfeed/ViewModal.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<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="currentFeedUrl" class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">Podcast RSS Feed is Open</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="currentFeedUrl" 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(currentFeedUrl)">content_copy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
||||
|
||||
<div class="w-full relative mb-2">
|
||||
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
||||
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||
</div>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
feedUrl: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newFeedSlug: null,
|
||||
currentFeedUrl: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
demoFeedUrl() {
|
||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
hasEpisodesWithoutPubDate() {
|
||||
return this.episodes.some((ep) => !ep.pubDate)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFeed() {
|
||||
if (!this.newFeedSlug) {
|
||||
this.$toast.error('Must set a feed slug')
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||
if (this.newFeedSlug !== sanitized) {
|
||||
this.newFeedSlug = sanitized
|
||||
this.$toast.warning('Slug had to be modified - Run again')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = 'http://localhost:3333'
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.currentFeedUrl = data.feedUrl
|
||||
} else {
|
||||
const errorMsg = data.error || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to open RSS Feed', error)
|
||||
this.$toast.error()
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
this.$copyToClipboard(str, this)
|
||||
},
|
||||
closeFeed() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
|
||||
.then(() => {
|
||||
this.$toast.success('RSS Feed Closed')
|
||||
this.show = false
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close RSS feed', error)
|
||||
this.processing = false
|
||||
this.$toast.error()
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (!this.libraryItem) return
|
||||
this.newFeedSlug = this.libraryItem.id
|
||||
this.currentFeedUrl = this.feedUrl
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
65
client/components/player/PlayerPlaybackControls.vue
Normal file
65
client/components/player/PlayerPlaybackControls.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
seekLoading: Boolean,
|
||||
playbackRate: Number,
|
||||
paused: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
},
|
||||
restart() {
|
||||
this.$emit('restart')
|
||||
},
|
||||
jumpBackward() {
|
||||
this.$emit('jumpBackward')
|
||||
},
|
||||
jumpForward() {
|
||||
this.$emit('jumpForward')
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
185
client/components/player/PlayerTrackBar.vue
Normal file
185
client/components/player/PlayerTrackBar.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean,
|
||||
duration: Number,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
trackWidth: 0,
|
||||
currentTime: 0,
|
||||
percentReady: 0,
|
||||
bufferTime: 0,
|
||||
chapterTicks: [],
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
playedTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
bufferTrackWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
duration: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.setChapterTicks()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
setBufferTime(time) {
|
||||
this.bufferTime = time
|
||||
this.updateBufferTrack()
|
||||
},
|
||||
updateBufferTrack() {
|
||||
var bufferlen = (this.bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
if (this.$refs.bufferTrack) this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
setPercentageReady(percent) {
|
||||
this.percentReady = percent
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updatePlayedTrackWidth()
|
||||
},
|
||||
updatePlayedTrackWidth() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
if (this.$refs.playedTrack) this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
|
||||
console.log('Mousemove track', this.trackWidth, this.duration)
|
||||
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setTrackWidth()
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="w-full -mt-6">
|
||||
<div class="w-full relative mb-1">
|
||||
<div class="absolute -top-10 md:top-0 right-0 md:right-2 flex items-center h-full">
|
||||
<span class="material-icons text-2xl cursor-pointer" @click="toggleFullscreen(true)">expand_less</span>
|
||||
|
||||
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" class="mx-2 hidden md:block" />
|
||||
|
||||
<div class="cursor-pointer text-gray-300 mx-1 md:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||
@@ -21,57 +23,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||
<div class="flex-grow" />
|
||||
<template v-if="!loading">
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
|
||||
<span class="material-icons text-3xl">first_page</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||
<span class="material-icons text-3xl">replay_10</span>
|
||||
</div>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||
</div>
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
<span class="material-icons">autorenew</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate="playbackRate" :paused="paused" @restart="restart" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<!-- Track -->
|
||||
<div ref="track" class="w-full h-2 bg-gray-700 relative cursor-pointer transform duration-100 hover:scale-y-125 overflow-hidden" @mousemove="mousemoveTrack" @mouseleave="mouseleaveTrack" @click.stop="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferTrack" class="h-full bg-gray-400 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-30 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
<p class="font-mono text-sm text-gray-100 pointer-events-auto"> / {{ progressPercent }}%</p>
|
||||
@@ -106,17 +62,11 @@ export default {
|
||||
return {
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
trackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
bufferTrackWidth: 0,
|
||||
readyTrackWidth: 0,
|
||||
audioEl: null,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16, // Track is 16px from edge
|
||||
duration: 0,
|
||||
chapterTicks: []
|
||||
duration: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -154,26 +104,37 @@ export default {
|
||||
currentChapterName() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
isFullscreen() {
|
||||
return this.$store.state.playerIsFullscreen
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullscreen(isFullscreen) {
|
||||
this.$store.commit('setPlayerIsFullscreen', isFullscreen)
|
||||
|
||||
var videoPlayerEl = document.getElementById('video-player')
|
||||
if (videoPlayerEl) {
|
||||
if (isFullscreen) {
|
||||
videoPlayerEl.style.width = '100vw'
|
||||
videoPlayerEl.style.height = '100vh'
|
||||
videoPlayerEl.style.top = '0px'
|
||||
videoPlayerEl.style.left = '0px'
|
||||
} else {
|
||||
videoPlayerEl.style.width = '384px'
|
||||
videoPlayerEl.style.height = '216px'
|
||||
videoPlayerEl.style.top = 'unset'
|
||||
videoPlayerEl.style.bottom = '80px'
|
||||
videoPlayerEl.style.left = '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.duration = duration
|
||||
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
this.updateTimestamp()
|
||||
this.updatePlayedTrack()
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setCurrentTime(time)
|
||||
},
|
||||
playPause() {
|
||||
this.$emit('playPause')
|
||||
@@ -226,67 +187,11 @@ export default {
|
||||
seek(time) {
|
||||
this.$emit('seek', time)
|
||||
},
|
||||
playbackRateUpdated(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.setPlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.duration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
this.$refs.trackCursor.style.left = offsetX - 1 + 'px'
|
||||
}
|
||||
},
|
||||
mouseleaveTrack() {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(1)
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
@@ -301,10 +206,7 @@ export default {
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
@@ -315,36 +217,9 @@ export default {
|
||||
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
|
||||
ts.innerText = currTimeClean
|
||||
},
|
||||
updatePlayedTrack() {
|
||||
var perc = this.currentTime / this.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
var time = perc * this.duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (!this.audioEl) {
|
||||
return
|
||||
}
|
||||
var bufferlen = (bufferTime / this.duration) * this.trackWidth
|
||||
bufferlen = Math.round(bufferlen)
|
||||
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
|
||||
this.$refs.bufferTrack.style.width = bufferlen + 'px'
|
||||
this.bufferTrackWidth = bufferlen
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
},
|
||||
showChapters() {
|
||||
if (!this.chapters.length) return
|
||||
@@ -353,14 +228,6 @@ export default {
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setTrackWidth()
|
||||
},
|
||||
setTrackWidth() {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
@@ -368,6 +235,11 @@ export default {
|
||||
}
|
||||
},
|
||||
closePlayer() {
|
||||
if (this.isFullscreen) {
|
||||
this.toggleFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.loading) return
|
||||
this.$emit('close')
|
||||
},
|
||||
@@ -382,19 +254,14 @@ export default {
|
||||
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.DECREASE_PLAYBACK_RATE) this.decreasePlaybackRate()
|
||||
else if (action === this.$hotkeys.AudioPlayer.CLOSE) this.closePlayer()
|
||||
},
|
||||
windowResize() {
|
||||
this.setTrackWidth()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResize)
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
}
|
||||
273
client/components/stats/Heatmap.vue
Normal file
273
client/components/stats/Heatmap.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div id="heatmap" class="w-full">
|
||||
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||
|
||||
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||
|
||||
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||
|
||||
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||
<div class="flex-grow" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
daysListening: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
contentWidth: 0,
|
||||
maxInnerWidth: 0,
|
||||
innerHeight: 13 * 7,
|
||||
blockWidth: 13,
|
||||
data: [],
|
||||
monthLabels: [],
|
||||
tooltipEl: null,
|
||||
tooltipTextEl: null,
|
||||
tooltipArrowEl: null,
|
||||
showingTooltipIndex: -1,
|
||||
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
// GH Colors
|
||||
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
weeksToShow() {
|
||||
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||
},
|
||||
innerWidth() {
|
||||
return (this.weeksToShow + 1) * 13
|
||||
},
|
||||
daysToShow() {
|
||||
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||
},
|
||||
dayOfWeekToday() {
|
||||
return new Date().getDay()
|
||||
},
|
||||
firstWeekStart() {
|
||||
return this.$addDaysToToday(-this.daysToShow)
|
||||
},
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
label: 'Mon',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
legendBlocks() {
|
||||
return [
|
||||
{
|
||||
id: 'legend-0',
|
||||
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-1',
|
||||
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-2',
|
||||
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-3',
|
||||
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
},
|
||||
{
|
||||
id: 'legend-4',
|
||||
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
destroyTooltip() {
|
||||
if (this.tooltipEl) this.tooltipEl.remove()
|
||||
this.tooltipEl = null
|
||||
this.showingTooltipIndex = -1
|
||||
},
|
||||
createTooltip() {
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||
tooltip.style.display = 'none'
|
||||
tooltip.id = 'heatmap-tooltip'
|
||||
|
||||
const tooltipText = document.createElement('p')
|
||||
tooltipText.innerText = 'Tooltip'
|
||||
tooltipText.style.fontSize = '10px'
|
||||
tooltipText.style.lineHeight = '10px'
|
||||
tooltip.appendChild(tooltipText)
|
||||
|
||||
const tooltipArrow = document.createElement('div')
|
||||
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||
tooltip.appendChild(tooltipArrow)
|
||||
|
||||
this.tooltipEl = tooltip
|
||||
this.tooltipTextEl = tooltipText
|
||||
this.tooltipArrowEl = tooltipArrow
|
||||
|
||||
document.body.appendChild(this.tooltipEl)
|
||||
},
|
||||
showTooltip(index, block, rect) {
|
||||
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||
if (!this.tooltipEl) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
this.showingTooltipIndex = index
|
||||
this.tooltipEl.style.display = 'block'
|
||||
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||
|
||||
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||
|
||||
const w = calculateRect.width / 2
|
||||
var left = rect.x - w
|
||||
var offsetX = 0
|
||||
if (left < 0) {
|
||||
offsetX = Math.abs(left)
|
||||
left = 0
|
||||
} else if (rect.x + w > window.innerWidth - 10) {
|
||||
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||
left += offsetX
|
||||
}
|
||||
|
||||
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||
},
|
||||
hideTooltip() {
|
||||
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||
this.tooltipEl.style.display = 'none'
|
||||
this.showingTooltipIndex = -1
|
||||
}
|
||||
},
|
||||
mouseover(e) {
|
||||
if (isNaN(e.target.dataset.index)) {
|
||||
this.hideTooltip()
|
||||
return
|
||||
}
|
||||
var block = this.data[e.target.dataset.index]
|
||||
var rect = e.target.getBoundingClientRect()
|
||||
this.showTooltip(e.target.dataset.index, block, rect)
|
||||
},
|
||||
mouseout(e) {
|
||||
this.hideTooltip()
|
||||
},
|
||||
buildData() {
|
||||
this.data = []
|
||||
|
||||
var maxValue = 0
|
||||
var minValue = 0
|
||||
Object.values(this.daysListening).forEach((val) => {
|
||||
if (val > maxValue) maxValue = val
|
||||
if (!minValue || val < minValue) minValue = val
|
||||
})
|
||||
const range = maxValue - minValue + 0.01
|
||||
|
||||
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
|
||||
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||
const monthString = this.$formatJsDate(date, 'MMM')
|
||||
const value = this.daysListening[dateString] || 0
|
||||
const x = col * 13
|
||||
const y = row * 13
|
||||
|
||||
var bgColor = this.bgColors[0]
|
||||
var outlineColor = this.outlineColors[0]
|
||||
if (value) {
|
||||
outlineColor = this.outlineColors[1]
|
||||
var percentOfAvg = (value - minValue) / range
|
||||
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||
bgColor = this.bgColors[bgIndex] || 'red'
|
||||
}
|
||||
|
||||
this.data.push({
|
||||
date,
|
||||
dateString,
|
||||
datePretty,
|
||||
monthString,
|
||||
dayOfMonth: Number(dateString.split('-').pop()),
|
||||
yearString: dateString.split('-').shift(),
|
||||
value,
|
||||
col,
|
||||
row,
|
||||
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||
})
|
||||
}
|
||||
console.log('Data', this.data)
|
||||
|
||||
this.monthLabels = []
|
||||
var lastMonth = null
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
if (this.data[i].monthString !== lastMonth) {
|
||||
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||
if (weekOfMonth <= 2) {
|
||||
this.monthLabels.push({
|
||||
id: this.data[i].dateString + '-ml',
|
||||
label: this.data[i].monthString,
|
||||
style: {
|
||||
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||
lineHeight: '10px',
|
||||
fontSize: '10px'
|
||||
}
|
||||
})
|
||||
lastMonth = this.data[i].monthString
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
init() {
|
||||
const heatmapEl = document.getElementById('heatmap')
|
||||
this.contentWidth = heatmapEl.clientWidth
|
||||
this.maxInnerWidth = this.contentWidth - 52
|
||||
this.buildData()
|
||||
}
|
||||
},
|
||||
updated() {},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
74
client/components/tables/ChaptersTable.vue
Normal file
74
client/components/tables/ChaptersTable.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">Chapters</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
keepOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate" :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">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
@@ -59,7 +59,8 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
libraryItemId: String
|
||||
libraryItemId: String,
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">{{ title }}</p>
|
||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-center">
|
||||
<!-- <span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="editUser(user)">edit</span> -->
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||
<!-- Dont show edit for non-root users -->
|
||||
<div v-if="user.type !== 'root' || userIsRoot" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</div>
|
||||
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
||||
@@ -58,7 +58,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
<modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +76,9 @@ export default {
|
||||
currentUserId() {
|
||||
return this.$store.state.user.user.id
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
usersOnline() {
|
||||
var usermap = {}
|
||||
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, session: u.session }))
|
||||
@@ -156,6 +159,10 @@ export default {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$refs.accountModal) {
|
||||
this.$refs.accountModal.close()
|
||||
}
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 h-full px-2 flex items-center">
|
||||
<div>
|
||||
<div class="flex-grow max-w-md h-full px-2 flex items-center">
|
||||
<div class="truncate px-1">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline">{{ bookTitle }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow flex items-center">
|
||||
<div class="w-20 flex items-center">
|
||||
<p class="font-mono text-sm">{{ bookDuration }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,18 +6,20 @@
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
<div v-if="!libraries.length" class="pb-4">
|
||||
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
|
||||
</div>
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,8 +34,6 @@ export default {
|
||||
return {
|
||||
libraryCopies: [],
|
||||
currentOrder: [],
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null,
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
@@ -97,12 +97,10 @@ export default {
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
},
|
||||
clickAddLibrary() {
|
||||
this.selectedLibrary = null
|
||||
this.showLibraryModal = true
|
||||
this.$emit('showLibraryModal', null)
|
||||
},
|
||||
editLibrary(library) {
|
||||
this.selectedLibrary = library
|
||||
this.showLibraryModal = true
|
||||
this.$emit('showLibraryModal', library)
|
||||
},
|
||||
init() {
|
||||
this.libraryCopies = this.libraries.map((lib) => {
|
||||
|
||||
@@ -73,10 +73,28 @@ export default {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
scan() {
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
},
|
||||
forceScan() {
|
||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||
if (confirm(`Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed to be used for the library item.\n\nAre you sure you want to force re-scan?`)) {
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
})
|
||||
}
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div v-if="episode" class="flex items-center h-24">
|
||||
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="episode" class="flex items-center h-24 cursor-pointer" @click="$emit('view', episode)">
|
||||
<div class="flex-grow px-2">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ description }}</p>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
</ui-tooltip>
|
||||
<p v-if="episode.season" class="px-4 text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="px-4 text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
<p v-if="publishedAt" class="px-4 text-sm text-gray-300">Published {{ $formatDate(publishedAt, 'MMM do, yyyy') }}</p>
|
||||
</div>
|
||||
@@ -48,8 +46,7 @@ export default {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isDragging: Boolean
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -58,15 +55,6 @@ export default {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isDragging: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.isHovering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@@ -80,10 +68,11 @@ export default {
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
subtitle() {
|
||||
return this.episode.subtitle || ''
|
||||
},
|
||||
description() {
|
||||
if (this.episode.subtitle) return this.episode.subtitle
|
||||
var desc = this.episode.description || ''
|
||||
return desc
|
||||
return this.episode.description || ''
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
@@ -116,7 +105,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
mouseover() {
|
||||
if (this.isDragging) return
|
||||
// if (this.isDragging) return
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
@@ -153,22 +142,7 @@ export default {
|
||||
})
|
||||
},
|
||||
removeClick() {
|
||||
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
||||
this.processingRemove = true
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
||||
.then((updatedPodcast) => {
|
||||
console.log(`Episode removed from podcast`, updatedPodcast)
|
||||
this.$toast.success('Episode removed from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove episode from podcast', error)
|
||||
this.$toast.error('Failed to remove episode from podcast')
|
||||
this.processingRemove = false
|
||||
})
|
||||
}
|
||||
this.$emit('remove', this.episode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +3,19 @@
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold">Episodes</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
||||
<div v-if="userCanUpdate" class="w-12">
|
||||
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
||||
</div>
|
||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
|
||||
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
||||
<template v-for="episode in episodesCopy">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
||||
</template>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" />
|
||||
</template>
|
||||
|
||||
<modals-podcast-edit-episode v-model="showEditEpisodeModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
@@ -36,32 +24,19 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sortKey: 'index',
|
||||
sortDesc: true,
|
||||
drag: false,
|
||||
episodesCopy: [],
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
selectedEpisode: null,
|
||||
showEditEpisodeModal: false,
|
||||
orderChanged: false,
|
||||
savingOrder: false
|
||||
showPodcastRemoveModal: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
handler(newVal) {
|
||||
this.init()
|
||||
}
|
||||
libraryItem() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dragOptions() {
|
||||
return {
|
||||
animation: 200,
|
||||
group: 'description',
|
||||
ghostClass: 'ghost',
|
||||
disabled: !this.userCanUpdate
|
||||
}
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@@ -73,63 +48,33 @@ export default {
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeSort() {
|
||||
this.episodesCopy.sort((a, b) => {
|
||||
},
|
||||
episodesSorted() {
|
||||
return this.episodesCopy.sort((a, b) => {
|
||||
if (this.sortDesc) {
|
||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
checkHasOrderChanged() {
|
||||
for (let i = 0; i < this.episodesCopy.length; i++) {
|
||||
var epc = this.episodesCopy[i]
|
||||
var ep = this.episodes[i]
|
||||
if (epc.index != ep.index) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.showPodcastRemoveModal = true
|
||||
},
|
||||
editEpisode(episode) {
|
||||
this.selectedEpisode = episode
|
||||
this.showEditEpisodeModal = true
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
draggableUpdate() {
|
||||
this.orderChanged = this.checkHasOrderChanged()
|
||||
},
|
||||
async saveOrder() {
|
||||
if (!this.userCanUpdate) return
|
||||
|
||||
this.savingOrder = true
|
||||
|
||||
var episodesUpdate = {
|
||||
episodes: this.episodesCopy.map((b) => b.id)
|
||||
}
|
||||
await this.$axios
|
||||
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
||||
.then((podcast) => {
|
||||
console.log('Podcast updated', podcast)
|
||||
this.$toast.success('Saved episode order')
|
||||
this.orderChanged = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update podcast', error)
|
||||
this.$toast.error('Failed to save podcast episode order')
|
||||
})
|
||||
this.savingOrder = false
|
||||
viewEpisode(episode) {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
|
||||
},
|
||||
init() {
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return {
|
||||
...ep
|
||||
}
|
||||
})
|
||||
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
paddingY: Number,
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
@@ -48,14 +49,17 @@ export default {
|
||||
if (this.small) {
|
||||
list.push('text-sm')
|
||||
if (this.paddingX === undefined) list.push('px-4')
|
||||
list.push('py-1')
|
||||
if (this.paddingY === undefined) list.push('py-1')
|
||||
} else {
|
||||
if (this.paddingX === undefined) list.push('px-8')
|
||||
list.push('py-2')
|
||||
if (this.paddingY === undefined) list.push('py-2')
|
||||
}
|
||||
if (this.paddingX !== undefined) {
|
||||
list.push(`px-${this.paddingX}`)
|
||||
}
|
||||
if (this.paddingY !== undefined) {
|
||||
list.push(`py-${this.paddingY}`)
|
||||
}
|
||||
if (this.disabled) {
|
||||
list.push('cursor-not-allowed')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileInput" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
75
client/components/ui/RichTextEditor.vue
Normal file
75
client/components/ui/RichTextEditor.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}
|
||||
</p>
|
||||
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
label: String,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
content: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
config() {
|
||||
return {
|
||||
toolbar: {
|
||||
getDefaultHTML: () => ` <div class="trix-button-row">
|
||||
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="#{lang.bold}" tabindex="-1">#{lang.bold}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="#{lang.italic}" tabindex="-1">#{lang.italic}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="#{lang.strike}" tabindex="-1">#{lang.strike}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="#{lang.link}" tabindex="-1">#{lang.link}</button>
|
||||
</span>
|
||||
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="#{lang.bullets}" tabindex="-1">#{lang.bullets}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="#{lang.numbers}" tabindex="-1">#{lang.numbers}</button>
|
||||
</span>
|
||||
|
||||
<span class="trix-button-group-spacer"></span>
|
||||
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="#{lang.undo}" tabindex="-1">#{lang.undo}</button>
|
||||
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="#{lang.redo}" tabindex="-1">#{lang.redo}</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="trix-dialogs" data-trix-dialogs>
|
||||
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
|
||||
<div class="trix-dialog__link-fields">
|
||||
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="#{lang.urlPlaceholder}" aria-label="#{lang.url}" required data-trix-input>
|
||||
<div class="trix-button-group">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="#{lang.link}" data-trix-method="setAttribute">
|
||||
<input type="button" class="trix-button trix-button--dialog" value="#{lang.unlink}" data-trix-method="removeAttribute">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
trixFileAccept(e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
console.log('Before destroy')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div ref="wrapper" class="relative">
|
||||
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +34,10 @@ export default {
|
||||
clearable: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
@@ -49,6 +55,10 @@ export default {
|
||||
if (this.noSpinner) _list.push('no-spinner')
|
||||
if (this.textCenter) _list.push('text-center')
|
||||
return _list.join(' ')
|
||||
},
|
||||
actualType() {
|
||||
if (this.type === 'password' && this.showPassword) return 'text'
|
||||
return this.type
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -69,9 +79,20 @@ export default {
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.type === 'password' && this.$refs.wrapper) {
|
||||
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
|
||||
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
|
||||
284
client/components/ui/VueTrix.vue
Normal file
284
client/components/ui/VueTrix.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div>
|
||||
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
|
||||
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/*
|
||||
ORIGINAL SOURCE: https://github.com/hanhdt/vue-trix
|
||||
|
||||
modified for audiobookshelf
|
||||
*/
|
||||
import Trix from 'trix'
|
||||
import '@/assets/trix.css'
|
||||
|
||||
export default {
|
||||
name: 'vue-trix',
|
||||
model: {
|
||||
prop: 'srcContent',
|
||||
event: 'update'
|
||||
},
|
||||
props: {
|
||||
/**
|
||||
* This prop will put the editor in read-only mode
|
||||
*/
|
||||
disabledEditor: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This is referenced `id` of the hidden input field defined.
|
||||
* It is optional and will be a random string by default.
|
||||
*/
|
||||
inputId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default() {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This is referenced `name` of the hidden input field defined,
|
||||
* default value is `content`.
|
||||
*/
|
||||
inputName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default() {
|
||||
return 'content'
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The placeholder attribute specifies a short hint
|
||||
* that describes the expected value of a editor.
|
||||
*/
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default() {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The source content is associcated to v-model directive.
|
||||
*/
|
||||
srcContent: {
|
||||
type: String,
|
||||
required: false,
|
||||
default() {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The boolean attribute allows saving editor state into browser's localStorage
|
||||
* (optional, default is `false`).
|
||||
*/
|
||||
localStorage: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Focuses cursor in the editor when attached to the DOM
|
||||
* (optional, default is `false`).
|
||||
*/
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default() {
|
||||
return false
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Object to override default editor configuration
|
||||
*/
|
||||
config: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default() {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editorContent: this.srcContent,
|
||||
isActived: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editorContent: {
|
||||
handler: 'emitEditorState'
|
||||
},
|
||||
initialContent: {
|
||||
handler: 'handleInitialContentChange'
|
||||
},
|
||||
isDisabled: {
|
||||
handler: 'decorateDisabledEditor'
|
||||
},
|
||||
config: {
|
||||
handler: 'overrideConfig',
|
||||
immediate: true,
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Compute a random id of hidden input
|
||||
* when it haven't been specified.
|
||||
*/
|
||||
generateId() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
var r = (Math.random() * 16) | 0
|
||||
var v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
},
|
||||
computedId() {
|
||||
return this.inputId || this.generateId
|
||||
},
|
||||
initialContent() {
|
||||
return this.srcContent
|
||||
},
|
||||
isDisabled() {
|
||||
return this.disabledEditor
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
processTrixFocus(event) {
|
||||
if (this.$refs.trix) {
|
||||
this.isActived = true
|
||||
this.$emit('trix-focus', this.$refs.trix.editor, event)
|
||||
}
|
||||
},
|
||||
processTrixBlur(event) {
|
||||
if (this.$refs.trix) {
|
||||
this.isActived = false
|
||||
this.$emit('trix-blur', this.$refs.trix.editor, event)
|
||||
}
|
||||
},
|
||||
handleContentChange(event) {
|
||||
this.editorContent = event.srcElement ? event.srcElement.value : event.target.value
|
||||
this.$emit('input', this.editorContent)
|
||||
},
|
||||
handleInitialize(event) {
|
||||
/**
|
||||
* If autofocus is true, manually set focus to
|
||||
* beginning of content (consistent with Trix behavior)
|
||||
*/
|
||||
if (this.autofocus) {
|
||||
this.$refs.trix.editor.setSelectedRange(0)
|
||||
}
|
||||
this.$emit('trix-initialize', this.emitInitialize)
|
||||
},
|
||||
handleInitialContentChange(newContent, oldContent) {
|
||||
newContent = newContent === undefined ? '' : newContent
|
||||
if (this.$refs.trix.editor && this.$refs.trix.editor.innerHTML !== newContent) {
|
||||
/* Update editor's content when initial content changed */
|
||||
this.editorContent = newContent
|
||||
/**
|
||||
* If user are typing, then don't reload the editor,
|
||||
* hence keep cursor's position after typing.
|
||||
*/
|
||||
if (!this.isActived) {
|
||||
this.reloadEditorContent(this.editorContent)
|
||||
}
|
||||
}
|
||||
},
|
||||
emitEditorState(value) {
|
||||
/**
|
||||
* If localStorage is enabled,
|
||||
* then save editor's content into storage
|
||||
*/
|
||||
if (this.localStorage) {
|
||||
localStorage.setItem(this.storageId('VueTrix'), JSON.stringify(this.$refs.trix.editor))
|
||||
}
|
||||
this.$emit('update', this.editorContent)
|
||||
},
|
||||
storageId(component) {
|
||||
if (this.inputId) {
|
||||
return `${component}.${this.inputId}.content`
|
||||
} else {
|
||||
return `${component}.content`
|
||||
}
|
||||
},
|
||||
reloadEditorContent(newContent) {
|
||||
// Reload HTML content
|
||||
this.$refs.trix.editor.loadHTML(newContent)
|
||||
// Move cursor to end of new content updated
|
||||
this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
|
||||
},
|
||||
getContentEndPosition() {
|
||||
return this.$refs.trix.editor.getDocument().toString().length - 1
|
||||
},
|
||||
decorateDisabledEditor(editorState) {
|
||||
/** Disable toolbar and editor by pointer events styling */
|
||||
if (editorState) {
|
||||
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none'
|
||||
this.$refs.trix.contentEditable = false
|
||||
this.$refs.trix.style['background'] = '#e9ecef'
|
||||
} else {
|
||||
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset'
|
||||
this.$refs.trix.style['pointer-events'] = 'unset'
|
||||
this.$refs.trix.style['background'] = 'transparent'
|
||||
}
|
||||
},
|
||||
overrideConfig(config) {
|
||||
Trix.config = this.deepMerge(Trix.config, config)
|
||||
},
|
||||
deepMerge(target, override) {
|
||||
// deep merge the object into the target object
|
||||
for (let prop in override) {
|
||||
if (override.hasOwnProperty(prop)) {
|
||||
if (Object.prototype.toString.call(override[prop]) === '[object Object]') {
|
||||
// if the property is a nested object
|
||||
target[prop] = this.deepMerge(target[prop], override[prop])
|
||||
} else {
|
||||
// for regular property
|
||||
target[prop] = override[prop]
|
||||
}
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
/** Override editor configuration */
|
||||
this.overrideConfig(this.config)
|
||||
/** Check if editor read-only mode is required */
|
||||
this.decorateDisabledEditor(this.disabledEditor)
|
||||
this.$nextTick(() => {
|
||||
/**
|
||||
* If localStorage is enabled,
|
||||
* then load editor's content from the beginning.
|
||||
*/
|
||||
if (this.localStorage) {
|
||||
const savedValue = localStorage.getItem(this.storageId('VueTrix'))
|
||||
if (savedValue && !this.srcContent) {
|
||||
this.$refs.trix.editor.loadJSON(JSON.parse(savedValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css" module>
|
||||
.trix_container {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.trix_container .trix-button-group {
|
||||
background-color: white;
|
||||
}
|
||||
.trix_container .trix-content {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :library-item-id="libraryItemId" class="mt-6" />
|
||||
<tables-tracks-table :title="`Audiobook Tracks`" :tracks="media.tracks" :is-file="isFile" :library-item-id="libraryItemId" class="mt-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,8 @@ export default {
|
||||
media: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
isFile: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
112
client/components/widgets/AuthorsSlider.vue
Normal file
112
client/components/widgets/AuthorsSlider.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio / 1.25
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editAuthor(author) {
|
||||
this.$store.commit('globals/showEditAuthorModal', author)
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +1,68 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="flex-grow px-1">
|
||||
<widgets-series-input-widget v-model="details.series" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span>
|
||||
</div>
|
||||
<form @submit.prevent="submitSeriesForm">
|
||||
<div class="bg-bg rounded-lg p-8" @click.stop>
|
||||
<div class="flex">
|
||||
<div class="flex-grow p-1 min-w-80">
|
||||
<ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" />
|
||||
</div>
|
||||
<div class="w-40 p-1">
|
||||
<ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,8 +76,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedSeries: {},
|
||||
showSeriesForm: false,
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
@@ -148,24 +123,6 @@ export default {
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
existingSeriesNames() {
|
||||
// Only show series names not already selected
|
||||
var alreadySelectedSeriesIds = this.details.series.map((se) => se.id)
|
||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||
},
|
||||
seriesItems: {
|
||||
get() {
|
||||
return this.details.series.map((se) => {
|
||||
return {
|
||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||
...se
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
this.details.series = val
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -216,50 +173,6 @@ export default {
|
||||
this.$refs.tagsSelect.forceBlur()
|
||||
}
|
||||
},
|
||||
cancelSeriesForm() {
|
||||
this.showSeriesForm = false
|
||||
},
|
||||
editSeriesItem(series) {
|
||||
var _series = this.details.series.find((se) => se.id === series.id)
|
||||
if (!_series) return
|
||||
this.selectedSeries = {
|
||||
..._series
|
||||
}
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
addNewSeries() {
|
||||
this.selectedSeries = {
|
||||
id: `new-${Date.now()}`,
|
||||
name: '',
|
||||
sequence: ''
|
||||
}
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
}
|
||||
if (this.$refs.newSeriesSelect) {
|
||||
this.$refs.newSeriesSelect.blur()
|
||||
}
|
||||
var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id)
|
||||
|
||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||
this.selectedSeries.id = seriesSameName.id
|
||||
}
|
||||
|
||||
if (existingSeriesIndex >= 0) {
|
||||
this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries })
|
||||
} else {
|
||||
this.details.series.push({
|
||||
...this.selectedSeries
|
||||
})
|
||||
}
|
||||
|
||||
this.showSeriesForm = false
|
||||
},
|
||||
stringArrayEqual(array1, array2) {
|
||||
// return false if different
|
||||
if (array1.length !== array2.length) return false
|
||||
|
||||
148
client/components/widgets/EpisodeSlider.vue
Normal file
148
client/components/widgets/EpisodeSlider.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editEpisode({ libraryItem, episode }) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
editPodcast(libraryItem) {
|
||||
var itemIds = this.items.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
|
||||
this.items.forEach((ent) => {
|
||||
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
})
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
143
client/components/widgets/ItemSlider.vue
Normal file
143
client/components/widgets/ItemSlider.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return this.cardHeight / this.bookCoverAspectRatio
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelectedEntities() {
|
||||
this.updateSelectionMode(false)
|
||||
},
|
||||
editItem(libraryItem) {
|
||||
var itemIds = this.items.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||
this.$store.commit('showEditModal', libraryItem)
|
||||
},
|
||||
selectItem(libraryItem) {
|
||||
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||
this.$nextTick(() => {
|
||||
this.$eventBus.$emit('item-selected', libraryItem)
|
||||
})
|
||||
},
|
||||
itemSelectedEvt() {
|
||||
this.updateSelectionMode(this.isSelectionMode)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
|
||||
this.items.forEach((item) => {
|
||||
var component = this.$refs[`slider-item-${item.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(item.id)
|
||||
})
|
||||
},
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute w-32 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex h-7 items-center px-2 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click="clickAction(item.func)">
|
||||
<p>{{ item.text }}</p>
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||
<div class="flex -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||
</div>
|
||||
<div class="w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||
</div>
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<div class="flex justify-center">
|
||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow px-1 pt-6">
|
||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
111
client/components/widgets/SeriesInputWidget.vue
Normal file
111
client/components/widgets/SeriesInputWidget.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<ui-multi-select-query-input v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||
|
||||
<modals-edit-series-input-inner-modal v-model="showSeriesForm" :selected-series="selectedSeries" :existing-series-names="existingSeriesNames" @submit="submitSeriesForm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedSeries: null,
|
||||
showSeriesForm: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seriesItems: {
|
||||
get() {
|
||||
return (this.value || []).map((se) => {
|
||||
return {
|
||||
displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name,
|
||||
...se
|
||||
}
|
||||
})
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
series() {
|
||||
return this.filterData.series || []
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
existingSeriesNames() {
|
||||
// Only show series names not already selected
|
||||
var alreadySelectedSeriesIds = (this.value || []).map((se) => se.id)
|
||||
return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelSeriesForm() {
|
||||
this.showSeriesForm = false
|
||||
},
|
||||
editSeriesItem(series) {
|
||||
var _series = this.seriesItems.find((se) => se.id === series.id)
|
||||
if (!_series) return
|
||||
|
||||
this.selectedSeries = {
|
||||
..._series
|
||||
}
|
||||
|
||||
console.log('Selected series', this.selectedSeries)
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
addNewSeries() {
|
||||
this.selectedSeries = {
|
||||
id: `new-${Date.now()}`,
|
||||
name: '',
|
||||
sequence: ''
|
||||
}
|
||||
|
||||
this.showSeriesForm = true
|
||||
},
|
||||
submitSeriesForm() {
|
||||
console.log('submit series form', this.value, this.selectedSeries)
|
||||
|
||||
if (!this.selectedSeries.name) {
|
||||
this.$toast.error('Must enter a series')
|
||||
return
|
||||
}
|
||||
|
||||
var existingSeriesIndex = this.seriesItems.findIndex((se) => se.id === this.selectedSeries.id)
|
||||
|
||||
var existingSeriesSameName = this.seriesItems.findIndex((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||
if (existingSeriesSameName >= 0 && existingSeriesIndex < 0) {
|
||||
console.error('Attempt to add duplicate series')
|
||||
this.$toast.error('Cannot add two of the same series')
|
||||
return
|
||||
}
|
||||
|
||||
var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase())
|
||||
if (existingSeriesIndex < 0 && seriesSameName) {
|
||||
this.selectedSeries.id = seriesSameName.id
|
||||
}
|
||||
|
||||
var selectedSeriesCopy = { ...this.selectedSeries }
|
||||
selectedSeriesCopy.displayName = selectedSeriesCopy.sequence ? `${selectedSeriesCopy.name} #${selectedSeriesCopy.sequence}` : selectedSeriesCopy.name
|
||||
|
||||
var seriesCopy = this.seriesItems.map((v) => ({ ...v }))
|
||||
if (existingSeriesIndex >= 0) {
|
||||
seriesCopy.splice(existingSeriesIndex, 1, selectedSeriesCopy)
|
||||
this.seriesItems = seriesCopy
|
||||
} else {
|
||||
seriesCopy.push(selectedSeriesCopy)
|
||||
this.seriesItems = seriesCopy
|
||||
}
|
||||
|
||||
this.showSeriesForm = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
109
client/components/widgets/SeriesSlider.vue
Normal file
109
client/components/widgets/SeriesSlider.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center py-3">
|
||||
<slot />
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||
<span class="material-icons text-2xl">chevron_left</span>
|
||||
</button>
|
||||
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||
<span class="material-icons text-2xl">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.TITLES" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isScrollable: false,
|
||||
canScrollLeft: false,
|
||||
canScrollRight: false,
|
||||
clientWidth: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
cardScaleMulitiplier() {
|
||||
return this.height / 192
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height - 40 * this.cardScaleMulitiplier
|
||||
},
|
||||
cardWidth() {
|
||||
return 2 * (this.cardHeight / this.bookCoverAspectRatio)
|
||||
},
|
||||
booksPerPage() {
|
||||
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.canScrollRight) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||
|
||||
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.canScrollLeft) return
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
|
||||
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||
|
||||
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||
slider.scrollLeft = newScrollLeft
|
||||
},
|
||||
setScrollVars() {
|
||||
const slider = this.$refs.slider
|
||||
if (!slider) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||
|
||||
this.clientWidth = clientWidth
|
||||
this.isScrollable = scrollWidth > clientWidth
|
||||
this.canScrollRight = scrollPercent < 1
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.setScrollVars()
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,14 +2,19 @@
|
||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||
<app-appbar />
|
||||
|
||||
<Nuxt />
|
||||
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
||||
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
|
||||
<Nuxt />
|
||||
</div>
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
|
||||
<modals-item-edit-modal />
|
||||
<modals-user-collections-modal />
|
||||
<modals-edit-collection-modal />
|
||||
<modals-bookshelf-texture-modal />
|
||||
<modals-podcast-edit-episode />
|
||||
<modals-podcast-view-episode />
|
||||
<modals-authors-edit-modal />
|
||||
<readers-reader />
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,6 +48,13 @@ export default {
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting
|
||||
},
|
||||
isShowingSideRail() {
|
||||
if (!this.$route.name) return false
|
||||
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
appContentMarginLeft() {
|
||||
return this.isShowingSideRail ? 80 : 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -105,12 +117,6 @@ export default {
|
||||
}
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
|
||||
if (payload.serverSettings.chromecastEnabled) {
|
||||
console.log('Chromecast enabled import script')
|
||||
require('@/plugins/chromecast.js').default(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Start scans currently running
|
||||
@@ -166,17 +172,40 @@ export default {
|
||||
libraryUpdated(library) {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
},
|
||||
libraryRemoved(library) {
|
||||
async libraryRemoved(library) {
|
||||
console.log('Library removed', library)
|
||||
this.$store.commit('libraries/remove', library)
|
||||
|
||||
// When removed currently selected library then set next accessible library
|
||||
const currLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
if (currLibraryId === library.id) {
|
||||
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
|
||||
if (nextLibrary) {
|
||||
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
|
||||
|
||||
if (this.$route.name.startsWith('config')) {
|
||||
// No need to refresh
|
||||
} else if (this.$route.name.startsWith('library')) {
|
||||
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
|
||||
this.$router.push(newRoute)
|
||||
} else {
|
||||
this.$router.push(`/library/${nextLibrary.id}`)
|
||||
}
|
||||
} else {
|
||||
console.error('User has no more accessible libraries')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
}
|
||||
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
libraryItemRemoved(item) {
|
||||
if (this.$route.name.startsWith('item')) {
|
||||
@@ -484,6 +513,14 @@ export default {
|
||||
},
|
||||
resize() {
|
||||
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
|
||||
},
|
||||
checkVersionUpdate() {
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -502,12 +539,7 @@ export default {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
this.checkVersionUpdate()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
@@ -525,4 +557,20 @@ export default {
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
width: 100%;
|
||||
}
|
||||
#app-content.has-siderail {
|
||||
width: calc(100% - 80px);
|
||||
max-width: calc(100% - 80px);
|
||||
margin-left: 80px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#app-content.has-siderail {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
export default function (context) {
|
||||
if (process.client) {
|
||||
var route = context.route
|
||||
var from = context.from
|
||||
var store = context.store
|
||||
|
||||
if (route.name === 'login' || from.name === 'login') return
|
||||
|
||||
if (!route.name) {
|
||||
console.warn('No Route name', route)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.state.isRoutingBack) {
|
||||
// pressing back button in appbar do not add to route history
|
||||
store.commit('setIsRoutingBack', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
||||
var _history = [...store.state.routeHistory]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
store.commit('setRouteHistory', _history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
bookCoverAspectRatio: this.bookCoverAspectRatio,
|
||||
bookshelfView: this.bookshelfView
|
||||
}
|
||||
if (this.entityName === 'series-books') props.showSequence = true
|
||||
|
||||
if (this.entityName === 'books') {
|
||||
props.filterBy = this.filterBy
|
||||
props.orderBy = this.orderBy
|
||||
|
||||
@@ -112,11 +112,22 @@ export default {
|
||||
items: []
|
||||
})
|
||||
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
||||
dirReader.readEntries((entries) => {
|
||||
let entriesPromises = []
|
||||
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
resolve(Promise.all(entriesPromises))
|
||||
})
|
||||
|
||||
let entriesPromises = []
|
||||
// readEntries returns 100 items max, continue calling readEntries until empty
|
||||
function readEntries() {
|
||||
dirReader.readEntries((entries) => {
|
||||
if (entries.length > 0) {
|
||||
for (let entr of entries) {
|
||||
entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
}
|
||||
readEntries()
|
||||
} else {
|
||||
resolve(Promise.all(entriesPromises))
|
||||
}
|
||||
})
|
||||
}
|
||||
readEntries()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -174,8 +185,8 @@ export default {
|
||||
if (mediaType === 'podcast') return this.cleanPodcast(item, index)
|
||||
return this.cleanBook(item, index)
|
||||
},
|
||||
async getItemsFromDataTransferItems(items, mediaType) {
|
||||
var files = await this.getFilesDropped(items)
|
||||
async getItemsFromDataTransferItems(dataTransferItems, mediaType) {
|
||||
var files = await this.getFilesDropped(dataTransferItems)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
var itemData = this.fileTreeToItems(files, mediaType)
|
||||
if (!itemData.items.length && !itemData.ignoredFiles.length) {
|
||||
@@ -189,7 +200,7 @@ export default {
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.itemFiles.length
|
||||
}).map(ab => this.cleanItem(ab, index++))
|
||||
}).map(ab => this.cleanItem(ab, mediaType, index++))
|
||||
return {
|
||||
items,
|
||||
ignoredFiles
|
||||
|
||||
@@ -9,7 +9,6 @@ module.exports = {
|
||||
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
|
||||
chromecastReceiver: 'FD1F76C5'
|
||||
},
|
||||
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
|
||||
telemetry: false,
|
||||
|
||||
publicRuntimeConfig: {
|
||||
@@ -33,14 +32,11 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
router: {},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
|
||||
2318
client/package-lock.json
generated
2318
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.1",
|
||||
"description": "Audiobook manager and player",
|
||||
"version": "2.0.18",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
@@ -22,6 +22,7 @@
|
||||
"libarchive.js": "^1.3.0",
|
||||
"nuxt": "^2.15.8",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"trix": "^1.3.1",
|
||||
"v-click-outside": "^3.1.2",
|
||||
"vue-pdf": "^4.3.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<div class="w-full h-px bg-primary my-4" />
|
||||
|
||||
<p class="mb-4 text-lg">Change Password</p>
|
||||
<form @submit.prevent="submitChangePassword">
|
||||
<p v-if="!isGuest" class="mb-4 text-lg">Change Password</p>
|
||||
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
||||
@@ -60,6 +60,9 @@ export default {
|
||||
},
|
||||
isRoot() {
|
||||
return this.usertype === 'root'
|
||||
},
|
||||
isGuest() {
|
||||
return this.usertype === 'guest'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
422
client/pages/audiobook/_id/chapters.vue
Normal file
422
client/pages/audiobook/_id/chapters.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center py-4 max-w-7xl mx-auto">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-base">Duration:</p>
|
||||
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||
<div class="w-full max-w-3xl py-4">
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||
<div class="w-40" />
|
||||
</div>
|
||||
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="w-12"></div>
|
||||
<div class="w-32 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
<div class="w-40"></div>
|
||||
</div>
|
||||
<template v-for="chapter in newChapters">
|
||||
<div :key="chapter.id" class="flex py-1">
|
||||
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-32 px-1">
|
||||
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||
</div>
|
||||
<div class="w-40 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||
<span class="material-icons-outlined text-base">remove</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip text="Insert chapter below" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
|
||||
<span v-else class="material-icons-outlined text-base">play_arrow</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl py-4">
|
||||
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="flex-grow">Filename</div>
|
||||
<div class="w-20">Duration</div>
|
||||
<div class="w-20 text-center">Chapters</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||
<div class="flex-grow">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
|
||||
</div>
|
||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div v-if="!chapterData" class="flex p-20">
|
||||
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||
</div>
|
||||
|
||||
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||
<div class="w-24 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
</div>
|
||||
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
|
||||
<div class="w-24 min-w-24 px-2">
|
||||
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-2">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
if (!libraryItem) {
|
||||
console.error('Not found...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
if (libraryItem.mediaType != 'book') {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newChapters: [],
|
||||
selectedChapter: null,
|
||||
audioEl: null,
|
||||
isPlayingChapter: false,
|
||||
isLoadingChapter: false,
|
||||
currentTrackIndex: 0,
|
||||
saving: false,
|
||||
asinInput: null,
|
||||
findingChapters: false,
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
mediaDuration() {
|
||||
return this.media.duration
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.media.audioFiles || []
|
||||
},
|
||||
audioTracks() {
|
||||
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||
},
|
||||
selectedChapterId() {
|
||||
return this.selectedChapter ? this.selectedChapter.id : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
addChapter(chapter) {
|
||||
console.log('Add chapter', chapter)
|
||||
const newChapter = {
|
||||
id: chapter.id + 1,
|
||||
start: chapter.start,
|
||||
end: chapter.end,
|
||||
title: ''
|
||||
}
|
||||
this.newChapters.splice(chapter.id + 1, 0, newChapter)
|
||||
this.checkChapters()
|
||||
},
|
||||
removeChapter(chapter) {
|
||||
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||
this.checkChapters()
|
||||
},
|
||||
checkChapters() {
|
||||
var previousStart = 0
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
this.newChapters[i].id = i
|
||||
this.newChapters[i].start = Number(this.newChapters[i].start)
|
||||
|
||||
if (i === 0 && this.newChapters[i].start !== 0) {
|
||||
this.newChapters[i].error = 'First chapter must start at 0'
|
||||
} else if (this.newChapters[i].start <= previousStart && i > 0) {
|
||||
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
|
||||
} else if (this.newChapters[i].start >= this.mediaDuration) {
|
||||
this.newChapters[i].error = 'Invalid start time must be < duration'
|
||||
} else {
|
||||
this.newChapters[i].error = null
|
||||
}
|
||||
previousStart = this.newChapters[i].start
|
||||
}
|
||||
},
|
||||
playChapter(chapter) {
|
||||
console.log('Play Chapter', chapter.id)
|
||||
if (this.selectedChapterId === chapter.id) {
|
||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||
if (this.isLoadingChapter) return
|
||||
if (this.isPlayingChapter) {
|
||||
console.log('Destroying chapter')
|
||||
this.destroyAudioEl()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.selectedChapterId) {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
|
||||
const audioTrack = this.tracks.find((at) => {
|
||||
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||
})
|
||||
console.log('audio track', audioTrack)
|
||||
|
||||
this.selectedChapter = chapter
|
||||
this.isLoadingChapter = true
|
||||
|
||||
const trackOffset = chapter.start - audioTrack.startOffset
|
||||
this.playTrackAtTime(audioTrack, trackOffset)
|
||||
},
|
||||
playTrackAtTime(audioTrack, trackOffset) {
|
||||
this.currentTrackIndex = audioTrack.index
|
||||
|
||||
const audioEl = this.audioEl || document.createElement('audio')
|
||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||
if (this.$isDev) {
|
||||
src = `http://localhost:3333${src}`
|
||||
}
|
||||
console.log('src', src)
|
||||
|
||||
audioEl.src = src
|
||||
audioEl.id = 'chapter-audio'
|
||||
document.body.appendChild(audioEl)
|
||||
|
||||
audioEl.addEventListener('loadeddata', () => {
|
||||
console.log('Audio loaded data', audioEl.duration)
|
||||
audioEl.currentTime = trackOffset
|
||||
audioEl.play()
|
||||
console.log('Playing audio at current time', trackOffset)
|
||||
})
|
||||
audioEl.addEventListener('play', () => {
|
||||
console.log('Audio playing')
|
||||
this.isLoadingChapter = false
|
||||
this.isPlayingChapter = true
|
||||
})
|
||||
audioEl.addEventListener('ended', () => {
|
||||
console.log('Audio ended')
|
||||
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
|
||||
if (nextTrack) {
|
||||
console.log('Playing next track', nextTrack.index)
|
||||
this.currentTrackIndex = nextTrack.index
|
||||
this.playTrackAtTime(nextTrack, 0)
|
||||
} else {
|
||||
console.log('No next track')
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
})
|
||||
this.audioEl = audioEl
|
||||
},
|
||||
destroyAudioEl() {
|
||||
if (!this.audioEl) return
|
||||
this.audioEl.remove()
|
||||
this.audioEl = null
|
||||
this.selectedChapter = null
|
||||
this.isPlayingChapter = false
|
||||
this.isLoadingChapter = false
|
||||
},
|
||||
saveChapters() {
|
||||
this.checkChapters()
|
||||
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
if (this.newChapters[i].error) {
|
||||
this.$toast.error('Chapters have errors')
|
||||
return
|
||||
}
|
||||
if (!this.newChapters[i].title) {
|
||||
this.$toast.error('Chapters must have titles')
|
||||
return
|
||||
}
|
||||
|
||||
const nextChapter = this.newChapters[i + 1]
|
||||
if (nextChapter) {
|
||||
this.newChapters[i].end = nextChapter.start
|
||||
} else {
|
||||
this.newChapters[i].end = this.mediaDuration
|
||||
}
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
|
||||
console.log('udpated chapters', this.newChapters)
|
||||
const payload = {
|
||||
chapters: this.newChapters
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
this.saving = false
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters updated')
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
} else {
|
||||
this.$toast.info('No changes needed updating')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.saving = false
|
||||
console.error('Failed to update chapters', error)
|
||||
this.$toast.error('Failed to update chapters')
|
||||
})
|
||||
},
|
||||
applyChapterData() {
|
||||
var index = 0
|
||||
this.newChapters = this.chapterData.chapters
|
||||
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||
.map((chap) => {
|
||||
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
|
||||
return {
|
||||
id: index++,
|
||||
start: chap.startOffsetMs / 1000,
|
||||
end: chapEnd,
|
||||
title: chap.title
|
||||
}
|
||||
})
|
||||
this.showFindChaptersModal = false
|
||||
this.chapterData = null
|
||||
},
|
||||
findChapters() {
|
||||
if (!this.asinInput) {
|
||||
this.$toast.error('Must input an ASIN')
|
||||
return
|
||||
}
|
||||
this.findingChapters = true
|
||||
this.chapterData = null
|
||||
this.$axios
|
||||
.$get(`/api/search/chapters?asin=${this.asinInput}`)
|
||||
.then((data) => {
|
||||
this.findingChapters = false
|
||||
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
this.showFindChaptersModal = false
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
this.chapterData = data
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.findingChapters = false
|
||||
console.error('Failed to get chapter data', error)
|
||||
this.$toast.error('Failed to find chapters')
|
||||
this.showFindChaptersModal = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.asinInput = this.mediaMetadata.asin || null
|
||||
this.newChapters = this.chapters.map((c) => ({ ...c }))
|
||||
if (!this.newChapters.length) {
|
||||
this.newChapters = [
|
||||
{
|
||||
id: 0,
|
||||
start: 0,
|
||||
end: this.mediaDuration,
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
||||
<li v-for="(audio, index) in files" :key="audio.ino" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center relative">
|
||||
<div class="font-book text-center px-4 py-1 w-12">
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="font-sans text-xs font-normal w-56">
|
||||
{{ audio.error }}
|
||||
</div>
|
||||
<div class="font-sans text-xs font-normal w-40 flex justify-center">
|
||||
<div class="font-sans text-xs font-normal w-40 flex items-center justify-center">
|
||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||
</div>
|
||||
</li>
|
||||
@@ -89,9 +89,6 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
@@ -107,6 +104,10 @@ export default {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
if (libraryItem.isFile) {
|
||||
console.error('No need to edit library item that is 1 file...')
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem,
|
||||
files: libraryItem.media.audioFiles ? libraryItem.media.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
|
||||
@@ -158,9 +159,6 @@ export default {
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
112
client/pages/author/_id.vue
Normal file
112
client/pages/author/_id.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex mb-6">
|
||||
<div class="w-48 min-w-48">
|
||||
<div class="w-full h-52">
|
||||
<covers-author-image :author="author" rounded="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-8">
|
||||
<div class="flex items-center mb-8">
|
||||
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||
|
||||
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
|
||||
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
|
||||
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
|
||||
</nuxt-link>
|
||||
</widgets-item-slider>
|
||||
</div>
|
||||
|
||||
<div v-for="series in authorSeries" :key="series.id" class="py-4">
|
||||
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
|
||||
<h2 class="text-lg">{{ series.name }}</h2>
|
||||
</nuxt-link>
|
||||
<p class="text-white text-opacity-40 text-base px-2">Series</p>
|
||||
</widgets-item-slider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!author) {
|
||||
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||
}
|
||||
|
||||
return {
|
||||
author
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryItems() {
|
||||
return this.author.libraryItems || []
|
||||
},
|
||||
authorSeries() {
|
||||
return this.author.series || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editAuthor() {
|
||||
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||
},
|
||||
authorUpdated(author) {
|
||||
if (author.id === this.author.id) {
|
||||
console.log('Author was updated', author)
|
||||
this.author = {
|
||||
...author,
|
||||
series: this.authorSeries,
|
||||
libraryItems: this.libraryItems
|
||||
}
|
||||
}
|
||||
},
|
||||
authorRemoved(author) {
|
||||
if (author.id === this.author.id) {
|
||||
console.warn('Author was removed')
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.author) this.$router.replace('/')
|
||||
|
||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
||||
<div class="configContent" :class="`page-${currentPage}`">
|
||||
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
||||
@@ -15,7 +15,7 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect, route }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
// Non-Root user only has access to the listening stats page
|
||||
if (route.name !== 'config-stats') {
|
||||
redirect('/config/stats')
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input type="number" v-model="maxBackupSize" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||
|
||||
<ui-tooltip :text="maxBackupSizeTooltip">
|
||||
<p class="pl-4 text-lg">Maximum backup size (in GB) <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<tables-backups-table />
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,6 +40,7 @@ export default {
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
backupsToKeep: 2,
|
||||
maxBackupSize: 1,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
@@ -47,19 +56,27 @@ export default {
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
maxBackupSizeTooltip() {
|
||||
return 'As a safeguard against misconfiguration, backups will fail if they exceed the configured size.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.maxBackupSize) || this.maxBackupSize <= 0) {
|
||||
this.$toast.error('Invalid maximum backup size')
|
||||
return
|
||||
}
|
||||
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
||||
this.$toast.error('Invalid number of backups to keep')
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||
backupsToKeep: Number(this.backupsToKeep)
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
}
|
||||
this.updateServerSettings(updatePayload)
|
||||
},
|
||||
@@ -81,6 +98,7 @@ export default {
|
||||
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
this.maxBackupSize = this.newServerSettings.maxBackupSize || 1
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||
<ui-tooltip :text="tooltips.bookshelfView">
|
||||
<p class="pl-4 text-lg">
|
||||
Use alternative library bookshelf view
|
||||
Use alternative bookshelf view
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
@@ -113,6 +113,16 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerPreferMatchedMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferMatchedMetadata', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerPreferMatchedMetadata">
|
||||
<p class="pl-4 text-lg">
|
||||
Scanner prefer matched metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
|
||||
<ui-tooltip :text="tooltips.scannerDisableWatcher">
|
||||
@@ -122,6 +132,20 @@
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 mt-8">
|
||||
<h1 class="text-xl">Experimental Feature Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="tooltips.enableEReader">
|
||||
<p class="pl-4 text-lg">
|
||||
Enable e-reader for all users
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
@@ -169,10 +193,12 @@
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||
<p class="pl-4 text-lg">
|
||||
Experimental Features
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -207,16 +233,19 @@ export default {
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {},
|
||||
tooltips: {
|
||||
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
||||
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||
scannerPreferMatchedMetadata: 'Matched data will overide book details when using Quick Match',
|
||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
||||
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
|
||||
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers',
|
||||
bookshelfView: 'Alternative view without wooden bookshelf',
|
||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
||||
},
|
||||
showConfirmPurgeCache: false
|
||||
}
|
||||
@@ -229,9 +258,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
experimentalFeaturesTooltip() {
|
||||
return 'Features in development that could use your feedback and help testing.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<tables-library-libraries-table />
|
||||
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
|
||||
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
methods: {
|
||||
setShowLibraryModal(selectedLibrary) {
|
||||
this.selectedLibrary = selectedLibrary
|
||||
this.showLibraryModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user