Compare commits
118 Commits
0.9.61-beta
...
v1.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9d5a8d1b | |||
| 0db34dcab5 | |||
| 47c6c1aaad | |||
| d6cab8e591 | |||
| dc18eb408e | |||
| 6f891208d0 | |||
| 643040e635 | |||
| 4c07f9ec25 | |||
| 0ba38d45bc | |||
| 0da327222e | |||
| 868e1af28a | |||
| a343a1038c | |||
| 3e5338ec8e | |||
| 01fdca4bf9 | |||
| ed96dd7c81 | |||
| 1ead5de9f5 | |||
| 06554811e2 | |||
| 9935bd2ffa | |||
| 4260903bbe | |||
| 64ae8ef849 | |||
| be15f2f5a0 | |||
| 341de6a196 | |||
| 740f6966ba | |||
| 2e889ff9fe | |||
| d123abd4cd | |||
| cc84349d6d | |||
| b5e83d8866 | |||
| ca9521ac9a | |||
| 078f404fe4 | |||
| bc47dfa343 | |||
| 03f39d71e3 | |||
| 69cd6aa4d0 | |||
| e161f70710 | |||
| 3ef0173226 | |||
| 87a308f749 | |||
| f45a1c4161 | |||
| 5e15695f64 | |||
| b35997e8be | |||
| fcd664c16e | |||
| 94741598af | |||
| 6cb418a871 | |||
| d234fdea11 | |||
| 13ac5f1d2a | |||
| f4d6e65380 | |||
| baccbaf82a | |||
| d6969e0b85 | |||
| b3ad9c95ce | |||
| 2f6417dec2 | |||
| f54d48270e | |||
| 57321084af | |||
| 1d97422011 | |||
| 2f2a64b89e | |||
| b2e129eec7 | |||
| cb79e48685 | |||
| e735ef7869 | |||
| 8f1152762a | |||
| 587adb3773 | |||
| db01db3a2b | |||
| 0851a1e71e | |||
| 0addfc8269 | |||
| b2ab5730f5 | |||
| 7859d7a502 | |||
| 174fce9614 | |||
| 3dfd7ea035 | |||
| 6cb253598b | |||
| 11f4caffa8 | |||
| 80f90907d4 | |||
| 4e92ea3992 | |||
| ddbf678a8b | |||
| 315de87bfc | |||
| 26d922d3dc | |||
| ee452d41ee | |||
| 1d7d2a1dac | |||
| 41c391e87b | |||
| 1f2afe4d92 | |||
| e534d015be | |||
| d2a2f3ff6a | |||
| af05e78cdf | |||
| e566c6c9d5 | |||
| 197012e662 | |||
| e4dac5dd05 | |||
| a86bda59f6 | |||
| 5b1269cbe8 | |||
| c81a0260e2 | |||
| 2b1e6b0c3b | |||
| bff42962ba | |||
| 73e2f184cf | |||
| 0c017c4227 | |||
| 234653b549 | |||
| 23f343f1df | |||
| 88c7c1632e | |||
| 8c9fb0d45e | |||
| e54535f465 | |||
| 46d7c45ca5 | |||
| 9c32e4cbda | |||
| dc0f25aca3 | |||
| 93c78a672c | |||
| 63cae5b0ed | |||
| 81487d1dba | |||
| 6ca7e9e6a6 | |||
| e230cb47e8 | |||
| 9300a0bfb6 | |||
| 3d64115051 | |||
| 2f215ed2e5 | |||
| bda0c0c804 | |||
| bf38004b5e | |||
| f83c5dd440 | |||
| 2548aba840 | |||
| 5803559183 | |||
| 73a786879e | |||
| f4cb5d101e | |||
| 9331b5870f | |||
| dd213ddfd1 | |||
| 0990c61c93 | |||
| 5b64453101 | |||
| cbf2938c9c | |||
| 04643ad686 | |||
| 13cd5a4041 |
@@ -8,5 +8,7 @@ npm-debug.log
|
||||
/audiobooks2
|
||||
/metadata
|
||||
dev.js
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
@@ -5,5 +5,7 @@ node_modules/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/metadata/
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ RUN npm run generate
|
||||
### STAGE 2: Build server ###
|
||||
FROM node:12-alpine
|
||||
ENV NODE_ENV=production
|
||||
ENV LOG_LEVEL=INFO
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY --from=ffmpeg / /
|
||||
COPY index.js index.js
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Package: audiobookshelf
|
||||
Version: 1.2.1
|
||||
Section: base
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends:
|
||||
Maintainer: advplyr
|
||||
Description: Self-hosted audiobook server for managing and playing audiobooks
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
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"
|
||||
|
||||
if hash systemctl 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' ]]; then
|
||||
{
|
||||
systemctl enable "$service_name.service" && \
|
||||
systemctl start "$service_name.service"
|
||||
} || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash service 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
|
||||
service "$service_name" start || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash start 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' ]]; then
|
||||
start "$service_name" || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash update-rc.d 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'sysv' ]]; then
|
||||
{
|
||||
update-rc.d "$service_name" defaults && \
|
||||
"/etc/init.d/$service_name" start
|
||||
} || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
else
|
||||
echo 'Your system does not appear to use systemd, Upstart, or System V, so the service could not be started'
|
||||
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'
|
||||
|
||||
start_service 'audiobookshelf'
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||
TARGET_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
|
||||
if ! cd "$TARGET_DIR"; then
|
||||
echo "WARNING: can't access working directory ($TARGET_DIR) creating it" >&2
|
||||
mkdir "$TARGET_DIR"
|
||||
cd "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
$WGET
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$TARGET_DIR"
|
||||
|
||||
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() {
|
||||
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
|
||||
PORT=$PORT"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
echo "$config_text" > /etc/default/audiobookshelf;
|
||||
|
||||
echo "Config created"
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
declare -r init_type='auto'
|
||||
declare -r service_name='audiobookshelf'
|
||||
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
|
||||
if hash systemctl 2> /dev/null; then
|
||||
systemctl disable "$service_name.service" && \
|
||||
systemctl stop "$service_name.service" || \
|
||||
echo "$service_name wasn't even running!"
|
||||
elif hash service 2> /dev/null; then
|
||||
service "$service_name" stop || echo "$service_name wasn't even running!"
|
||||
elif hash stop 2> /dev/null; then
|
||||
stop "$service_name" || echo "$service_name wasn't even running!"
|
||||
elif hash update-rc.d 2> /dev/null; then
|
||||
{
|
||||
update-rc.d "$service_name" remove && \
|
||||
"/etc/init.d/$service_name" stop
|
||||
} || "$service_name wasn't even running!"
|
||||
else
|
||||
echo "Your system does not appear to use upstart, systemd or sysv, so $service_name could not be stopped"
|
||||
echo 'Unless these systems were removed since install, no processes have been left running'
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Self-hosted audiobook server for managing and playing audiobooks
|
||||
Requires=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/default/audiobookshelf
|
||||
WorkingDirectory=/usr/share/audiobookshelf
|
||||
ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
PermissionsStartOnly=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
cd "$SCRIPT_DIR/.."
|
||||
|
||||
# Get package version without double quotes
|
||||
VERSION="$( eval echo $( jq '.version' package.json) )"
|
||||
DESCRIPTION="$( eval echo $( jq '.description' package.json) )"
|
||||
OUTPUT_FILE="audiobookshelf_${VERSION}_amd64.deb"
|
||||
|
||||
echo ">>> Building Client"
|
||||
echo "--------------------"
|
||||
|
||||
cd client
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
npm run generate
|
||||
cd ..
|
||||
|
||||
echo ">>> Building Server"
|
||||
echo "--------------------"
|
||||
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
|
||||
echo ">>> Packaging"
|
||||
echo "--------------------"
|
||||
|
||||
# Create debian control file
|
||||
|
||||
mkdir -p dist
|
||||
cp -R build/debian dist/debian
|
||||
chmod -R 775 dist/debian
|
||||
|
||||
controlfile="Package: audiobookshelf
|
||||
Version: $VERSION
|
||||
Section: base
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends:
|
||||
Maintainer: advplyr
|
||||
Description: $DESCRIPTION"
|
||||
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
|
||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||
chmod +x "dist/$OUTPUT_FILE"
|
||||
|
||||
echo "Finished! Filename: $OUTPUT_FILE"
|
||||
@@ -9,20 +9,38 @@
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
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;
|
||||
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;
|
||||
@@ -61,4 +79,52 @@
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
}
|
||||
|
||||
.box-shadow-md {
|
||||
box-shadow: 2px 8px 6px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-sm-up {
|
||||
box-shadow: 0px -5px 8px #11111122;
|
||||
}
|
||||
|
||||
.box-shadow-md-up {
|
||||
box-shadow: 0px -8px 8px #11111144;
|
||||
}
|
||||
|
||||
.box-shadow-lg-up {
|
||||
box-shadow: 0px -12px 8px #111111ee;
|
||||
}
|
||||
|
||||
.box-shadow-xl {
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-book3d {
|
||||
box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-side {
|
||||
box-shadow: 5px 0px 5px #11111166;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-24 top-0 bottom-0">
|
||||
<div class="absolute right-20 top-0 bottom-0">
|
||||
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-32 top-0 bottom-0">
|
||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||
</div>
|
||||
<div class="flex my-2">
|
||||
@@ -27,7 +33,7 @@
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
|
||||
<controls-playback-speed-control v-model="playbackRate" @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">
|
||||
@@ -47,10 +53,17 @@
|
||||
<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-50 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">00:00</p>
|
||||
<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>
|
||||
@@ -58,6 +71,8 @@
|
||||
</div>
|
||||
|
||||
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,7 +81,11 @@ import Hls from 'hls.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean
|
||||
loading: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -84,18 +103,37 @@ export default {
|
||||
audioEl: null,
|
||||
totalDuration: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false
|
||||
seekLoading: false,
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16 // Track is 16px from edge
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
token() {
|
||||
return this.$store.getters.getToken
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.totalDuration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) {
|
||||
return
|
||||
@@ -110,7 +148,7 @@ export default {
|
||||
}
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
console.warn('SEEK TO', this.$secondsToTimestamp(time))
|
||||
|
||||
this.audioEl.currentTime = time
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
@@ -130,22 +168,51 @@ export default {
|
||||
},
|
||||
updatePlaybackRate(playbackRate) {
|
||||
if (this.audioEl) {
|
||||
console.log('UpdatePlaybackRate', playbackRate)
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
try {
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
this.audioEl.defaultPlaybackRate = playbackRate
|
||||
} catch (error) {
|
||||
console.error('Update playback rate failed', error)
|
||||
}
|
||||
} else {
|
||||
console.error('No Audio El updatePlaybackRate')
|
||||
}
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.updatePlaybackRate(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.totalDuration
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
|
||||
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) {
|
||||
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
|
||||
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
|
||||
@@ -156,6 +223,9 @@ export default {
|
||||
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
|
||||
}
|
||||
@@ -259,7 +329,6 @@ export default {
|
||||
end: end + offset
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
},
|
||||
getLastBufferedTime() {
|
||||
@@ -285,7 +354,6 @@ export default {
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
timeupdate() {
|
||||
// console.log('Time update', this.audioEl.currentTime)
|
||||
if (!this.$refs.playedTrack) {
|
||||
console.error('Invalid no played track ref')
|
||||
return
|
||||
@@ -305,6 +373,8 @@ export default {
|
||||
|
||||
this.updateTimestamp()
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
@@ -351,11 +421,12 @@ export default {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
}
|
||||
}
|
||||
console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
var audio = this.$refs.audio
|
||||
audio.volume = this.volume
|
||||
audio.playbackRate = this.playbackRate
|
||||
audio.defaultPlaybackRate = this.playbackRate
|
||||
|
||||
this.hlsInstance.attachMedia(audio)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
// console.log('[HLS] MEDIA ATTACHED')
|
||||
@@ -369,16 +440,19 @@ export default {
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details)
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
console.warn('[HLS] Destroying HLS Instance')
|
||||
console.log('[HLS] Destroying HLS Instance')
|
||||
})
|
||||
})
|
||||
},
|
||||
showChapters() {
|
||||
this.showChaptersModal = true
|
||||
},
|
||||
play() {
|
||||
if (!this.$refs.audio) {
|
||||
console.error('No Audio ref')
|
||||
@@ -399,7 +473,6 @@ export default {
|
||||
this.staleHlsInstance = this.hlsInstance
|
||||
this.staleHlsInstance.destroy()
|
||||
this.hlsInstance = null
|
||||
console.log('Terminated HLS Instance', this.staleHlsInstance)
|
||||
}
|
||||
},
|
||||
async resetStream(startTime) {
|
||||
@@ -410,17 +483,27 @@ export default {
|
||||
this.set(this.url, startTime, true)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
this.audioEl = this.$refs.audio
|
||||
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) {
|
||||
this.updatePlaybackRate(settings.playbackRate)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// this.$nextTick(this.init)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,56 @@
|
||||
<template>
|
||||
<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-6 py-1 z-30">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
|
||||
<img v-if="!showBack" src="/Logo48.png" class="w-12 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">AudioBookshelf</h1>
|
||||
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
<!-- <div class="-mb-2">
|
||||
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
|
||||
<p class="text-sm text-gray-400 leading-3">My Library</p>
|
||||
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
|
||||
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<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">
|
||||
<span class="material-icons">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 ml-4">
|
||||
<span class="material-icons">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
|
||||
<nuxt-link to="/account" class="relative w-32 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ username }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">person</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
|
||||
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
|
||||
>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate">
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,56 +60,131 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
menuItems: [
|
||||
// {
|
||||
// value: 'settings',
|
||||
// text: 'Settings'
|
||||
// },
|
||||
{
|
||||
value: 'logout',
|
||||
text: 'Logout'
|
||||
}
|
||||
]
|
||||
processingBatchDelete: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHome() {
|
||||
return this.$route.name === 'index'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'index'
|
||||
return this.$route.name !== 'library-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
isAllSelected() {
|
||||
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
},
|
||||
audiobooksShowing() {
|
||||
// return this.$store.getters['audiobooks/getFiltered']()
|
||||
return this.$store.getters['audiobooks/getEntitiesShowing']()
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
selectedIsRead() {
|
||||
// Find an audiobook that is not read, if none then all audiobooks read
|
||||
return !this.selectedAudiobooks.find((ab) => {
|
||||
var userAb = this.userAudiobooks[ab]
|
||||
return !userAb || !userAb.isRead
|
||||
})
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (this.$route.name === 'audiobook-id-edit') {
|
||||
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
},
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected) {
|
||||
this.cancelSelectionMode()
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
var audiobookIds = this.audiobooksShowing.map((a) => a.id)
|
||||
this.$store.commit('setSelectedAudiobooks', audiobookIds)
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
console.log('Call Start Init')
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
logout() {
|
||||
this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsRead = !this.selectedIsRead
|
||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
||||
return {
|
||||
audiobookId: ab,
|
||||
isRead: newIsRead
|
||||
}
|
||||
})
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
this.$router.push('/login')
|
||||
this.$axios
|
||||
.patch(`/api/user/audiobooks`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
},
|
||||
menuAction(action) {
|
||||
if (action === 'logout') {
|
||||
this.logout()
|
||||
} else if (action === 'settings') {
|
||||
// Show settings modal
|
||||
batchDeleteClick() {
|
||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/audiobooks/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -83,7 +193,6 @@ export default {
|
||||
|
||||
<style>
|
||||
#appbar {
|
||||
/* box-shadow: 0px 8px 8px #111111aa; */
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +1,242 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
|
||||
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in groupedBooks">
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="audiobook in shelf">
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
<template v-for="entity in shelf">
|
||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
width: 0,
|
||||
bookWidth: 176,
|
||||
booksPerRow: 0,
|
||||
groupedBooks: [],
|
||||
currFilterOrderKey: null
|
||||
shelves: [],
|
||||
currFilterOrderKey: null,
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
keywordFilter() {
|
||||
this.checkKeywordFilter()
|
||||
},
|
||||
selectedSeries() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
},
|
||||
searchResults() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
keywordFilter() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
filterOrderKey() {
|
||||
return this.$store.getters['settings/getFilterOrderKey']
|
||||
return this.$store.getters['user/getFilterOrderKey']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
var _width = this.bookCoverWidth + this.paddingX * 2
|
||||
return this.showGroups ? _width * 1.6 : _width
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
showGroups() {
|
||||
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
||||
},
|
||||
entities() {
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||
} else if (this.page === 'search') {
|
||||
return this.searchResults || []
|
||||
} else {
|
||||
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
var group = seriesGroups.find((group) => group.name === this.selectedSeries)
|
||||
return group.books
|
||||
}
|
||||
return seriesGroups
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setGroupedBooks() {
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', null)
|
||||
if (this.filterBy !== 'all') {
|
||||
this.$store.dispatch('user/updateUserSettings', {
|
||||
filterBy: 'all'
|
||||
})
|
||||
} else {
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
},
|
||||
checkKeywordFilter() {
|
||||
clearTimeout(this.keywordFilterTimeout)
|
||||
this.keywordFilterTimeout = setTimeout(() => {
|
||||
this.setBookshelfEntities()
|
||||
}, 500)
|
||||
},
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
setBookshelfEntities() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
||||
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
||||
|
||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||
|
||||
var entities = this.entities
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
var currentGroup = []
|
||||
|
||||
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||
this.currFilterOrderKey = this.filterOrderKey
|
||||
|
||||
for (let i = 0; i < audiobooksSorted.length; i++) {
|
||||
var row = Math.floor(i / this.booksPerRow)
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
var row = Math.floor(i / booksPerRow)
|
||||
if (row > currentRow) {
|
||||
groups.push([...currentGroup])
|
||||
currentRow = row
|
||||
currentGroup = []
|
||||
}
|
||||
currentGroup.push(audiobooksSorted[i])
|
||||
currentGroup.push(entities[i])
|
||||
}
|
||||
if (currentGroup.length) {
|
||||
groups.push([...currentGroup])
|
||||
}
|
||||
this.groupedBooks = groups
|
||||
this.shelves = groups
|
||||
},
|
||||
calculateBookshelf() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
var booksPerRow = Math.floor(this.width / this.bookWidth)
|
||||
this.booksPerRow = booksPerRow
|
||||
},
|
||||
getAudiobookCard(id) {
|
||||
if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) {
|
||||
return this.$refs[`audiobookCard-${id}`][0]
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
|
||||
var isLoading = await this.$store.dispatch('audiobooks/load')
|
||||
if (!isLoading) {
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
return null
|
||||
},
|
||||
init() {
|
||||
this.calculateBookshelf()
|
||||
},
|
||||
resize() {
|
||||
this.$nextTick(() => {
|
||||
this.calculateBookshelf()
|
||||
this.setGroupedBooks()
|
||||
})
|
||||
this.$nextTick(this.setBookshelfEntities)
|
||||
},
|
||||
audiobooksUpdated() {
|
||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
},
|
||||
settingsUpdated() {
|
||||
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
|
||||
settingsUpdated(settings) {
|
||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.$refs.wrapper) {
|
||||
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
|
||||
this.$nextTick(this.setBookshelfEntities)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.$store.dispatch('audiobooks/load')
|
||||
this.init()
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('settings/removeListener', 'bookshelf')
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div class="fixed bottom-2 right-4 z-40">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
overflowingShelvesRight: {},
|
||||
overflowingShelvesLeft: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
signSizeMultiplier() {
|
||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
mostRecentPlayed() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
mostRecentAdded() {
|
||||
var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt)
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
seriesGroups() {
|
||||
return this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
},
|
||||
recentlyUpdatedSeries() {
|
||||
var mostRecentTime = 0
|
||||
var mostRecentSeries = null
|
||||
this.seriesGroups.forEach((series) => {
|
||||
if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) {
|
||||
mostRecentTime = series.lastUpdate
|
||||
mostRecentSeries = series
|
||||
}
|
||||
})
|
||||
if (!mostRecentSeries) return null
|
||||
return mostRecentSeries.books
|
||||
},
|
||||
booksRecentlyRead() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
shelves() {
|
||||
var shelves = []
|
||||
if (this.mostRecentPlayed.length) {
|
||||
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
|
||||
}
|
||||
|
||||
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
|
||||
|
||||
if (this.recentlyUpdatedSeries) {
|
||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||
}
|
||||
|
||||
if (this.booksRecentlyRead.length) {
|
||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||
}
|
||||
return shelves
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
|
||||
await this.$store.dispatch('audiobooks/load')
|
||||
},
|
||||
resize() {},
|
||||
audiobooksUpdated() {},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center -mb-2">
|
||||
<template v-for="entity in shelf.books">
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||
|
||||
<div v-show="canScrollLeft && !isScrolling" class="absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||
<span class="material-icons text-8xl text-white">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-8xl text-white">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
shelf: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canScrollRight: false,
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 50)
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 0
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 999
|
||||
},
|
||||
updatedBookCard() {
|
||||
clearTimeout(this.updateTimer)
|
||||
this.updateTimer = setTimeout(() => {
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 100)
|
||||
},
|
||||
checkCanScroll() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
var clientWidth = this.$refs.shelf.clientWidth
|
||||
var scrollWidth = this.$refs.shelf.scrollWidth
|
||||
var scrollLeft = this.$refs.shelf.scrollLeft
|
||||
if (scrollWidth > clientWidth) {
|
||||
this.canScrollRight = scrollLeft === 0
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
} else {
|
||||
this.canScrollRight = false
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookshelfRowCategorized {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
background-image: url(/wood_panels.jpg);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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%);
|
||||
border-color: rgba(255, 244, 182, 0.6);
|
||||
border-style: solid;
|
||||
color: #fce3a6;
|
||||
}
|
||||
.book-shelf-arrow-right {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
.book-shelf-arrow-left {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +1,96 @@
|
||||
<template>
|
||||
<div class="w-full h-10 relative">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<p class="font-book">{{ numShowing }} Audiobooks</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" />
|
||||
<span class="px-4 text-sm">by</span>
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
</template>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
|
||||
<div class="flex-grow" />
|
||||
<p>Search results for "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
settings: {}
|
||||
settings: {},
|
||||
hasInit: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showSortFilters() {
|
||||
return this.page === ''
|
||||
},
|
||||
numShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
} else if (this.page === 'search') {
|
||||
return (this.searchResults || []).length
|
||||
} else {
|
||||
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
var group = groups.find((g) => g.name === this.selectedSeries)
|
||||
if (group) return group.books.length
|
||||
return 0
|
||||
}
|
||||
return groups.length
|
||||
}
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'Audiobooks'
|
||||
if (this.page === 'series') return 'Series'
|
||||
if (this.page === 'collections') return 'Collections'
|
||||
return ''
|
||||
},
|
||||
_keywordFilter: {
|
||||
get() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchBackArrow() {
|
||||
this.$router.replace('/library')
|
||||
},
|
||||
seriesBackArrow() {
|
||||
this.$router.replace('/library/series')
|
||||
this.$emit('update:selectedSeries', null)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
@@ -30,15 +98,23 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
// Send to server
|
||||
this.$store.commit('settings/setSettings', this.settings)
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
init() {
|
||||
this.settings = { ...this.$store.state.settings.settings }
|
||||
this.settings = { ...this.$store.state.user.settings }
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
for (const key in settings) {
|
||||
this.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -46,6 +122,6 @@ export default {
|
||||
|
||||
<style>
|
||||
#toolbar {
|
||||
box-shadow: 0px 8px 8px #111111aa;
|
||||
box-shadow: 0px 8px 6px #111111aa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<nuxt-link to="/" 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" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library" 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="paramId === '' && !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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library/series" 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="paramId === 'series' ? '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="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Series</p>
|
||||
|
||||
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<!-- <nuxt-link to="/library/collections" 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="paramId === 'collections' ? '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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
||||
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/tags" 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="paramId === 'tags' ? '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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
||||
|
||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? '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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
||||
|
||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
},
|
||||
selectedClassName() {
|
||||
return ''
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'index'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
|
||||
<div class="absolute -top-16 left-4">
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<div class="flex items-center pl-24">
|
||||
<div>
|
||||
<h1>
|
||||
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm">by {{ author }}</p>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
|
||||
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</nuxt-link>
|
||||
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export default {
|
||||
return 'Logo.png'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
@@ -48,6 +49,9 @@ export default {
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
@@ -62,7 +66,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push('/library')
|
||||
}
|
||||
var settingsUpdate = {
|
||||
filterBy: `authors.${this.$encode(this.author)}`
|
||||
}
|
||||
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
|
||||
},
|
||||
audioPlayerMounted() {
|
||||
this.audioPlayerReady = true
|
||||
if (this.stream) {
|
||||
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
|
||||
this.openStream()
|
||||
@@ -92,7 +106,7 @@ export default {
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
@@ -102,14 +116,14 @@ export default {
|
||||
streamOpen(stream) {
|
||||
this.stream = stream
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log('[STREAM-CONTAINER] streamOpen', stream)
|
||||
console.log('[StreamContainer] streamOpen', stream)
|
||||
this.openStream()
|
||||
} else {
|
||||
} else if (this.audioPlayerReady) {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
if (this.stream && this.stream.id === streamId) {
|
||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
||||
this.terminateStream()
|
||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||
this.stream = null
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
||||
<div class="flex-grow px-2 searchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: calc(40px * 1.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
|
||||
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
|
||||
<div class="perspective">
|
||||
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
|
||||
<div class="book book-1 box-shadow-book3d" ref="front"></div>
|
||||
<div class="title book-1 pointer-events-none" ref="left"></div>
|
||||
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
|
||||
<div class="book-back book-1 pointer-events-none">
|
||||
<div class="text pointer-events-none">
|
||||
<h3 class="mb-4">Book Back</h3>
|
||||
<p>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
hover2: false,
|
||||
standardWidth: 200,
|
||||
standardHeight: 320,
|
||||
isAttached: true,
|
||||
pageX: 0,
|
||||
pageY: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src(newVal) {
|
||||
this.setCover()
|
||||
},
|
||||
width(newVal) {
|
||||
this.init()
|
||||
},
|
||||
hover(newVal) {
|
||||
if (newVal) {
|
||||
this.unattach()
|
||||
} else {
|
||||
this.attach()
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.hover2 = newVal
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scaleMultiplier() {
|
||||
return this.hover2 ? 1.25 : 1
|
||||
},
|
||||
scale() {
|
||||
var scale = this.width / this.standardWidth
|
||||
return scale
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
unattach() {
|
||||
if (this.$refs.card && this.isAttached) {
|
||||
var bookshelf = document.getElementById('bookshelf')
|
||||
if (bookshelf) {
|
||||
var pos = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
this.pageX = pos.x
|
||||
this.pageY = pos.y
|
||||
document.body.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = this.pageX + 'px'
|
||||
this.$refs.card.style.top = this.pageY + 'px'
|
||||
this.$refs.card.style.zIndex = 50
|
||||
this.isAttached = false
|
||||
} else if (bookshelf) {
|
||||
console.log(this.pageX, this.pageY)
|
||||
this.isAttached = false
|
||||
}
|
||||
}
|
||||
},
|
||||
attach() {
|
||||
if (this.$refs.card && !this.isAttached) {
|
||||
if (this.$refs.wrapper) {
|
||||
this.isAttached = true
|
||||
|
||||
this.$refs.wrapper.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = '0px'
|
||||
this.$refs.card.style.top = '0px'
|
||||
}
|
||||
} else {
|
||||
console.log('Is attached already', this.isAttached)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
var standardWidth = this.standardWidth
|
||||
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
|
||||
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
|
||||
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
|
||||
document.documentElement.style.setProperty('--book-d', 40 + 'px')
|
||||
},
|
||||
setElBg(el) {
|
||||
el.style.backgroundImage = `url("${this.src}")`
|
||||
el.style.backgroundSize = 'cover'
|
||||
el.style.backgroundPosition = 'center center'
|
||||
el.style.backgroundRepeat = 'no-repeat'
|
||||
},
|
||||
setCover() {
|
||||
if (this.$refs.front) {
|
||||
this.setElBg(this.$refs.front)
|
||||
}
|
||||
if (this.$refs.bottom) {
|
||||
this.setElBg(this.$refs.bottom)
|
||||
this.$refs.bottom.style.backgroundSize = '2000%'
|
||||
this.$refs.bottom.style.filter = 'blur(1px)'
|
||||
}
|
||||
if (this.$refs.left) {
|
||||
this.setElBg(this.$refs.left)
|
||||
this.$refs.left.style.backgroundSize = '2000%'
|
||||
this.$refs.left.style.filter = 'blur(1px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setCover()
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* :root {
|
||||
--book-w: 200px;
|
||||
--book-h: 320px;
|
||||
--book-d: 30px;
|
||||
--book-wx: 201px;
|
||||
} */
|
||||
/*
|
||||
.wrap {
|
||||
width: calc(1.1 * var(--book-w));
|
||||
height: calc(1.1 * var(--book-h));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.perspective {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
perspective: 600px;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: 'all ease-out 0.6s';
|
||||
}
|
||||
|
||||
.book {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
content: '';
|
||||
height: var(--book-h);
|
||||
width: var(--book-d);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: calc(var(--book-wx) * -1);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(-80deg) translateX(-14px);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
content: '';
|
||||
height: var(--book-d);
|
||||
width: var(--book-w);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: var(--book-h);
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.book-back {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background-color: #444;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
transform: rotate(180deg) translateZ(-30px) translateX(5px);
|
||||
}
|
||||
.book-back .text {
|
||||
transform: rotateX(180deg);
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.book-back .text h3 {
|
||||
color: #fff;
|
||||
}
|
||||
.book-back .text span {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.book-wrap.rotate {
|
||||
transform: rotateY(30deg) rotateX(0deg);
|
||||
}
|
||||
.book-wrap.flip {
|
||||
transform: rotateY(180deg);
|
||||
} */
|
||||
</style>
|
||||
@@ -1,29 +1,53 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
|
||||
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<!-- New Book Flag -->
|
||||
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
|
||||
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
|
||||
<p class="text-center text-sm">New</p>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons text-5xl">play_circle_filled</span>
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<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>
|
||||
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" style="font-size: 16px">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
|
||||
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :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' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 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' }">EBook</p>
|
||||
</div> -->
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -36,7 +60,12 @@ export default {
|
||||
userProgress: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
showVolumeNumber: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -44,29 +73,82 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isNew() {
|
||||
return this.tags.includes('New')
|
||||
},
|
||||
tags() {
|
||||
return this.audiobook.tags || []
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
isSelectionMode() {
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
selected() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
width() {
|
||||
return 120
|
||||
},
|
||||
height() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
title() {
|
||||
return this.book.title
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
author() {
|
||||
return this.book.author
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL || this.author
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF || this.author
|
||||
},
|
||||
authorFormat() {
|
||||
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
|
||||
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this.audiobook.hasMissingParts
|
||||
@@ -75,6 +157,7 @@ export default {
|
||||
return this.audiobook.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
@@ -84,9 +167,28 @@ export default {
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
clickError(e) {
|
||||
e.stopPropagation()
|
||||
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||
@@ -97,14 +199,14 @@ export default {
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookCard {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<img ref="cover" :src="cover" @error="imageError" class="w-full h-full object-cover" />
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
@@ -26,6 +32,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -33,13 +40,22 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
bookLastUpdate() {
|
||||
return this.book.lastUpdate || Date.now()
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
@@ -50,6 +66,7 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorCleaned() {
|
||||
@@ -58,8 +75,14 @@ export default {
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || '/book_placeholder.jpg'
|
||||
return this.book.cover || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
@@ -78,9 +101,37 @@ export default {
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
this.$refs.coverBg.style.backgroundSize = 'cover'
|
||||
this.$refs.coverBg.style.backgroundPosition = 'center'
|
||||
this.$refs.coverBg.style.opacity = 0.25
|
||||
this.$refs.coverBg.style.filter = 'blur(1px)'
|
||||
}
|
||||
},
|
||||
hideCoverBg() {},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
||||
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap">
|
||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_group() {
|
||||
return this.group || {}
|
||||
},
|
||||
height() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
},
|
||||
userAudiobooks() {
|
||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
||||
},
|
||||
userProgressItems() {
|
||||
return this.bookItems.map((item) => {
|
||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
||||
return userAudiobook || {}
|
||||
})
|
||||
},
|
||||
groupName() {
|
||||
return this._group.name || 'No Name'
|
||||
},
|
||||
groupType() {
|
||||
return this._group.type
|
||||
},
|
||||
groupEncode() {
|
||||
return this.$encode(this.groupName)
|
||||
},
|
||||
filter() {
|
||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
|
||||
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: String,
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noValidCovers: false,
|
||||
coverDiv: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookItems: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
// ensure wrapper is initialized
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 192
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCoverUrl(book) {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(src, bgCoverWidth, offsetLeft, forceCoverBg = false) {
|
||||
var showCoverBg =
|
||||
forceCoverBg ||
|
||||
(await new Promise((resolve) => {
|
||||
var image = new Image()
|
||||
|
||||
image.onload = () => {
|
||||
var { naturalWidth, naturalHeight } = image
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
resolve(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
image.onerror = (err) => {
|
||||
console.error(err)
|
||||
resolve(false)
|
||||
}
|
||||
image.src = src
|
||||
}))
|
||||
|
||||
var imgdiv = document.createElement('div')
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = bgCoverWidth + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book'
|
||||
imgdiv.style.boxShadow = '-4px 0px 4px #11111166'
|
||||
// imgdiv.style.transform = 'skew(0deg, 15deg)'
|
||||
|
||||
if (showCoverBg) {
|
||||
var coverbgwrapper = document.createElement('div')
|
||||
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full bg-primary'
|
||||
|
||||
var coverbg = document.createElement('div')
|
||||
coverbg.className = 'w-full h-full'
|
||||
coverbg.style.backgroundImage = `url("${src}")`
|
||||
coverbg.style.backgroundSize = 'cover'
|
||||
coverbg.style.backgroundPosition = 'center'
|
||||
coverbg.style.opacity = 0.25
|
||||
coverbg.style.filter = 'blur(1px)'
|
||||
|
||||
coverbgwrapper.appendChild(coverbg)
|
||||
imgdiv.appendChild(coverbgwrapper)
|
||||
}
|
||||
|
||||
var img = document.createElement('img')
|
||||
img.src = src
|
||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||
|
||||
imgdiv.appendChild(img)
|
||||
return imgdiv
|
||||
},
|
||||
async init() {
|
||||
if (this.coverDiv) {
|
||||
this.coverDiv.remove()
|
||||
this.coverDiv = null
|
||||
}
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
|
||||
if (!validCovers.length) {
|
||||
this.noValidCovers = true
|
||||
return
|
||||
}
|
||||
this.noValidCovers = false
|
||||
|
||||
var coverWidth = this.width
|
||||
var widthPer = this.width
|
||||
if (validCovers.length > 1) {
|
||||
coverWidth = this.height / 1.6
|
||||
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
|
||||
}
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
outerdiv.className = 'w-full h-full relative'
|
||||
|
||||
for (let i = 0; i < validCovers.length; i++) {
|
||||
var offsetLeft = widthPer * i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, validCovers.length === 1)
|
||||
outerdiv.appendChild(img)
|
||||
}
|
||||
|
||||
if (this.$refs.wrapper) {
|
||||
this.coverDiv = outerdiv
|
||||
this.$refs.wrapper.appendChild(outerdiv)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cover() {
|
||||
return this.src
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
|
||||
this.$refs.coverBg.style.backgroundSize = 'cover'
|
||||
this.$refs.coverBg.style.backgroundPosition = 'center'
|
||||
this.$refs.coverBg.style.opacity = 0.25
|
||||
this.$refs.coverBg.style.filter = 'blur(1px)'
|
||||
}
|
||||
},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
@@ -39,9 +42,9 @@
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -73,6 +76,21 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Authors',
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,31 +119,41 @@ export default {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
if (parts.length > 1) {
|
||||
return this.snakeToNormal(parts[1])
|
||||
return this.$decode(parts[1])
|
||||
}
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
return this.$store.getters['audiobooks/getGenresUsed']
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
authors() {
|
||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||
},
|
||||
progress() {
|
||||
return ['Read', 'Unread', 'In Progress']
|
||||
},
|
||||
sublistItems() {
|
||||
return this[this.sublist] || []
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
clearSelected() {
|
||||
this.selected = 'all'
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.selectedItemSublist) this.sublist = null
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
</li>
|
||||
<li v-else-if="!items.length" class="py-2 px-2">
|
||||
<p>No Results</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
|
||||
<template v-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
isFocused: false,
|
||||
focusTimeout: null,
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
items: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
this.$router.push(`/library/search?query=${this.search}`)
|
||||
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
})
|
||||
},
|
||||
focussed() {
|
||||
this.isFocused = true
|
||||
this.showMenu = true
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
clearTimeout(this.focusTimeout)
|
||||
this.focusTimeout = setTimeout(() => {
|
||||
this.showMenu = false
|
||||
}, 200)
|
||||
},
|
||||
async runSearch(value) {
|
||||
this.lastSearch = value
|
||||
if (!this.lastSearch) {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
data: res,
|
||||
type: 'audiobook'
|
||||
}
|
||||
})
|
||||
},
|
||||
inputUpdate(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
if (!val) {
|
||||
this.lastSearch = ''
|
||||
this.isTyping = false
|
||||
return
|
||||
}
|
||||
this.isTyping = true
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.isTyping = false
|
||||
this.runSearch(val)
|
||||
}, 1000)
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.type === 'audiobook') {
|
||||
this.$router.push(`/audiobook/${option.data.id}`)
|
||||
}
|
||||
},
|
||||
clickClear() {
|
||||
if (this.search) {
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span> -->
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
@@ -42,13 +37,21 @@ export default {
|
||||
value: 'book.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
value: 'book.author'
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Volume #',
|
||||
value: 'book.volumeNumber'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'duration'
|
||||
@@ -78,7 +81,8 @@ export default {
|
||||
}
|
||||
},
|
||||
selectedText() {
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
|
||||
@@ -8,12 +8,24 @@
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full no-scroll flex">
|
||||
<template v-for="(rate, index) in rates">
|
||||
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm">⨯</span></p>
|
||||
<div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 h-full w-7 border-r border-black-300 bg-black-300 rounded-l-lg flex items-center justify-center cursor-pointer" :class="rateIndex === 0 ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="leftArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
|
||||
</div>
|
||||
<div class="overflow-hidden relative" style="width: 220px">
|
||||
<div class="flex items-center h-full absolute top-0 left-0 transition-transform duration-100" :style="{ transform: `translateX(${xPos}px)` }">
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border-r" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 h-full w-7 bg-black-300 rounded-r-lg flex items-center justify-center cursor-pointer" :class="rateIndex === rates.length - numVisible ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="rightArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +41,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
showMenu: false,
|
||||
rateIndex: 1,
|
||||
numVisible: 5
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -42,7 +56,10 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
|
||||
return [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
},
|
||||
xPos() {
|
||||
return -1 * this.rateIndex * 44
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -55,6 +72,12 @@ export default {
|
||||
this.playbackRate = newPlaybackRate
|
||||
if (hasChanged) this.$emit('change', newPlaybackRate)
|
||||
this.showMenu = false
|
||||
},
|
||||
leftArrowClick() {
|
||||
this.rateIndex = Math.max(0, this.rateIndex - 1)
|
||||
},
|
||||
rightArrowClick() {
|
||||
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :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>
|
||||
</div>
|
||||
</template>
|
||||
<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="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
</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" />
|
||||
</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>
|
||||
<div class="flex-grow" />
|
||||
<div v-show="!isEditingRoot" 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>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">Permissions</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Download</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Update</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Delete</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Upload</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
account: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin']
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newUser.username) {
|
||||
this.$toast.error('Enter a username')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
} else {
|
||||
this.submitUpdateAccount()
|
||||
}
|
||||
},
|
||||
submitUpdateAccount() {
|
||||
var account = { ...this.newUser }
|
||||
if (!account.password || account.type === 'root') {
|
||||
delete account.password
|
||||
}
|
||||
if (account.type === 'root' && !account.isActive) return
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/${this.account.id}`, account)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
} else {
|
||||
this.$toast.success('Account updated')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update account', error)
|
||||
this.processing = false
|
||||
this.$toast.error('Failed to update account')
|
||||
})
|
||||
},
|
||||
submitCreateAccount() {
|
||||
if (!this.newUser.password) {
|
||||
this.$toast.error('Must have a password, only root user can have an empty password')
|
||||
return
|
||||
}
|
||||
|
||||
var account = { ...this.newUser }
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/user', account)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to create account: ${data.error}`)
|
||||
} else {
|
||||
this.$toast.success('New account created')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create account', error)
|
||||
this.processing = false
|
||||
this.$toast.error('Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin'
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions }
|
||||
}
|
||||
} else {
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
permissions: {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false,
|
||||
upload: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChapter(chap) {
|
||||
this.$emit('select', chap)
|
||||
},
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
|
||||
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||
<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 class="absolute -top-10 left-0 w-full flex">
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<keep-alive>
|
||||
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
|
||||
</keep-alive>
|
||||
@@ -23,20 +22,63 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
selectedTab: 'details',
|
||||
processing: false,
|
||||
audiobook: null
|
||||
audiobook: null,
|
||||
fetchOnShow: false,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-edit-tabs-details'
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
component: 'modals-edit-tabs-tracks'
|
||||
},
|
||||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-edit-tabs-chapters'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
|
||||
var availableTabIds = this.availableTabs.map((tab) => tab.id)
|
||||
if (!availableTabIds.length) {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
||||
if (this.fetchOnShow) this.fetchFull()
|
||||
return
|
||||
}
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.init()
|
||||
} else {
|
||||
this.$store.commit('audiobooks/removeListener', 'edit-modal')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,12 +92,39 @@ export default {
|
||||
this.$store.commit('setShowEditModal', val)
|
||||
}
|
||||
},
|
||||
selectedTab: {
|
||||
get() {
|
||||
return this.$store.state.editModalTab
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setEditModalTab', val)
|
||||
}
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.id === 'download' && this.isMissing) return false
|
||||
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
},
|
||||
height() {
|
||||
var maxHeightAllowed = window.innerHeight - 150
|
||||
return Math.min(maxHeightAllowed, 650)
|
||||
},
|
||||
tabName() {
|
||||
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
|
||||
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
|
||||
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
|
||||
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
|
||||
return ''
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
isMissing() {
|
||||
return this.selectedAudiobook.isMissing
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook || {}
|
||||
@@ -75,7 +144,10 @@ export default {
|
||||
this.selectedTab = tab
|
||||
},
|
||||
audiobookUpdated() {
|
||||
this.fetchFull()
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
else {
|
||||
this.fetchFull()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-30 opacity-0">
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
@@ -31,6 +31,10 @@ export default {
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: 'unset'
|
||||
},
|
||||
contentMarginTop: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<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>
|
||||
<template v-for="chapter in chapters">
|
||||
<tr :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>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chapters: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
init() {
|
||||
this.chapters = this.audiobook.chapters || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,44 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||
<div class="flex">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<span class="material-icons">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image(s)</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover.localPath" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
|
||||
@@ -23,20 +53,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">No Covers Found</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||
<p class="text-lg">Preview Cover</p>
|
||||
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<div class="flex justify-center py-4">
|
||||
<cards-preview-cover :src="previewUpload" :width="240" />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
||||
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
|
||||
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
@@ -47,18 +94,24 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processingUpload: false,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
imageUrl: null,
|
||||
coversFound: [],
|
||||
hasSearched: false
|
||||
hasSearched: false,
|
||||
showLocalCovers: false,
|
||||
previewUpload: null,
|
||||
selectedFile: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,10 +126,63 @@ export default {
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
_file.localPath = Path.join('local', _file.path)
|
||||
return _file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitCoverUpload() {
|
||||
this.processingUpload = true
|
||||
var form = new FormData()
|
||||
form.set('cover', this.selectedFile)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Cover Uploaded')
|
||||
this.resetCoverPreview()
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
},
|
||||
resetCoverPreview() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.reset()
|
||||
}
|
||||
this.previewUpload = null
|
||||
this.selectedFile = null
|
||||
},
|
||||
fileUploadSelected(file) {
|
||||
this.previewUpload = URL.createObjectURL(file)
|
||||
this.selectedFile = file
|
||||
},
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
@@ -85,29 +191,59 @@ export default {
|
||||
this.searchTitle = this.book.title || ''
|
||||
this.searchAuthor = this.book.author || ''
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.book.cover) {
|
||||
this.imageUrl = ''
|
||||
return
|
||||
}
|
||||
this.updateCover('')
|
||||
},
|
||||
submitForm() {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
}
|
||||
if (cover === this.book.cover) {
|
||||
console.warn('Cover has not changed..', cover)
|
||||
return
|
||||
}
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
|
||||
this.isProcessing = true
|
||||
var success = false
|
||||
|
||||
// Download cover from url and use
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
||||
console.error('Failed to download cover from url', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
// Update local cover url
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
}
|
||||
}
|
||||
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.book.cover || ''
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=best&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
|
||||
@@ -1,36 +1,72 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
|
||||
<div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
|
||||
<div class="w-full flex items-center">
|
||||
<p>
|
||||
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm">
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
|
||||
<div class="w-full flex items-center">
|
||||
<p>
|
||||
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div> -->
|
||||
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
|
||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label 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 v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
|
||||
<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="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
|
||||
<ui-tooltip text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex py-4">
|
||||
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -49,14 +85,20 @@ export default {
|
||||
return {
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
author: null,
|
||||
narrarator: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
genres: []
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
genres: ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -76,50 +118,96 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.$store.getters['getUserAudiobook'](this.audiobookId)
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userProgress() {
|
||||
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addGenre(genre) {
|
||||
this.genres.push({
|
||||
text: genre,
|
||||
value: genre
|
||||
})
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
async submitForm() {
|
||||
console.log('Submit form', this.details)
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: this.details
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.book.title
|
||||
this.details.subtitle = this.book.subtitle
|
||||
this.details.description = this.book.description
|
||||
this.details.author = this.book.author
|
||||
this.details.narrarator = this.book.narrarator
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
@@ -138,7 +226,7 @@ export default {
|
||||
}
|
||||
},
|
||||
deleteAudiobook() {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?`)) {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/audiobook/${this.audiobookId}`)
|
||||
@@ -153,7 +241,41 @@ export default {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.formWrapper) {
|
||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(this.$refs.formWrapper)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// this.init()
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads 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.<br />Download will timeout after 15 minutes.</p>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
|
||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
||||
<p v-else>Zip 1 File</p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
singleDownloadStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
singleAudioDownload() {
|
||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
||||
},
|
||||
singleDownloadStatus() {
|
||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
||||
},
|
||||
zipDownload() {
|
||||
return this.downloads.find((d) => d.type === 'zip')
|
||||
},
|
||||
zipDownloadStatus() {
|
||||
return this.zipDownload ? this.zipDownload.status : false
|
||||
},
|
||||
isSingleTrack() {
|
||||
if (!this.audiobook.tracks) return false
|
||||
return this.audiobook.tracks.length === 1
|
||||
},
|
||||
singleTrackPath() {
|
||||
if (!this.isSingleTrack) return null
|
||||
return this.audiobook.tracks[0].path
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startZipDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'zip'
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
startSingleAudioDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'singleAudio',
|
||||
includeMetadata: true,
|
||||
includeCover: true
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-72 px-1">
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>No Results</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||
@@ -37,11 +37,13 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
audiobookId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'best',
|
||||
searchResults: []
|
||||
searchResults: [],
|
||||
hasSearched: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -64,7 +66,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
@@ -90,15 +92,22 @@ export default {
|
||||
})
|
||||
this.searchResults = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.author || ''
|
||||
this.runSearch()
|
||||
},
|
||||
async selectMatch(match) {
|
||||
this.isProcessing = true
|
||||
@@ -120,7 +129,6 @@ export default {
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="flex mb-4">
|
||||
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
||||
<ui-btn color="primary">Edit Track Order</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -26,6 +27,9 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
@@ -54,12 +58,24 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.audioFiles = this.audiobook.audioFiles
|
||||
this.tracks = this.audiobook.tracks
|
||||
console.log('INIT', this.audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p class="pr-4">Other Audio Files</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<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' : ''">
|
||||
@@ -56,7 +56,11 @@ export default {
|
||||
showTracks: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p class="pr-4">Audio Tracks</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<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' : ''">
|
||||
@@ -19,6 +19,7 @@
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -34,6 +35,9 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
@@ -56,7 +60,14 @@ export default {
|
||||
showTracks: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<template>
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
to: String,
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
@@ -16,7 +32,9 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
small: Boolean
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -24,6 +42,7 @@ export default {
|
||||
computed: {
|
||||
classList() {
|
||||
var list = []
|
||||
if (this.loading) list.push('text-opacity-0')
|
||||
list.push('text-white')
|
||||
list.push(`bg-${this.color}`)
|
||||
if (this.small) {
|
||||
@@ -50,7 +69,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.btn::before {
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
@@ -61,7 +80,10 @@ button.btn::before {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.btn:hover::before {
|
||||
.btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="relative w-44" v-click-outside="clickOutside">
|
||||
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">chevron_down</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedItem() {
|
||||
return this.items.find((i) => i.value === this.selected)
|
||||
},
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(itemValue) {
|
||||
this.selected = itemValue
|
||||
this.showMenu = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp'
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
reset() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
}
|
||||
},
|
||||
clickUpload() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.click()
|
||||
}
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
var file = _files[0]
|
||||
console.log('File', file)
|
||||
this.$emit('change', file)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
|
||||
<span class="material-icons icon-text">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
disabled: Boolean,
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
className() {
|
||||
var classes = []
|
||||
classes.push(`bg-${this.bgColor}`)
|
||||
return classes.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
this.$emit('click')
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.icon-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.icon-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
disabled: Boolean,
|
||||
label: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.textInput = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
inputBlur() {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = item
|
||||
|
||||
// this.input = this.textInput ? this.textInput.trim() : null
|
||||
console.log('Clicked option', item)
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="w-40">
|
||||
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
|
||||
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
|
||||
<div class="loader-dots block relative w-20 h-5 mt-2">
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
|
||||
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -13,7 +18,7 @@
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
@@ -47,7 +52,6 @@ export default {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
@@ -71,38 +75,14 @@ export default {
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var normie = this.snakeToNormal(i)
|
||||
var iValue = String(normie).toLowerCase()
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
},
|
||||
normalToSnake(normie) {
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
},
|
||||
setMatchingItems() {
|
||||
if (!this.textInput) {
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.isTyping = true
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
@@ -156,8 +136,10 @@ export default {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, itemValue) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
@@ -169,6 +151,9 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.showMenu) {
|
||||
@@ -176,10 +161,15 @@ export default {
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
var kebabItem = this.normalToSnake(item)
|
||||
this.selected.push(kebabItem)
|
||||
this.$emit('addOption', kebabItem)
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
@@ -190,13 +180,12 @@ export default {
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.toLowerCase().trim()
|
||||
var cleanedKebab = this.normalToSnake(cleaned)
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned || cleanedKebab === i
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(matchesItem.value)
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" 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-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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isRead: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
this.$emit('click')
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.icon-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.icon-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -12,8 +12,15 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
transparent: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
paddingX: {
|
||||
type: Number,
|
||||
default: 3
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -26,11 +33,29 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
classList() {
|
||||
var _list = []
|
||||
_list.push(`px-${this.paddingX}`)
|
||||
_list.push(`py-${this.paddingY}`)
|
||||
return _list.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focused() {
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +12,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
note: String,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
onColor: {
|
||||
type: String,
|
||||
default: 'success'
|
||||
},
|
||||
offColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
computed: {
|
||||
toggleValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
className() {
|
||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||
},
|
||||
switchClassName() {
|
||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
||||
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickToggle() {
|
||||
if (this.disabled) return
|
||||
this.toggleValue = !this.toggleValue
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,12 @@ export default {
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -18,21 +23,72 @@ export default {
|
||||
isShowing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.updateText()
|
||||
},
|
||||
disabled(newVal) {
|
||||
if (newVal && this.isShowing) {
|
||||
this.hideTooltip()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateText() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.innerHTML = this.text
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
getTextWidth() {
|
||||
var styles = {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
console.log('Text Size', size.width, size.height)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
var top = boxChow.top
|
||||
var left = boxChow.left + boxChow.width + 4
|
||||
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.innerText = this.text
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
|
||||
this.setTooltipPosition(tooltip)
|
||||
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
setTooltipPosition(tooltip) {
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
|
||||
var shouldMount = !tooltip.isConnected
|
||||
// Calculate size of tooltip
|
||||
if (shouldMount) document.body.appendChild(tooltip)
|
||||
var { width, height } = tooltip.getBoundingClientRect()
|
||||
if (shouldMount) tooltip.remove()
|
||||
|
||||
var top = 0
|
||||
var left = 0
|
||||
if (this.direction === 'right') {
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left + boxChow.width + 4
|
||||
} else if (this.direction === 'bottom') {
|
||||
top = boxChow.top + boxChow.height + 4
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
} else if (this.direction === 'top') {
|
||||
top = boxChow.top - height - 4
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
} else if (this.direction === 'left') {
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left - width - 4
|
||||
}
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
},
|
||||
showTooltip() {
|
||||
if (this.disabled) return
|
||||
if (!this.tooltip) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
<template>
|
||||
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
|
||||
<p class="text-lg font-sans" v-html="text" />
|
||||
</div>
|
||||
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
|
||||
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
hasCanceled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isScanning(newVal) {
|
||||
if (newVal) {
|
||||
this.hasCanceled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
|
||||
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
},
|
||||
isScanning() {
|
||||
return this.isScanningFiles || this.isScanningCovers
|
||||
},
|
||||
isScanningFiles() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
scanProgressKey() {
|
||||
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
|
||||
},
|
||||
scanProgress() {
|
||||
return this.$store.state.scanProgress
|
||||
return this.$store.state[this.scanProgressKey]
|
||||
},
|
||||
scanPercent() {
|
||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
||||
@@ -31,7 +53,12 @@ export default {
|
||||
return this.scanProgress ? this.scanProgress.total : 0
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
cancelScan() {
|
||||
this.hasCanceled = true
|
||||
this.$root.socket.emit('cancel_scan')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||
<app-appbar />
|
||||
|
||||
<Nuxt />
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<modals-edit-modal />
|
||||
<widgets-scan-alert />
|
||||
@@ -10,6 +12,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
data() {
|
||||
return {
|
||||
socket: null
|
||||
@@ -20,17 +23,23 @@ export default {
|
||||
if (this.$store.state.showEditModal) {
|
||||
this.$store.commit('setShowEditModal', false)
|
||||
}
|
||||
if (this.$store.state.selectedAudiobooks) {
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
}
|
||||
if (this.$store.state.audiobooks.keywordFilter) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connect() {
|
||||
console.log('[SOCKET] Connected')
|
||||
var token = this.$store.getters.getToken
|
||||
var token = this.$store.getters['user/getToken']
|
||||
this.socket.emit('auth', token)
|
||||
},
|
||||
connectError() {},
|
||||
@@ -49,7 +58,11 @@ export default {
|
||||
}
|
||||
}
|
||||
if (payload.user) {
|
||||
this.$store.commit('setUser', payload.user)
|
||||
this.$store.commit('user/setUser', payload.user)
|
||||
this.$store.commit('user/setSettings', payload.user.settings)
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
@@ -76,26 +89,110 @@ export default {
|
||||
audiobookRemoved(audiobook) {
|
||||
if (this.$route.name.startsWith('audiobook')) {
|
||||
if (this.$route.params.id === audiobook.id) {
|
||||
this.$router.replace('/')
|
||||
this.$router.replace('/library')
|
||||
}
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
},
|
||||
scanComplete() {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
this.$toast.success('Scan Finished')
|
||||
scanComplete({ scanType, results }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', false)
|
||||
if (results) {
|
||||
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
if (results) {
|
||||
var scanResultMsgs = []
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||
}
|
||||
}
|
||||
},
|
||||
scanStart() {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
scanStart(scanType) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', true)
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
}
|
||||
},
|
||||
scanProgress(progress) {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
scanProgress({ scanType, progress }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setCoverScanProgress', progress)
|
||||
} else {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.id === user.id) {
|
||||
this.$store.commit('setUser', user)
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setSettings', user.settings)
|
||||
}
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
}
|
||||
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||
if (!audiobook) {
|
||||
return console.error('Audiobook not found for download', download)
|
||||
}
|
||||
this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
||||
},
|
||||
downloadStarted(download) {
|
||||
download.status = this.$constants.DownloadStatus.PENDING
|
||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadReady(download) {
|
||||
download.status = this.$constants.DownloadStatus.READY
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
this.$toast.success(`Download "${download.filename}" is ready!`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadFailed(download) {
|
||||
download.status = this.$constants.DownloadStatus.FAILED
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
console.warn('Download failed no existing download', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadKilled(download) {
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
console.warn('Download killed no existing download found', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" was terminated`)
|
||||
}
|
||||
this.$store.commit('downloads/removeDownload', download)
|
||||
},
|
||||
downloadExpired(download) {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -136,21 +233,60 @@ export default {
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
|
||||
// Download Listeners
|
||||
this.socket.on('download_started', this.downloadStarted)
|
||||
this.socket.on('download_ready', this.downloadReady)
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
},
|
||||
checkVersion() {
|
||||
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => {
|
||||
console.log('GOT DATA', data)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
if (!this.$store.state.user) {
|
||||
this.$router.replace(`/login?redirect=${this.$route.path}`)
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
var latestVersion = versionData.latestVersion
|
||||
|
||||
if (!ignoreVersion || ignoreVersion !== latestVersion) {
|
||||
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
|
||||
position: 'top-center',
|
||||
toastClassName: 'cursor-pointer',
|
||||
bodyClassName: 'custom-class-1',
|
||||
timeout: 20000,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
hideProgressBar: false,
|
||||
onClick: () => {
|
||||
window.open(versionData.githubTagUrl, '_blank')
|
||||
},
|
||||
onClose: () => {
|
||||
localStorage.setItem('ignoreVersion', versionData.latestVersion)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
this.checkVersion()
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function ({ store, redirect, route, app }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.fullPath}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'config-log' && 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
'@/assets/app.css'
|
||||
@@ -47,6 +51,7 @@ module.exports = {
|
||||
|
||||
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||
plugins: [
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/toast.js'
|
||||
@@ -70,7 +75,8 @@ module.exports = {
|
||||
|
||||
proxy: {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } }
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
|
||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||
},
|
||||
|
||||
io: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "0.9.6-beta",
|
||||
"version": "1.3.3",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,7 @@
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"author": "",
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="w-full h-full p-8">
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<h1 class="text-2xl">Account</h1>
|
||||
|
||||
<div class="my-4">
|
||||
<div class="flex -mx-2">
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="username" label="Username" />
|
||||
</div>
|
||||
<div class="w-1/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="usertype" label="Account Type" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-primary my-4" />
|
||||
|
||||
<p class="mb-4 text-lg">Change Password</p>
|
||||
<form @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" />
|
||||
<div class="flex items-center py-2">
|
||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="py-4 mt-8 flex">
|
||||
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>Logout</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
password: null,
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
changingPassword: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user || null
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
usertype() {
|
||||
return this.user.type
|
||||
},
|
||||
isRoot() {
|
||||
return this.usertype === 'root'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
this.$router.push('/login')
|
||||
},
|
||||
resetForm() {
|
||||
this.password = null
|
||||
this.newPassword = null
|
||||
this.confirmPassword = null
|
||||
},
|
||||
submitChangePassword() {
|
||||
if (this.newPassword !== this.confirmPassword) {
|
||||
return this.$toast.error('New password and confirm password do not match')
|
||||
}
|
||||
if (this.password === this.newPassword) {
|
||||
return this.$toast.error('Password and New Password cannot be the same')
|
||||
}
|
||||
this.changingPassword = true
|
||||
this.$axios
|
||||
.$patch('/api/user/password', {
|
||||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success('Password Changed Successfully')
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || 'Unknown Error')
|
||||
}
|
||||
this.changingPassword = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error('Api call failed')
|
||||
this.changingPassword = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -19,16 +19,15 @@
|
||||
<div class="font-mono w-20 text-center">Duration</div>
|
||||
<div class="font-mono text-center w-20">Status</div>
|
||||
<div class="font-mono w-56">Notes</div>
|
||||
<div class="font-book w-40">Include in Tracklist</div>
|
||||
</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">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(audio, index) in files" :key="audio.path" class="w-full list-group-item item flex items-center">
|
||||
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
||||
<div class="font-book text-center px-4 py-1 w-12">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="font-book text-center px-4 w-12">
|
||||
{{ audio.index }}
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
|
||||
<div class="font-book text-center px-2 w-32">
|
||||
{{ audio.trackNumFromFilename }}
|
||||
</div>
|
||||
@@ -51,6 +50,9 @@
|
||||
<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">
|
||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
@@ -66,9 +68,12 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
@@ -77,10 +82,9 @@ export default {
|
||||
console.error('No audiobook...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
let index = 0
|
||||
return {
|
||||
audiobook,
|
||||
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : []
|
||||
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -98,6 +102,13 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
numExcluded() {
|
||||
var count = 0
|
||||
this.files.forEach((file) => {
|
||||
if (!file.include) count++
|
||||
})
|
||||
return count
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -164,15 +175,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
includeToggled(audio) {
|
||||
var new_index = 0
|
||||
if (audio.include) {
|
||||
new_index = this.numExcluded
|
||||
}
|
||||
var old_index = this.files.findIndex((f) => f.ino === audio.ino)
|
||||
if (new_index >= this.files.length) {
|
||||
var k = new_index - this.files.length + 1
|
||||
while (k--) {
|
||||
this.files.push(undefined)
|
||||
}
|
||||
}
|
||||
this.files.splice(new_index, 0, this.files.splice(old_index, 1)[0])
|
||||
},
|
||||
saveTracklist() {
|
||||
console.log('Tracklist', this.files)
|
||||
var orderedFileData = this.files.map((file) => {
|
||||
return {
|
||||
index: file.index,
|
||||
filename: file.filename,
|
||||
ino: file.ino,
|
||||
exclude: !file.include
|
||||
}
|
||||
})
|
||||
|
||||
this.saving = true
|
||||
this.$axios
|
||||
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files })
|
||||
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData })
|
||||
.then((data) => {
|
||||
console.log('Finished patching files', data)
|
||||
this.saving = false
|
||||
// this.$router.go()
|
||||
this.$toast.success('Tracks Updated')
|
||||
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||
})
|
||||
@@ -207,16 +239,26 @@ export default {
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
.list-group-item {
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
.list-group-item:not(.ghost):hover {
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost) {
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):hover {
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,72 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full h-full overflow-y-auto p-8">
|
||||
<div class="flex max-w-6xl mx-auto">
|
||||
<div class="w-52" style="min-width: 208px">
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" :width="208" />
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div>
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-10">
|
||||
<div class="flex">
|
||||
<h1 class="text-2xl">{{ title }}</h1>
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||
<div class="w-min">
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm my-1">
|
||||
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
|
||||
</p>
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
|
||||
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
Play
|
||||
</ui-btn>
|
||||
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>
|
||||
|
||||
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||
Missing
|
||||
</ui-btn>
|
||||
|
||||
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||
Read
|
||||
</ui-btn> -->
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
|
||||
<ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<p class="text-sm text-gray-100">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
@@ -44,7 +79,9 @@
|
||||
<p class="text-sm mb-2">
|
||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
|
||||
@@ -54,6 +91,8 @@
|
||||
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,7 +100,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
@@ -78,10 +117,23 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resettingProgress: false
|
||||
isRead: false,
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
userIsRead: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.isRead = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -113,6 +165,9 @@ export default {
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
missingParts() {
|
||||
return this.audiobook.missingParts || []
|
||||
},
|
||||
@@ -128,6 +183,27 @@ export default {
|
||||
author() {
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF
|
||||
},
|
||||
authorTooltipText() {
|
||||
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
|
||||
return txt.join('<br>')
|
||||
},
|
||||
series() {
|
||||
return this.book.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
durationPretty() {
|
||||
return this.audiobook.durationPretty
|
||||
},
|
||||
@@ -154,11 +230,14 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
description() {
|
||||
return this.book.description || 'No Description'
|
||||
return this.book.description || ''
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.userAudiobooks[this.audiobookId] || null
|
||||
@@ -166,6 +245,9 @@ export default {
|
||||
userCurrentTime() {
|
||||
return this.userAudiobook ? this.userAudiobook.currentTime : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userTimeRemaining() {
|
||||
return this.duration - this.userCurrentTime
|
||||
},
|
||||
@@ -175,11 +257,61 @@ export default {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isStreaming() {
|
||||
streaming() {
|
||||
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
var ebook = this.ebooks[0]
|
||||
console.log('Ebook', ebook)
|
||||
this.$axios
|
||||
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
|
||||
.then(() => {
|
||||
console.log('opened')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed', error)
|
||||
})
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
|
||||
})
|
||||
},
|
||||
openRssFeed() {
|
||||
this.$axios
|
||||
.$post('/api/feed', { audiobookId: this.audiobook.id })
|
||||
.then((res) => {
|
||||
console.log('Feed open', res)
|
||||
this.$toast.success('RSS Feed Open')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to open feed')
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.audiobook)
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
@@ -223,6 +355,9 @@ export default {
|
||||
this.resettingProgress = false
|
||||
})
|
||||
}
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<template v-for="audiobook in audiobookCopies">
|
||||
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
|
||||
<div class="w-32">
|
||||
<cards-book-cover :audiobook="audiobook.originalAudiobook" :width="120" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4">
|
||||
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
|
||||
|
||||
<ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="audiobook.book.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 v-model="audiobook.book.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click="saveClick">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.state.selectedAudiobooks.length) {
|
||||
return redirect('/')
|
||||
}
|
||||
var audiobooks = store.state.audiobooks.audiobooks.filter((ab) => store.state.selectedAudiobooks.includes(ab.id))
|
||||
return {
|
||||
audiobooks
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
seriesItems() {
|
||||
return [...this.series, ...this.newSeriesItems]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
newSeriesItem(item) {
|
||||
if (!item) return
|
||||
this.newSeriesItems.push(item)
|
||||
},
|
||||
seriesChanged() {
|
||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||
return this.audiobookCopies.find((ab) => ab.book.series === item)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.audiobookCopies = this.audiobooks.map((ab) => {
|
||||
var copy = { ...ab }
|
||||
copy.tags = [...ab.tags]
|
||||
copy.book = { ...ab.book }
|
||||
copy.book.genres = [...ab.book.genres]
|
||||
copy.originalAudiobook = ab
|
||||
return copy
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {
|
||||
this.isScrollable = true
|
||||
}
|
||||
})
|
||||
},
|
||||
saveClick() {
|
||||
this.isProcessing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/audiobooks/update', this.audiobookCopies)
|
||||
.then((data) => {
|
||||
this.isProcessing = false
|
||||
if (data.updates) {
|
||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||
this.$router.replace('/library')
|
||||
} else {
|
||||
this.$toast.warning('No updates were necessary')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to batch update', error)
|
||||
this.$toast.error('Failed to batch update')
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
<template>
|
||||
<div class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl mb-2">Config</h1>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="p-4 text-center h-20">
|
||||
<p>Nothing much here yet...</p>
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-2xl">Users</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
|
||||
</div>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="flex items-center py-4 mb-8">
|
||||
<div class="p-4 text-center">
|
||||
<table id="accounts" class="mb-8">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<td>
|
||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
</td>
|
||||
<td>{{ user.type }}</td>
|
||||
<td class="text-sm font-mono">
|
||||
{{ new Date(user.createdAt).toISOString() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="w-full flex justify-center">
|
||||
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
|
||||
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="py-4 mb-4">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="scan">Scan</ui-btn>
|
||||
<div class="flex items-start py-2">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
||||
|
||||
<div class="w-full mb-4">
|
||||
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
|
||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn to="/config/log">View Logger</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<p class="font-mono">v{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -26,24 +86,209 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isResettingAudiobooks: false,
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
parseSubtitleTooltip() {
|
||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isScanning() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateScannerParseSubtitle(val) {
|
||||
var payload = {
|
||||
scannerParseSubtitle: val
|
||||
}
|
||||
this.updateServerSettings(payload)
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
})
|
||||
},
|
||||
setDeveloperMode() {
|
||||
var value = !this.$store.state.developerMode
|
||||
this.$store.commit('setDeveloperMode', value)
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
saveMetadataFiles() {
|
||||
this.$root.socket.emit('save_metadata')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
},
|
||||
resetAudiobooks() {
|
||||
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
|
||||
this.isResettingAudiobooks = true
|
||||
this.$axios
|
||||
.$delete('/api/audiobooks')
|
||||
.then(() => {
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.success('Successfully reset audiobooks')
|
||||
location.reload()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to reset audiobooks', error)
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
clickAddUser() {
|
||||
this.selectedAccount = null
|
||||
this.showAccountModal = true
|
||||
},
|
||||
editUser(user) {
|
||||
this.selectedAccount = user
|
||||
this.showAccountModal = true
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/${user.id}`)
|
||||
.then((data) => {
|
||||
this.isDeletingUser = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('User deleted')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error('Failed to delete user')
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addUpdateUser(user) {
|
||||
if (!this.users) return
|
||||
var index = this.users.findIndex((u) => u.id === user.id)
|
||||
if (index >= 0) {
|
||||
this.users.splice(index, 1, user)
|
||||
} else {
|
||||
this.users.push(user)
|
||||
}
|
||||
},
|
||||
userRemoved(user) {
|
||||
this.users = this.users.filter((u) => u.id !== user.id)
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.on('user_removed', this.userRemoved)
|
||||
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#accounts th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-2xl">Logger</p>
|
||||
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
|
||||
<p class="text-xl text-gray-200 mb-2">No Logs</p>
|
||||
<p class="text-base text-gray-400">Log listening starts when you login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newServerSettings: {},
|
||||
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
|
||||
logLevels: [
|
||||
{
|
||||
value: 1,
|
||||
text: 'Debug'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: 'Info'
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
text: 'Warn'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
}
|
||||
},
|
||||
logs() {
|
||||
this.updateScroll()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
logLevelItems() {
|
||||
if (process.env.NODE_ENV === 'production') return this.logLevels
|
||||
this.logLevels.unshift({ text: 'Trace', value: 0 })
|
||||
return this.logLevels
|
||||
},
|
||||
logs() {
|
||||
return this.$store.state.logs.logs.filter((log) => {
|
||||
return log.level >= this.newServerSettings.logLevel
|
||||
})
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateScroll() {
|
||||
if (this.$refs.container) {
|
||||
this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight
|
||||
}
|
||||
},
|
||||
logLevelUpdated(val) {
|
||||
var payload = {
|
||||
logLevel: Number(val)
|
||||
}
|
||||
this.updateServerSettings(payload)
|
||||
|
||||
this.$store.dispatch('logs/setLogListener', this.newServerSettings.logLevel)
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
})
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logmessage {
|
||||
width: calc(100% - 208px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,28 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar />
|
||||
<app-book-shelf />
|
||||
<!-- <app-book-shelf-toolbar /> -->
|
||||
<!-- <div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow"> -->
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// asyncData({ redirect }) {
|
||||
// redirect('/library')
|
||||
// },
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, query, store, app }) {
|
||||
if (query.filter) {
|
||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||
}
|
||||
var searchResults = []
|
||||
var searchQuery = null
|
||||
if (params.id === 'search' && query.query) {
|
||||
searchQuery = query.query
|
||||
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
store.commit('audiobooks/setSearchResults', searchResults)
|
||||
}
|
||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
var libraryPage = params.id || ''
|
||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||
|
||||
return {
|
||||
id: libraryPage,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSeries
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
'$route.query'(newVal) {
|
||||
if (this.id === 'search' && this.$route.query.query) {
|
||||
if (this.$route.query.query !== this.searchQuery) {
|
||||
this.newQuery()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async newQuery() {
|
||||
var query = this.$route.query.query
|
||||
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.searchQuery = query
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -27,51 +27,45 @@ export default {
|
||||
return {
|
||||
error: null,
|
||||
processing: false,
|
||||
username: 'root',
|
||||
username: '',
|
||||
password: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
user(newVal) {
|
||||
if (newVal) {
|
||||
// if (process.env.NODE_ENV !== 'production') {
|
||||
if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace('/')
|
||||
}
|
||||
|
||||
// } else {
|
||||
// window.location.reload()
|
||||
// }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
// var uri = `${process.env.serverUrl}/auth`
|
||||
|
||||
var payload = {
|
||||
username: this.username,
|
||||
password: this.password || ''
|
||||
}
|
||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
console.error('Failed', error.response)
|
||||
if (error.response) this.error = error.response.data
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
console.log('Auth res', authRes)
|
||||
if (!authRes) {
|
||||
this.error = 'Unknown Failure'
|
||||
} else if (authRes.error) {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
this.$store.commit('setUser', authRes.user)
|
||||
} else if (authRes) {
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -82,7 +76,6 @@ export default {
|
||||
if (token) {
|
||||
this.processing = true
|
||||
|
||||
console.log('Authorize', token)
|
||||
this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
@@ -90,7 +83,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.$store.commit('setUser', res.user)
|
||||
this.$store.commit('user/setUser', res.user)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<main class="container mx-auto h-full max-w-screen-lg p-6">
|
||||
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="title" label="Title" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
|
||||
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
|
||||
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
|
||||
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
|
||||
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
||||
</header>
|
||||
</section>
|
||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
|
||||
|
||||
<div v-if="validImageFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
|
||||
<div class="flex">
|
||||
<template v-for="file in validImageFiles">
|
||||
<div :key="file.name" class="h-28 w-20 bg-bg">
|
||||
<img :src="file.src" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validAudioFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
|
||||
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in validAudioFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in invalidFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
|
||||
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
|
||||
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
|
||||
</footer>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator text="Uploading..." />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
validImageFiles: [],
|
||||
invalidFiles: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
hasValidAudioFiles() {
|
||||
return this.validAudioFiles.length
|
||||
},
|
||||
directory() {
|
||||
if (!this.author || !this.title) return ''
|
||||
if (this.series) {
|
||||
return Path.join('/audiobooks', this.author, this.series, this.title)
|
||||
} else {
|
||||
return Path.join('/audiobooks', this.author, this.title)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.title = ''
|
||||
this.author = ''
|
||||
this.series = ''
|
||||
this.cancel()
|
||||
},
|
||||
cancel() {
|
||||
this.validAudioFiles = []
|
||||
this.validImageFiles = []
|
||||
this.invalidFiles = []
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
}
|
||||
this.showUploader = true
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
this.filesChanged(_files)
|
||||
}
|
||||
},
|
||||
drop(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
const files = [...evt.dataTransfer.files]
|
||||
this.filesChanged(files)
|
||||
},
|
||||
dragover(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragleave(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragenter(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
preventDefaults(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
},
|
||||
filesChanged(files) {
|
||||
this.showUploader = false
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
var ext = Path.extname(file.name)
|
||||
|
||||
if (this.acceptedAudioFormats.includes(ext)) {
|
||||
this.validAudioFiles.push(file)
|
||||
} else if (file.type.startsWith('image/')) {
|
||||
file.src = URL.createObjectURL(file)
|
||||
this.validImageFiles.push(file)
|
||||
} else {
|
||||
this.invalidFiles.push(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
clickSelectAudioFiles() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.click()
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
if (!this.title || !this.author) {
|
||||
this.$toast.error('Must enter a title and author')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
var form = new FormData()
|
||||
form.set('title', this.title)
|
||||
form.set('author', this.author)
|
||||
form.set('series', this.series)
|
||||
|
||||
var index = 0
|
||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||
files.forEach((file) => {
|
||||
form.set(`${index++}`, file)
|
||||
})
|
||||
|
||||
this.$axios
|
||||
.$post('/upload', form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,22 @@
|
||||
export default function ({ $axios, store }) {
|
||||
$axios.onRequest(config => {
|
||||
console.log('Making request to ' + config.url)
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
var bearerToken = store.state.user ? store.state.user.token : null
|
||||
// console.log('Bearer token', bearerToken)
|
||||
var bearerToken = store.state.user.user ? store.state.user.user.token : null
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
config.url = `/dev${config.url}`
|
||||
console.log('Making request to ' + config.url)
|
||||
}
|
||||
})
|
||||
|
||||
$axios.onError(error => {
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
console.error('Axios error code', code)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
console.error('Axios error', code, message)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const DownloadStatus = {
|
||||
PENDING: 0,
|
||||
READY: 1,
|
||||
EXPIRED: 2,
|
||||
FAILED: 3
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
DownloadStatus
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
inject('constants', Constants)
|
||||
}
|
||||
@@ -38,29 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function loadImageBlob(uri) {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
const c = document.createElement('canvas')
|
||||
const ctx = c.getContext('2d')
|
||||
img.onload = ({ target }) => {
|
||||
c.width = target.naturalWidth
|
||||
c.height = target.naturalHeight
|
||||
ctx.drawImage(target, 0, 0)
|
||||
c.toBlob((b) => resolve(b), 'image/jpeg', 0.75)
|
||||
}
|
||||
img.crossOrigin = ''
|
||||
img.src = uri
|
||||
})
|
||||
}
|
||||
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||
const el = document.createElement('p')
|
||||
|
||||
Vue.prototype.$downloadImage = async (uri, name) => {
|
||||
var blob = await loadImageBlob(uri)
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.target = '_blank'
|
||||
a.download = name || 'fotosho-image'
|
||||
a.click()
|
||||
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
|
||||
for (const key in styles) {
|
||||
if (styles[key] && String(styles[key]).length > 0) {
|
||||
attr += `${key}:${styles[key]};`
|
||||
}
|
||||
}
|
||||
|
||||
el.setAttribute('style', attr)
|
||||
el.innerText = text
|
||||
|
||||
document.body.appendChild(el)
|
||||
const boundingBox = el.getBoundingClientRect()
|
||||
el.remove()
|
||||
return {
|
||||
height: boundingBox.height,
|
||||
width: boundingBox.width
|
||||
}
|
||||
}
|
||||
|
||||
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||
@@ -118,3 +115,16 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
.replace(windowsTrailingRe, replacement);
|
||||
return sanitized
|
||||
}
|
||||
|
||||
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
||||
Vue.prototype.$encode = encode
|
||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
Vue.prototype.$decode = decode
|
||||
|
||||
export {
|
||||
encode,
|
||||
decode
|
||||
}
|
||||
export default ({ app }, inject) => {
|
||||
app.$decode = decode
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import Vue from "vue";
|
||||
import Toast from "vue-toastification";
|
||||
// Import the CSS or use your own!
|
||||
import "vue-toastification/dist/index.css";
|
||||
import Vue from "vue"
|
||||
import Toast from "vue-toastification"
|
||||
import "vue-toastification/dist/index.css"
|
||||
|
||||
const options = {
|
||||
hideProgressBar: true
|
||||
};
|
||||
hideProgressBar: true,
|
||||
draggable: false
|
||||
}
|
||||
|
||||
|
||||
Vue.use(Toast, options);
|
||||
Vue.use(Toast, options)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import packagejson from '../package.json'
|
||||
import axios from 'axios'
|
||||
|
||||
function parseSemver(ver) {
|
||||
if (!ver) return null
|
||||
var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/)
|
||||
if (groups && groups.length > 6) {
|
||||
var total = Number(groups[3]) * 10000 + Number(groups[4]) * 100 + Number(groups[5])
|
||||
if (isNaN(total)) {
|
||||
console.warn('Invalid version total', groups[3], groups[4], groups[5])
|
||||
return null
|
||||
}
|
||||
return {
|
||||
total,
|
||||
version: groups[2],
|
||||
major: Number(groups[3]),
|
||||
minor: Number(groups[4]),
|
||||
patch: Number(groups[5]),
|
||||
preRelease: groups[6] || null
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid semver string', ver)
|
||||
}
|
||||
return null
|
||||
}
|
||||
export async function checkForUpdate() {
|
||||
if (!packagejson.version) {
|
||||
return
|
||||
}
|
||||
var currVerObj = parseSemver('v' + packagejson.version)
|
||||
if (!currVerObj) {
|
||||
console.error('Invalid version', packagejson.version)
|
||||
return
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
|
||||
var tags = res.data
|
||||
if (tags && tags.length) {
|
||||
tags.forEach((tag) => {
|
||||
var verObj = parseSemver(tag.name)
|
||||
if (verObj) {
|
||||
if (!largestVer || largestVer.total < verObj.total) {
|
||||
largestVer = verObj
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if (!largestVer) {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
}
|
||||
return {
|
||||
hasUpdate: largestVer.total > currVerObj.total,
|
||||
latestVersion: largestVer.version,
|
||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||
currentVersion: currVerObj.version
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,62 +1,195 @@
|
||||
import { sort } from '@/assets/fastSort'
|
||||
import { decode } from '@/plugins/init.client'
|
||||
|
||||
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
|
||||
|
||||
export const state = () => ({
|
||||
audiobooks: [],
|
||||
lastLoad: 0,
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: []
|
||||
tags: [],
|
||||
series: [],
|
||||
keywordFilter: null,
|
||||
selectedSeries: null,
|
||||
libraryPage: null,
|
||||
searchResults: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getFiltered: (state, getters, rootState) => () => {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
|
||||
if (!state.libraryPage) {
|
||||
return getters.getFiltered()
|
||||
} else if (state.libraryPage === 'search') {
|
||||
return state.searchResults
|
||||
} else if (state.libraryPage === 'series') {
|
||||
var series = getters.getSeriesGroups()
|
||||
if (state.selectedSeries) {
|
||||
var _series = series.find(__series => __series.name === state.selectedSeries)
|
||||
if (!_series) return []
|
||||
return _series.books || []
|
||||
}
|
||||
return series
|
||||
}
|
||||
return []
|
||||
},
|
||||
getFiltered: (state, getters, rootState, rootGetters) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.settings.settings || {}
|
||||
var settings = rootState.user.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
var filterByParts = filterBy.split('.')
|
||||
if (filterByParts.length > 1) {
|
||||
var primary = filterByParts[0]
|
||||
var secondary = filterByParts[1]
|
||||
if (primary === 'genres') {
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filter = decode(filterBy.replace(`${group}.`, ''))
|
||||
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||
else if (group === 'progress') {
|
||||
filtered = filtered.filter(ab => {
|
||||
return ab.book && ab.book.genres.includes(secondary)
|
||||
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
|
||||
var isRead = userAudiobook && userAudiobook.isRead
|
||||
if (filter === 'Read' && isRead) return true
|
||||
if (filter === 'Unread' && !isRead) return true
|
||||
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
|
||||
return false
|
||||
})
|
||||
} else if (primary === 'tags') {
|
||||
filtered = filtered.filter(ab => ab.tags.includes(secondary))
|
||||
}
|
||||
}
|
||||
// TODO: Add filters
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
|
||||
const keyworkFilter = state.keywordFilter.toLowerCase()
|
||||
return filtered.filter(ab => {
|
||||
if (!ab.book) return false
|
||||
return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter)))
|
||||
})
|
||||
}
|
||||
return filtered
|
||||
},
|
||||
getFilteredAndSorted: (state, getters, rootState) => () => {
|
||||
var settings = rootState.settings.settings
|
||||
var settings = rootState.user.settings
|
||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||
|
||||
var filtered = getters.getFiltered()
|
||||
var orderByNumber = settings.orderBy === 'book.volumeNumber'
|
||||
return sort(filtered)[direction]((ab) => {
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
if (orderByNumber && !isNaN(value)) return Number(value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
getSeriesGroups: (state, getters, rootState) => () => {
|
||||
var series = {}
|
||||
state.audiobooks.forEach((audiobook) => {
|
||||
if (audiobook.book && audiobook.book.series) {
|
||||
if (series[audiobook.book.series]) {
|
||||
var bookLastUpdate = audiobook.book.lastUpdate
|
||||
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
|
||||
series[audiobook.book.series].books.push(audiobook)
|
||||
} else {
|
||||
series[audiobook.book.series] = {
|
||||
type: 'series',
|
||||
name: audiobook.book.series || '',
|
||||
books: [audiobook],
|
||||
lastUpdate: audiobook.book.lastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
var seriesArray = Object.values(series).map((_series) => {
|
||||
_series.books = sort(_series.books)['asc']((ab) => {
|
||||
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
|
||||
})
|
||||
return _series
|
||||
})
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilter = state.keywordFilter.toLowerCase()
|
||||
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
|
||||
}
|
||||
return seriesArray
|
||||
},
|
||||
getUniqueAuthors: (state) => {
|
||||
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
||||
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
},
|
||||
getGenresUsed: (state) => {
|
||||
var _genres = []
|
||||
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
||||
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
},
|
||||
getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
|
||||
if (!book || !book.cover || book.cover === placeholder) return placeholder
|
||||
var cover = book.cover
|
||||
|
||||
// Absolute URL covers
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
|
||||
|
||||
// Server hosted covers
|
||||
try {
|
||||
// Ensure cover is refreshed if cached
|
||||
var bookLastUpdate = book.lastUpdate || Date.now()
|
||||
var userToken = rootGetters['user/getToken']
|
||||
|
||||
var url = new URL(cover, document.baseURI)
|
||||
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
load({ commit }) {
|
||||
// Return true if calling load
|
||||
load({ state, commit, rootState }) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('audiobooks/load - User not set')
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't load again if already loaded in the last 5 minutes
|
||||
var lastLoadDiff = Date.now() - state.lastLoad
|
||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||
// Already up to date
|
||||
return false
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/audiobooks`)
|
||||
.then((data) => {
|
||||
commit('set', data)
|
||||
commit('setLastLoad')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
commit('set', [])
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setLastLoad(state) {
|
||||
state.lastLoad = Date.now()
|
||||
},
|
||||
setKeywordFilter(state, val) {
|
||||
state.keywordFilter = val
|
||||
},
|
||||
setSelectedSeries(state, val) {
|
||||
state.selectedSeries = val
|
||||
},
|
||||
setLibraryPage(state, val) {
|
||||
state.libraryPage = val
|
||||
},
|
||||
setSearchResults(state, val) {
|
||||
state.searchResults = val
|
||||
},
|
||||
set(state, audiobooks) {
|
||||
// GENRES
|
||||
var genres = [...state.genres]
|
||||
@@ -65,6 +198,7 @@ export const mutations = {
|
||||
genres = genres.concat(ab.book.genres)
|
||||
})
|
||||
state.genres = [...new Set(genres)] // Remove Duplicates
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// TAGS
|
||||
var tags = []
|
||||
@@ -72,6 +206,16 @@ export const mutations = {
|
||||
tags = tags.concat(ab.tags)
|
||||
})
|
||||
state.tags = [...new Set(tags)] // Remove Duplicates
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// SERIES
|
||||
var series = []
|
||||
audiobooks.forEach((ab) => {
|
||||
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
|
||||
series.push(ab.book.series)
|
||||
})
|
||||
state.series = series
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
state.audiobooks = audiobooks
|
||||
state.listeners.forEach((listener) => {
|
||||
@@ -80,19 +224,34 @@ export const mutations = {
|
||||
},
|
||||
addUpdate(state, audiobook) {
|
||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||
var origAudiobook = null
|
||||
if (index >= 0) {
|
||||
origAudiobook = { ...state.audiobooks[index] }
|
||||
state.audiobooks.splice(index, 1, audiobook)
|
||||
} else {
|
||||
state.audiobooks.push(audiobook)
|
||||
}
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
var newGenres = []
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!state.genres.includes(genre)) newGenres.push(genre)
|
||||
})
|
||||
if (newGenres.length) state.genres = state.genres.concat(newGenres)
|
||||
if (newGenres.length) {
|
||||
state.genres = state.genres.concat(newGenres)
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
|
||||
state.series.push(audiobook.book.series)
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
|
||||
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
|
||||
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -100,8 +259,10 @@ export const mutations = {
|
||||
audiobook.tags.forEach((tag) => {
|
||||
if (!state.tags.includes(tag)) newTags.push(tag)
|
||||
})
|
||||
if (newTags.length) state.tags = state.tags.concat(newTags)
|
||||
|
||||
if (newTags.length) {
|
||||
state.tags = state.tags.concat(newTags)
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
@@ -112,8 +273,8 @@ export const mutations = {
|
||||
remove(state, audiobook) {
|
||||
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!STANDARD_GENRES.includes(genre)) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => {
|
||||
@@ -125,6 +286,15 @@ export const mutations = {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
|
||||
if (!isInOtherAB) {
|
||||
// Series not used in any other audiobook - remove it
|
||||
state.series = state.series.filter(s => s !== audiobook.book.series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -138,7 +308,6 @@ export const mutations = {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
listener.meth()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
export const state = () => ({
|
||||
downloads: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getDownloads: (state) => (audiobookId) => {
|
||||
return state.downloads.filter(d => d.audiobookId === audiobookId)
|
||||
},
|
||||
getDownload: (state) => (id) => {
|
||||
return state.downloads.find(d => d.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
addUpdateDownload(state, download) {
|
||||
// Remove older downloads of matching type
|
||||
state.downloads = state.downloads.filter(d => {
|
||||
if (d.id !== download.id && d.type === download.type) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
var index = state.downloads.findIndex(d => d.id === download.id)
|
||||
if (index >= 0) {
|
||||
state.downloads.splice(index, 1, download)
|
||||
} else {
|
||||
state.downloads.push(download)
|
||||
}
|
||||
},
|
||||
removeDownload(state, download) {
|
||||
state.downloads = state.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,83 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
versionData: null,
|
||||
serverSettings: null,
|
||||
streamAudiobook: null,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
selectedAudiobook: null,
|
||||
playOnLoad: false,
|
||||
isScanning: false,
|
||||
scanProgress: null
|
||||
isScanningCovers: false,
|
||||
scanProgress: null,
|
||||
coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
getIsAudiobookSelected: state => audiobookId => {
|
||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
}
|
||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
updateServerSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setServerSettings', result.settings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
commit('setVersionData', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user.token) {
|
||||
localStorage.setItem('token', user.token)
|
||||
}
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
setVersionData(state, versionData) {
|
||||
state.versionData = versionData
|
||||
},
|
||||
setServerSettings(state, settings) {
|
||||
state.serverSettings = settings
|
||||
},
|
||||
setStreamAudiobook(state, audiobook) {
|
||||
state.playOnLoad = true
|
||||
@@ -47,17 +97,54 @@ export const mutations = {
|
||||
state.playOnLoad = val
|
||||
},
|
||||
showEditModal(state, audiobook) {
|
||||
state.editModalTab = 'details'
|
||||
state.selectedAudiobook = audiobook
|
||||
state.showEditModal = true
|
||||
},
|
||||
showEditModalOnTab(state, { audiobook, tab }) {
|
||||
state.editModalTab = tab
|
||||
state.selectedAudiobook = audiobook
|
||||
state.showEditModal = true
|
||||
},
|
||||
setEditModalTab(state, tab) {
|
||||
state.editModalTab = tab
|
||||
},
|
||||
setShowEditModal(state, val) {
|
||||
state.showEditModal = val
|
||||
},
|
||||
setIsScanning(state, isScanning) {
|
||||
state.isScanning = isScanning
|
||||
},
|
||||
setScanProgress(state, progress) {
|
||||
if (progress > 0) state.isScanning = true
|
||||
state.scanProgress = progress
|
||||
setScanProgress(state, scanProgress) {
|
||||
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
||||
state.scanProgress = scanProgress
|
||||
},
|
||||
setIsScanningCovers(state, isScanningCovers) {
|
||||
state.isScanningCovers = isScanningCovers
|
||||
},
|
||||
setCoverScanProgress(state, coverScanProgress) {
|
||||
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
|
||||
state.coverScanProgress = coverScanProgress
|
||||
},
|
||||
setDeveloperMode(state, val) {
|
||||
state.developerMode = val
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||
// state.selectedAudiobooks = audiobooks
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
// state.selectedAudiobooks = newSel
|
||||
console.log('Setting toggle on sel', newSel)
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
// state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export const state = () => ({
|
||||
isListening: false,
|
||||
logs: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
setLogListener({ state, commit, dispatch }) {
|
||||
dispatch('$nuxtSocket/emit', {
|
||||
label: 'main',
|
||||
evt: 'set_log_listener',
|
||||
msg: 0
|
||||
}, { root: true })
|
||||
commit('setIsListening', true)
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setIsListening(state, val) {
|
||||
state.isListening = val
|
||||
},
|
||||
logEvt(state, payload) {
|
||||
state.logs.push(payload)
|
||||
if (state.logs.length > 500) {
|
||||
state.logs = state.logs.slice(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
export const state = () => ({
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all'
|
||||
},
|
||||
|
||||
listeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setSettings(state, settings) {
|
||||
state.settings = {
|
||||
...settings
|
||||
}
|
||||
state.listeners.forEach((listener) => {
|
||||
listener.meth()
|
||||
})
|
||||
},
|
||||
addListener(state, listener) {
|
||||
var index = state.listeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.listeners.splice(index, 1, listener)
|
||||
else state.listeners.push(listener)
|
||||
},
|
||||
removeListener(state, listenerId) {
|
||||
state.listeners = state.listeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
bookshelfCoverSize: 120
|
||||
},
|
||||
settingsListeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
return state.settings ? state.settings[key] || null : null
|
||||
},
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
},
|
||||
getUserCanUpdate: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.update : false
|
||||
},
|
||||
getUserCanDelete: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.delete : false
|
||||
},
|
||||
getUserCanDownload: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
||||
},
|
||||
getUserCanUpload: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
updateUserSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
// Immediately update
|
||||
commit('setSettings', updatePayload)
|
||||
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setSettings', result.settings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update settings', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
setSettings(state, settings) {
|
||||
if (!settings) return
|
||||
|
||||
var hasChanges = false
|
||||
for (const key in settings) {
|
||||
if (state.settings[key] !== settings[key]) {
|
||||
hasChanges = true
|
||||
state.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
state.settingsListeners.forEach((listener) => {
|
||||
listener.meth(state.settings)
|
||||
})
|
||||
}
|
||||
},
|
||||
addSettingsListener(state, listener) {
|
||||
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
|
||||
else state.settingsListeners.push(listener)
|
||||
},
|
||||
removeSettingsListener(state, listenerId) {
|
||||
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ module.exports = {
|
||||
purge: {
|
||||
options: {
|
||||
safelist: [
|
||||
'bg-success'
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'py-1.5'
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -14,15 +16,21 @@ module.exports = {
|
||||
height: {
|
||||
'7.5': '1.75rem'
|
||||
},
|
||||
spacing: {
|
||||
'-54': '-13.5rem'
|
||||
},
|
||||
rotate: {
|
||||
'-60': '-60deg'
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#262626',
|
||||
primary: '#232323',
|
||||
accent: '#1ad691',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
successDark: '#3b8a3e',
|
||||
warning: '#FB8C00',
|
||||
darkgreen: 'rgb(34,127,35)',
|
||||
'black-50': '#bbbbbb',
|
||||
'black-100': '#666666',
|
||||
'black-200': '#555555',
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<MyIP/>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://hub.docker.com/r/advplyr/audiobookshelf/</Support>
|
||||
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
||||
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
||||
<Overview>Audiobook manager and player</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books Status:Beta<</Category>
|
||||
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books</Category>
|
||||
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
||||
<TemplateURL/>
|
||||
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
||||
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
@@ -20,7 +20,7 @@
|
||||
<DateInstalled>1629238508</DateInstalled>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Description>Audiobook manager and player</Description>
|
||||
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
|
||||
<Networking>
|
||||
<Mode>bridge</Mode>
|
||||
<Publish>
|
||||
|
||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -11,6 +11,7 @@ if (isDev) {
|
||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "0.9.61-beta",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"version": "1.3.3",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"start": "node index.js",
|
||||
"release": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks",
|
||||
"release-dry": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks --dry-run"
|
||||
"client": "cd client && npm install && npm run generate",
|
||||
"prod": "npm run client && npm install && node prod.js",
|
||||
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-linux": "build/linuxpackager"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
"pkg": {
|
||||
"assets": "client/dist/**/*",
|
||||
"scripts": [
|
||||
"prod.js",
|
||||
"server/**/*.js"
|
||||
]
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
"axios": "^0.21.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chokidar": "^3.5.2",
|
||||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"image-type": "^4.1.0",
|
||||
"ip": "^1.1.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-dir": "^0.1.17",
|
||||
"socket.io": "^4.1.3"
|
||||
"podcast": "^1.3.0",
|
||||
"read-chunk": "^3.1.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"watcher": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^4.0.0",
|
||||
"release-it": "^14.11.5"
|
||||
}
|
||||
}
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
const optionDefinitions = [
|
||||
{ name: 'config', alias: 'c', type: String },
|
||||
{ name: 'audiobooks', alias: 'a', type: String },
|
||||
{ name: 'metadata', alias: 'm', type: String },
|
||||
{ name: 'port', alias: 'p', type: String }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('command-line-args')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
|
||||
global.appRoot = __dirname
|
||||
|
||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
||||
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
|
||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||
|
||||
const PORT = options.port || process.env.PORT || 3333
|
||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||
|
||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
|
||||
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
Server.start()
|
||||