|
|
@ -1,40 +1,47 @@
|
||||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||||
{
|
{
|
||||||
"name": "Audiobookshelf",
|
"name": "Audiobookshelf",
|
||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||||
// Append -bullseye or -buster to pin to an OS version.
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
"args": {
|
"args": {
|
||||||
"VARIANT": "20"
|
"VARIANT": "20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": ["source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume", "type=bind,source=/Users/benjamindonaldson/Downloads,target=${containerWorkspaceFolder}/downloads"],
|
||||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
"features": {
|
||||||
],
|
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
"installZsh": true,
|
||||||
// "features": {},
|
"username": "vscode",
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
"upgradePackages": true
|
||||||
"forwardPorts": [
|
}
|
||||||
3000,
|
},
|
||||||
3333
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
],
|
"forwardPorts": [3000, 3333],
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
"customizations": {
|
"customizations": {
|
||||||
// Configure properties specific to VS Code.
|
// Configure properties specific to VS Code.
|
||||||
"vscode": {
|
"vscode": {
|
||||||
// Add the IDs of extensions you want installed when the container is created.
|
// Add the IDs of extensions you want installed when the container is created.
|
||||||
"extensions": [
|
"extensions": ["dbaeumer.vscode-eslint", "octref.vetur"]
|
||||||
"dbaeumer.vscode-eslint",
|
}
|
||||||
"octref.vetur"
|
},
|
||||||
]
|
// Add settings for Cursor
|
||||||
}
|
"settings": {
|
||||||
}
|
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
"terminal.integrated.profiles.linux": {
|
||||||
// "remoteUser": "root"
|
"zsh": {
|
||||||
|
"path": "/bin/zsh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||||
|
"remoteUser": "vscode",
|
||||||
|
"updateRemoteUserUID": true
|
||||||
}
|
}
|
||||||
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@
|
||||||
/media/
|
/media/
|
||||||
/metadata/
|
/metadata/
|
||||||
/plugins/
|
/plugins/
|
||||||
|
*.epub
|
||||||
/client/.nuxt/
|
/client/.nuxt/
|
||||||
/client/dist/
|
/client/dist/
|
||||||
/dist/
|
/dist/
|
||||||
|
|
|
||||||
114
DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Audiobookshelf Deployment Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Desktop (for local deployment)
|
||||||
|
- Portainer (optional, for container management)
|
||||||
|
- At least 1GB of free RAM
|
||||||
|
- Sufficient disk space for your audiobooks and metadata
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
audiobookshelf/
|
||||||
|
├── audiobooks/ # Your audiobooks directory
|
||||||
|
├── podcasts/ # Your podcasts directory
|
||||||
|
├── metadata/ # Application metadata
|
||||||
|
├── config/ # Configuration files
|
||||||
|
├── client/ # Frontend source code
|
||||||
|
├── server/ # Backend source code
|
||||||
|
├── Dockerfile # Docker build instructions
|
||||||
|
└── docker-compose.prod.yml
|
||||||
|
|
||||||
|
External Directories:
|
||||||
|
/libraries/ # Additional media libraries directory (customizable path)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### Using Docker Desktop
|
||||||
|
|
||||||
|
1. Create the required directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p audiobooks podcasts metadata config
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure your libraries path: Edit `docker-compose.prod.yml` and modify the libraries volume mount to point to your desired path:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/libraries:/libraries
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and start the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
|
||||||
|
# Start the containers
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application at `http://localhost:13378`
|
||||||
|
|
||||||
|
### Using Portainer
|
||||||
|
|
||||||
|
1. In Portainer, go to "Stacks" and click "Add Stack"
|
||||||
|
2. Give your stack a name (e.g., "audiobookshelf")
|
||||||
|
3. Copy the contents of `docker-compose.prod.yml` into the web editor
|
||||||
|
4. Modify the libraries volume mount path to match your system
|
||||||
|
5. Enable "Build Image" option in Portainer
|
||||||
|
6. Click "Deploy the stack"
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- The application runs on port 13378 by default
|
||||||
|
- Data is persisted in the mounted volumes:
|
||||||
|
- `./audiobooks`: Default audiobooks directory
|
||||||
|
- `./podcasts`: Default podcasts directory
|
||||||
|
- `./metadata`: Application metadata
|
||||||
|
- `./config`: Configuration files
|
||||||
|
- `/libraries`: Additional media libraries (customize path in docker-compose.prod.yml)
|
||||||
|
- Timezone can be configured in the docker-compose file
|
||||||
|
- User/Group IDs can be set via PUID/PGID environment variables
|
||||||
|
|
||||||
|
## Post-Installation
|
||||||
|
|
||||||
|
1. On first run, create an admin account
|
||||||
|
2. Configure your libraries in the web interface:
|
||||||
|
- Set up the default audiobooks and podcasts directories
|
||||||
|
- Add additional libraries from the `/libraries` mount point
|
||||||
|
3. Add your media files to the appropriate directories
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
When you make changes to your code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild the image with your changes
|
||||||
|
docker compose -f docker-compose.prod.yml build
|
||||||
|
|
||||||
|
# Restart the containers with the new image
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Important directories to backup:
|
||||||
|
|
||||||
|
- `config/` - Contains application settings and database
|
||||||
|
- `metadata/` - Contains metadata for your media
|
||||||
|
- `audiobooks/` and `podcasts/` - Your media files
|
||||||
|
- Any additional libraries you've configured
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Check container logs: `docker logs audiobookshelf`
|
||||||
|
- Ensure proper file permissions on mounted volumes
|
||||||
|
- Verify port 13378 is not in use by another application
|
||||||
|
- Check resource usage in Docker Desktop dashboard
|
||||||
|
- Build logs: `docker compose -f docker-compose.prod.yml build --progress=plain`
|
||||||
|
- Check that all volume mount paths exist and are accessible
|
||||||
|
- Verify file permissions on the libraries directory
|
||||||
|
|
@ -7,6 +7,92 @@
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(~static/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(~static/textures/wood_default.jpg);
|
||||||
--bookshelf-divider-bg: 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%);
|
--bookshelf-divider-bg: 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%);
|
||||||
|
|
||||||
|
/* Theme Colors */
|
||||||
|
--color-primary: #1e293b;
|
||||||
|
--color-primary-dark: #0f172a;
|
||||||
|
--color-success: #22c55e;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-bg: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Classes */
|
||||||
|
.bg-primary {
|
||||||
|
background-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary-dark {
|
||||||
|
background-color: var(--color-primary-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-success {
|
||||||
|
background-color: var(--color-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
background-color: var(--color-warning) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-error {
|
||||||
|
background-color: var(--color-error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-info {
|
||||||
|
background-color: var(--color-info) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-bg {
|
||||||
|
background-color: var(--color-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary-dark {
|
||||||
|
color: var(--color-primary-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--color-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--color-warning) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--color-error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-info {
|
||||||
|
color: var(--color-info) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-primary {
|
||||||
|
border-color: var(--color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-primary-dark {
|
||||||
|
border-color: var(--color-primary-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-success {
|
||||||
|
border-color: var(--color-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-warning {
|
||||||
|
border-color: var(--color-warning) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-error {
|
||||||
|
border-color: var(--color-error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-info {
|
||||||
|
border-color: var(--color-info) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
color utility to any element that depends on these defaults.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
|
|
@ -58,22 +59,23 @@
|
||||||
--spacing-80e: 20em;
|
--spacing-80e: 20em;
|
||||||
--spacing-96e: 24em;
|
--spacing-96e: 24em;
|
||||||
|
|
||||||
--color-bg: #373838;
|
--color-bg: #2F4F4F;
|
||||||
--color-primary: #232323;
|
--color-primary: #2E8B57;
|
||||||
|
--color-mid-purple: #3CB371;
|
||||||
--color-accent: #1ad691;
|
--color-accent: #1ad691;
|
||||||
--color-error: #ff5252;
|
--color-error: #ff5252;
|
||||||
--color-info: #2196f3;
|
--color-info: #2196f3;
|
||||||
--color-success: #4caf50;
|
--color-success: #4caf50;
|
||||||
--color-warning: #fb8c00;
|
--color-warning: #fb8c00;
|
||||||
--color-darkgreen: rgb(34, 127, 35);
|
--color-darkgreen: rgb(34, 127, 35);
|
||||||
--color-black-50: #bbbbbb;
|
--color-black-50: #F0FFF0;
|
||||||
--color-black-100: #666666;
|
--color-black-100: #98FB98;
|
||||||
--color-black-200: #555555;
|
--color-black-200: #90EE90;
|
||||||
--color-black-300: #444444;
|
--color-black-300: #3CB371;
|
||||||
--color-black-400: #333333;
|
--color-black-400: #2E8B57;
|
||||||
--color-black-500: #222222;
|
--color-black-500: #228B22;
|
||||||
--color-black-600: #111111;
|
--color-black-600: #006400;
|
||||||
--color-black-700: #101010;
|
--color-black-700: #004225;
|
||||||
|
|
||||||
--font-sans: 'Source Sans Pro';
|
--font-sans: 'Source Sans Pro';
|
||||||
--font-mono: 'Ubuntu Mono';
|
--font-mono: 'Ubuntu Mono';
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
<div id="appbar" role="toolbar" aria-label="Appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
<img src="~static/bookfire-logo-white.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl mr-6 hidden lg:block hover:underline">{{ serverTitle }}</h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown class="mr-2" />
|
<ui-libraries-dropdown class="mr-2" />
|
||||||
|
|
@ -42,14 +42,31 @@
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true">
|
<div class="relative">
|
||||||
<span class="items-center hidden md:flex">
|
<button @click="showProfileDropdown = !showProfileDropdown" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="true" :aria-expanded="showProfileDropdown">
|
||||||
<span class="block truncate">{{ username }}</span>
|
<span class="items-center hidden md:flex">
|
||||||
</span>
|
<span class="block truncate">{{ username }}</span>
|
||||||
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
</span>
|
||||||
<span class="material-symbols text-xl text-gray-100"></span>
|
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
||||||
</span>
|
<span class="material-symbols text-xl text-gray-100"></span>
|
||||||
</nuxt-link>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showProfileDropdown" class="absolute right-0 mt-1 w-48 bg-bg border border-gray-500 rounded-sm shadow-lg z-50 overflow-hidden">
|
||||||
|
<nuxt-link :to="'/user/' + user.id" class="block px-4 py-2.5 text-sm text-white hover:bg-primary/40 transition-colors duration-150">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="material-symbols text-lg mr-2"></span>
|
||||||
|
Profile
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<button @click="logout" class="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-primary/40 transition-colors duration-150">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="material-symbols text-lg mr-2"></span>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||||
|
|
@ -80,6 +97,9 @@
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Apply Tags Modal -->
|
||||||
|
<modals-batch-apply-tags-modal v-model="showApplyTagsModal" :selected-media-items="selectedMediaItems" @tagsApplied="tagsApplied" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -87,7 +107,9 @@
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
totalEntities: 0
|
totalEntities: 0,
|
||||||
|
showProfileDropdown: false,
|
||||||
|
showApplyTagsModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -116,7 +138,7 @@ export default {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.displayName || this.user.username : 'err'
|
||||||
},
|
},
|
||||||
numMediaItemsSelected() {
|
numMediaItemsSelected() {
|
||||||
return this.selectedMediaItems.length
|
return this.selectedMediaItems.length
|
||||||
|
|
@ -180,6 +202,14 @@ export default {
|
||||||
action: 'rescan'
|
action: 'rescan'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add Edit Tags option
|
||||||
|
if (this.userCanUpdate) {
|
||||||
|
options.push({
|
||||||
|
text: 'Edit Tags',
|
||||||
|
action: 'apply-tags'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
|
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
|
||||||
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
|
// + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains
|
||||||
if (this.selectedMediaItems.length <= 40) {
|
if (this.selectedMediaItems.length <= 40) {
|
||||||
|
|
@ -190,6 +220,9 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
|
},
|
||||||
|
serverTitle() {
|
||||||
|
return this.$store.state.serverSettings?.title || 'Bookfire'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -226,6 +259,8 @@ export default {
|
||||||
this.batchRescan()
|
this.batchRescan()
|
||||||
} else if (action === 'download') {
|
} else if (action === 'download') {
|
||||||
this.batchDownload()
|
this.batchDownload()
|
||||||
|
} else if (action === 'apply-tags') {
|
||||||
|
this.batchApplyTagsClick()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async batchRescan() {
|
async batchRescan() {
|
||||||
|
|
@ -375,13 +410,64 @@ export default {
|
||||||
},
|
},
|
||||||
batchAutoMatchClick() {
|
batchAutoMatchClick() {
|
||||||
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
|
||||||
|
},
|
||||||
|
batchApplyTagsClick() {
|
||||||
|
this.showApplyTagsModal = true
|
||||||
|
},
|
||||||
|
tagsApplied() {
|
||||||
|
// Clear selection and refresh the bookshelf
|
||||||
|
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||||
|
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||||
|
// Optionally refresh the current page to show updated tags
|
||||||
|
this.$eventBus.$emit('bookshelf_refresh')
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
this.showProfileDropdown = false
|
||||||
|
|
||||||
|
// Disconnect from socket if it exists and is connected
|
||||||
|
if (this.$root.socket && this.$root.socket.connected) {
|
||||||
|
try {
|
||||||
|
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||||
|
this.$root.socket.removeAllListeners()
|
||||||
|
this.$root.socket.disconnect()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disconnecting socket:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear user data
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
this.$store.commit('libraries/setUserPlaylists', [])
|
||||||
|
this.$store.commit('libraries/setCollections', [])
|
||||||
|
this.$store.commit('user/setUser', null)
|
||||||
|
|
||||||
|
// Logout request
|
||||||
|
this.$axios
|
||||||
|
.$post('/logout')
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$router.push('/login')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
this.handleClickOutside = (e) => {
|
||||||
|
if (!this.$el.contains(e.target)) {
|
||||||
|
this.showProfileDropdown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', this.handleClickOutside)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||||
|
// Remove event listener with proper reference
|
||||||
|
if (this.handleClickOutside) {
|
||||||
|
document.removeEventListener('click', this.handleClickOutside)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('BookShelfCategorized: libraryItem added', libraryItem)
|
||||||
// TODO: Check if libraryItem would be on this shelf
|
// TODO: Check if libraryItem would be on this shelf
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
|
|
@ -499,6 +499,70 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
console.error('Error socket not initialized')
|
console.error('Error socket not initialized')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
selectAllItems() {
|
||||||
|
console.log('BookShelfCategorized: selectAllItems called!')
|
||||||
|
console.log('BookShelfCategorized: shelves:', this.shelves)
|
||||||
|
|
||||||
|
// Get all visible items from shelves
|
||||||
|
const allVisibleEntities = []
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
console.log('BookShelfCategorized: processing shelf:', shelf.label, 'type:', shelf.type, 'entities:', shelf.entities.length)
|
||||||
|
if (shelf.type === 'book' || shelf.type === 'podcast') {
|
||||||
|
allVisibleEntities.push(...shelf.entities)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if all visible items are already selected
|
||||||
|
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||||
|
const visibleEntityIds = allVisibleEntities.map((e) => e.id)
|
||||||
|
const selectedVisibleCount = selectedMediaItems.filter((item) => visibleEntityIds.includes(item.id)).length
|
||||||
|
const shouldDeselect = selectedVisibleCount === visibleEntityIds.length && selectedVisibleCount > 0
|
||||||
|
|
||||||
|
console.log('BookShelfCategorized: visible entities:', visibleEntityIds.length, 'selected visible:', selectedVisibleCount, 'shouldDeselect:', shouldDeselect)
|
||||||
|
|
||||||
|
if (shouldDeselect) {
|
||||||
|
// Deselect all visible items
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
console.log('BookShelfCategorized: processing shelf for deselection:', shelf.label, 'type:', shelf.type, 'entities:', shelf.entities.length)
|
||||||
|
if (shelf.type === 'book' || shelf.type === 'podcast') {
|
||||||
|
shelf.entities.forEach((entity) => {
|
||||||
|
console.log('BookShelfCategorized: deselecting entity:', entity.id, entity.mediaType)
|
||||||
|
// Create the media item object the same way the individual deselect does
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the item from selection
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Select all visible items
|
||||||
|
this.shelves.forEach((shelf) => {
|
||||||
|
if (shelf.type === 'book' || shelf.type === 'podcast') {
|
||||||
|
shelf.entities.forEach((entity) => {
|
||||||
|
// Create the media item object the same way the individual select does
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing store mutation to mark the item as selected
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a single UI update after all items are selected/deselected
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,13 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isBatchSelecting && (!this.page || this.page === 'search')) {
|
||||||
|
items.push({
|
||||||
|
text: 'Select All',
|
||||||
|
action: 'select-all'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
this.addSubtitlesMenuItem(items)
|
this.addSubtitlesMenuItem(items)
|
||||||
this.addCollapseSeriesMenuItem(items)
|
this.addCollapseSeriesMenuItem(items)
|
||||||
|
|
||||||
|
|
@ -426,12 +433,19 @@ export default {
|
||||||
if (action === 'export-opml') {
|
if (action === 'export-opml') {
|
||||||
this.exportOPML()
|
this.exportOPML()
|
||||||
return
|
return
|
||||||
|
} else if (action === 'select-all') {
|
||||||
|
this.selectAllItems()
|
||||||
|
return
|
||||||
} else if (this.handleSubtitlesAction(action)) {
|
} else if (this.handleSubtitlesAction(action)) {
|
||||||
return
|
return
|
||||||
} else if (this.handleCollapseSeriesAction(action)) {
|
} else if (this.handleCollapseSeriesAction(action)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
selectAllItems() {
|
||||||
|
// Emit an event to the parent component to handle selecting all items
|
||||||
|
this.$emit('select-all-items')
|
||||||
|
},
|
||||||
exportOPML() {
|
exportOPML() {
|
||||||
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
||||||
},
|
},
|
||||||
|
|
@ -644,7 +658,6 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#toolbar {
|
#toolbar {
|
||||||
box-shadow: 0px 8px 6px #111111aa;
|
box-shadow: 0px 8px 6px #111111aa;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary/30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary/70' : 'hover:bg-primary/30'">
|
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary/30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary/70' : 'hover:bg-primary/30'">
|
||||||
<p class="leading-4">{{ route.title }}</p>
|
<p class="leading-4">{{ route.title }}</p>
|
||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="routeName === route.id" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||||
|
|
@ -109,6 +109,11 @@ export default {
|
||||||
id: 'config-authentication',
|
id: 'config-authentication',
|
||||||
title: this.$strings.HeaderAuthentication,
|
title: this.$strings.HeaderAuthentication,
|
||||||
path: '/config/authentication'
|
path: '/config/authentication'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'styling',
|
||||||
|
title: this.$strings.HeaderStyling,
|
||||||
|
path: '/config/styling'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
53
client/components/app/GlobalStyling.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'GlobalStyling',
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['getServerStyling'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
applyColors() {
|
||||||
|
const styling = this.getServerStyling
|
||||||
|
if (!styling) return
|
||||||
|
|
||||||
|
// Create a style element if it doesn't exist
|
||||||
|
let styleEl = document.getElementById('custom-colors')
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style')
|
||||||
|
styleEl.id = 'custom-colors'
|
||||||
|
document.head.appendChild(styleEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update CSS variables
|
||||||
|
const css = `
|
||||||
|
:root {
|
||||||
|
--color-primary: ${styling.primary};
|
||||||
|
--color-primary-dark: ${styling.primaryDark};
|
||||||
|
--color-success: ${styling.success};
|
||||||
|
--color-warning: ${styling.warning};
|
||||||
|
--color-error: ${styling.error};
|
||||||
|
--color-info: ${styling.info};
|
||||||
|
--color-bg: ${styling.background};
|
||||||
|
}
|
||||||
|
`
|
||||||
|
styleEl.innerHTML = css
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.applyColors()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
getServerStyling: {
|
||||||
|
handler() {
|
||||||
|
this.applyColors()
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -543,7 +543,7 @@ export default {
|
||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('LazyBookshelf: libraryItem added', libraryItem)
|
||||||
// TODO: Check if audiobook would be on this shelf
|
// TODO: Check if audiobook would be on this shelf
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
},
|
},
|
||||||
|
|
@ -880,6 +880,64 @@ export default {
|
||||||
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier
|
||||||
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft
|
||||||
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
|
||||||
|
},
|
||||||
|
selectAllItems() {
|
||||||
|
// Check if all visible items are already selected
|
||||||
|
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||||
|
const visibleEntityIds = this.entities.filter((e) => e).map((e) => e.id)
|
||||||
|
const selectedVisibleCount = selectedMediaItems.filter((item) => visibleEntityIds.includes(item.id)).length
|
||||||
|
const shouldDeselect = selectedVisibleCount === visibleEntityIds.length && selectedVisibleCount > 0
|
||||||
|
|
||||||
|
if (shouldDeselect) {
|
||||||
|
// Deselect all visible items
|
||||||
|
this.entities.forEach((entity) => {
|
||||||
|
if (entity) {
|
||||||
|
// Create the media item object the same way the individual deselect does
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the item from selection
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Select all visible items
|
||||||
|
this.entities.forEach((entity) => {
|
||||||
|
if (entity) {
|
||||||
|
// Create the media item object the same way the individual select does
|
||||||
|
const mediaItem = {
|
||||||
|
id: entity.id,
|
||||||
|
mediaType: entity.mediaType,
|
||||||
|
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing store mutation to mark the item as selected
|
||||||
|
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force update all card components to reflect the selection state
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.updateBookSelectionMode(true)
|
||||||
|
|
||||||
|
// Also update the selected property of each card component
|
||||||
|
const updatedSelectedItems = this.$store.state.globals.selectedMediaItems
|
||||||
|
|
||||||
|
for (const key in this.entityComponentRefs) {
|
||||||
|
if (this.entityIndexesMounted.includes(Number(key))) {
|
||||||
|
const component = this.entityComponentRefs[key]
|
||||||
|
const entity = this.entities[key]
|
||||||
|
if (component && entity) {
|
||||||
|
const isSelected = updatedSelectedItems.some((item) => item.id === entity.id)
|
||||||
|
component.selected = isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
68
client/components/app/ServerSettingsTab.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<nuxt-link v-for="setting in serverSettings" :key="setting.path" :to="setting.path" class="p-4 bg-primary hover:bg-primary-600 rounded-lg transition-colors duration-200">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="material-symbols text-2xl mr-3">{{ setting.icon }}</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">{{ setting.title }}</h3>
|
||||||
|
<p class="text-sm text-gray-300">{{ setting.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
serverSettings: [
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderAuthentication || 'Authentication',
|
||||||
|
description: this.$strings.DescriptionAuthentication || 'Configure authentication methods and settings',
|
||||||
|
path: '/config/authentication',
|
||||||
|
icon: 'security'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderLibraries || 'Libraries',
|
||||||
|
description: this.$strings.DescriptionLibraries || 'Manage your libraries',
|
||||||
|
path: '/config/libraries',
|
||||||
|
icon: 'library_books'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderBackups || 'Backups',
|
||||||
|
description: this.$strings.DescriptionBackups || 'Configure server backups',
|
||||||
|
path: '/config/backups',
|
||||||
|
icon: 'backup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderEmail || 'Email',
|
||||||
|
description: this.$strings.DescriptionEmail || 'Configure email settings',
|
||||||
|
path: '/config/email',
|
||||||
|
icon: 'mail'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderNotifications || 'Notifications',
|
||||||
|
description: this.$strings.DescriptionNotifications || 'Configure notification settings',
|
||||||
|
path: '/config/notifications',
|
||||||
|
icon: 'notifications'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderServerLogs || 'Server Logs',
|
||||||
|
description: this.$strings.DescriptionServerLogs || 'View server logs',
|
||||||
|
path: '/config/log',
|
||||||
|
icon: 'article'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: this.$strings.HeaderServerStyling || 'Server Styling',
|
||||||
|
description: this.$strings.DescriptionServerStyling || 'Customize server appearance',
|
||||||
|
path: '/config/styling',
|
||||||
|
icon: 'palette'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -18,9 +18,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="!isEditingRoot" class="flex py-2">
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
|
<div class="w-1/2 px-2">
|
||||||
|
<ui-text-input-with-label v-model.trim="newUser.displayName" :label="'Display Name'" />
|
||||||
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="!isEditingRoot" class="flex py-2">
|
||||||
<div class="px-2 w-52">
|
<div class="px-2 w-52">
|
||||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -387,6 +392,7 @@ export default {
|
||||||
username: null,
|
username: null,
|
||||||
email: null,
|
email: null,
|
||||||
password: null,
|
password: null,
|
||||||
|
displayName: null,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
|
|
|
||||||
162
client/components/modals/BatchApplyTagsModal.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="batch-apply-tags" :processing="processing" :width="500" :height="'unset'">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
|
<p class="text-3xl text-white truncate">Apply Tags</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
|
<div v-if="show" class="w-full h-full">
|
||||||
|
<div class="py-4 px-4">
|
||||||
|
<h1 class="text-2xl">Apply Tags to {{ selectedItemsCount }} Items</h1>
|
||||||
|
<p class="text-sm text-gray-300 mt-2">Add or remove tags from {{ selectedItemsCount }} selected items</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 pb-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<ui-multi-select ref="tagsMultiSelect" v-model="selectedTags" :items="allTags" :label="addMode ? 'Tags to add' : 'Tags to remove'" @newItem="newTagItem" @removedItem="removedTagItem" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<ui-toggle-switch v-model="addMode" />
|
||||||
|
<p class="text-sm ml-2">{{ addMode ? 'Add tags to items' : 'Remove tags from items' }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
{{ addMode ? 'Selected tags will be added to all selected items' : 'Selected tags will be removed from all selected items' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<ui-btn small @click="show = false">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
|
<ui-btn color="bg-success" small :disabled="!selectedTags.length" @click="applyTags">
|
||||||
|
{{ addMode ? 'Add Tags' : 'Remove Tags' }}
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
selectedMediaItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
selectedTags: [],
|
||||||
|
allTags: [],
|
||||||
|
addMode: true,
|
||||||
|
localSelectedItems: [] // Local copy to avoid Vuex mutation issues
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedMediaItems: {
|
||||||
|
handler(newItems) {
|
||||||
|
// Create a local copy to avoid Vuex mutation issues
|
||||||
|
this.localSelectedItems = newItems ? [...newItems] : []
|
||||||
|
},
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItemsCount() {
|
||||||
|
return this.localSelectedItems.length
|
||||||
|
},
|
||||||
|
filterData() {
|
||||||
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
init() {
|
||||||
|
this.selectedTags = []
|
||||||
|
this.loadTags()
|
||||||
|
// Ensure we have a local copy of selected items
|
||||||
|
this.localSelectedItems = this.selectedMediaItems ? [...this.selectedMediaItems] : []
|
||||||
|
},
|
||||||
|
loadTags() {
|
||||||
|
// Get tags from filter data (existing tags in the library)
|
||||||
|
this.allTags = [...(this.filterData.tags || [])]
|
||||||
|
},
|
||||||
|
newTagItem(tag) {
|
||||||
|
if (!this.allTags.includes(tag)) {
|
||||||
|
this.allTags = [...this.allTags, tag]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removedTagItem(tag) {
|
||||||
|
// Don't remove from allTags as user might want to re-add
|
||||||
|
},
|
||||||
|
async applyTags() {
|
||||||
|
if (!this.selectedTags.length || !this.localSelectedItems.length) return
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the current library items to see their existing tags
|
||||||
|
const libraryItemsResponse = await this.$axios.$post('/api/items/batch/get', {
|
||||||
|
libraryItemIds: this.localSelectedItems.map((item) => item.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const libraryItems = libraryItemsResponse.libraryItems || []
|
||||||
|
|
||||||
|
// Prepare update payloads for each item
|
||||||
|
const updatePayloads = libraryItems.map((item) => {
|
||||||
|
let currentTags = item.media?.tags || []
|
||||||
|
|
||||||
|
let newTags
|
||||||
|
if (this.addMode) {
|
||||||
|
// Add tags: merge current tags with selected tags (remove duplicates)
|
||||||
|
newTags = [...new Set([...currentTags, ...this.selectedTags])]
|
||||||
|
} else {
|
||||||
|
// Remove tags: filter out selected tags from current tags
|
||||||
|
newTags = currentTags.filter((tag) => !this.selectedTags.includes(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
mediaPayload: {
|
||||||
|
tags: newTags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.$axios.$post('/api/items/batch/update', updatePayloads)
|
||||||
|
|
||||||
|
this.$toast.success(this.addMode ? `Successfully added ${this.selectedTags.length} tag(s) to ${this.selectedItemsCount} item(s)` : `Successfully removed ${this.selectedTags.length} tag(s) from ${this.selectedItemsCount} item(s)`)
|
||||||
|
|
||||||
|
this.show = false
|
||||||
|
this.$emit('tagsApplied')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply tags', error)
|
||||||
|
const errorMsg = error.response?.data || 'Failed to apply tags'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -151,7 +151,7 @@ export default {
|
||||||
ctx.fillText('\ue900', 15, 36)
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
// Top text
|
// Top text
|
||||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
addText('Bookfire', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
|
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
// Top left box
|
// Top left box
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export default {
|
||||||
ctx.fillText('\ue900', 15, 36)
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
// Top text
|
// Top text
|
||||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
addText('Bookfire', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
|
addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
// Top left box
|
// Top left box
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<table id="accounts">
|
<table id="accounts">
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $strings.LabelUsername }}</th>
|
<th>{{ $strings.LabelUsername }}</th>
|
||||||
|
<th>{{ $strings.LabelDisplayName }}</th>
|
||||||
<th class="w-20">{{ $strings.LabelAccountType }}</th>
|
<th class="w-20">{{ $strings.LabelAccountType }}</th>
|
||||||
<th class="hidden lg:table-cell">{{ $strings.LabelActivity }}</th>
|
<th class="hidden lg:table-cell">{{ $strings.LabelActivity }}</th>
|
||||||
<th class="w-32 hidden sm:table-cell">{{ $strings.LabelLastSeen }}</th>
|
<th class="w-32 hidden sm:table-cell">{{ $strings.LabelLastSeen }}</th>
|
||||||
|
|
@ -17,6 +18,9 @@
|
||||||
<p class="pl-2 truncate">{{ user.username }}</p>
|
<p class="pl-2 truncate">{{ user.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<p class="truncate">{{ user.displayName || '-' }}</p>
|
||||||
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
<td class="hidden lg:table-cell">
|
<td class="hidden lg:table-cell">
|
||||||
<div v-if="usersOnline[user.id]?.session?.displayTitle">
|
<div v-if="usersOnline[user.id]?.session?.displayTitle">
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,8 @@ export default {
|
||||||
},
|
},
|
||||||
setInputWidth() {
|
setInputWidth() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
var value = this.$refs.input.value
|
if (!this.$refs.input) return
|
||||||
|
var value = this.$refs.input.value || ''
|
||||||
var len = value.length * 7 + 24
|
var len = value.length * 7 + 24
|
||||||
this.$refs.input.style.width = len + 'px'
|
this.$refs.input.style.width = len + 'px'
|
||||||
this.recalcMenuPos()
|
this.recalcMenuPos()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||||
|
<app-global-styling />
|
||||||
<app-appbar />
|
<app-appbar />
|
||||||
|
|
||||||
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
||||||
|
|
@ -27,7 +28,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import AppGlobalStyling from '~/components/app/GlobalStyling.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
AppGlobalStyling
|
||||||
|
},
|
||||||
middleware: 'authenticated',
|
middleware: 'authenticated',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
<div class="absolute z-0 top-0 left-0 px-6 py-3">
|
<div class="absolute z-0 top-0 left-0 px-6 py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<img src="~static/icon.svg" alt="Audiobookshelf Logo" class="w-10 min-w-10 h-10" />
|
<img src="~static/bookfire-logo-white.svg" alt="Bookfire Logo" class="w-10 min-w-10 h-10" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link to="/">
|
<nuxt-link to="/">
|
||||||
<h1 class="text-xl ml-4 hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl ml-4 hover:underline">Bookfire</h1>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ module.exports = {
|
||||||
|
|
||||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||||
head: {
|
head: {
|
||||||
title: 'Audiobookshelf',
|
title: 'Bookfire',
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: 'en'
|
||||||
},
|
},
|
||||||
|
|
@ -87,19 +87,21 @@ module.exports = {
|
||||||
nativeUI: true
|
nativeUI: true
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Audiobookshelf',
|
name: 'Bookfire',
|
||||||
short_name: 'Audiobookshelf',
|
short_name: 'Bookfire',
|
||||||
display: 'standalone',
|
description: 'Self-hosted audiobook server',
|
||||||
background_color: '#232323',
|
theme_color: '#111827',
|
||||||
|
background_color: '#111827',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: routerBasePath + '/icon.svg',
|
src: routerBasePath + '/bookfire-logo-white.svg',
|
||||||
sizes: 'any'
|
type: 'image/svg+xml',
|
||||||
|
sizes: '512x512'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: routerBasePath + '/icon192.png',
|
src: routerBasePath + '/icon192.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: 'any'
|
sizes: '192x192'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -112,7 +114,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||||
build: {},
|
build: {
|
||||||
|
extend(config, { isDev, isClient }) {
|
||||||
|
// Add any necessary build configurations here
|
||||||
|
}
|
||||||
|
},
|
||||||
watchers: {
|
watchers: {
|
||||||
webpack: {
|
webpack: {
|
||||||
aggregateTimeout: 300,
|
aggregateTimeout: 300,
|
||||||
|
|
|
||||||
7161
client/package-lock.json
generated
|
|
@ -12,6 +12,16 @@
|
||||||
<ui-text-input-with-label disabled :value="usertype" :label="$strings.LabelAccountType" />
|
<ui-text-input-with-label disabled :value="usertype" :label="$strings.LabelAccountType" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<form @submit.prevent="submitDisplayName">
|
||||||
|
<div class="flex -mx-2 mt-4">
|
||||||
|
<div class="w-2/3 px-2">
|
||||||
|
<ui-text-input-with-label v-model="displayNameInput" :label="'Display Name'" />
|
||||||
|
</div>
|
||||||
|
<div class="px-2 flex items-end">
|
||||||
|
<ui-btn type="submit" color="bg-success">Update</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguage }}</p>
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelLanguage }}</p>
|
||||||
<ui-dropdown v-model="selectedLanguage" :items="$languageCodeOptions" small class="max-w-48" @input="updateLocalLanguage" />
|
<ui-dropdown v-model="selectedLanguage" :items="$languageCodeOptions" small class="max-w-48" @input="updateLocalLanguage" />
|
||||||
|
|
@ -88,6 +98,7 @@ export default {
|
||||||
confirmPassword: null,
|
confirmPassword: null,
|
||||||
changingPassword: false,
|
changingPassword: false,
|
||||||
selectedLanguage: '',
|
selectedLanguage: '',
|
||||||
|
displayNameInput: '',
|
||||||
newEReaderDevice: {
|
newEReaderDevice: {
|
||||||
name: '',
|
name: '',
|
||||||
email: ''
|
email: ''
|
||||||
|
|
@ -103,7 +114,9 @@ export default {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user || null
|
const user = this.$store.state.user.user || null
|
||||||
|
console.log('[Account Page] Current user object:', user)
|
||||||
|
return user
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.username
|
||||||
|
|
@ -111,6 +124,9 @@ export default {
|
||||||
usertype() {
|
usertype() {
|
||||||
return this.user.type
|
return this.user.type
|
||||||
},
|
},
|
||||||
|
displayName() {
|
||||||
|
return this.user.displayName || ''
|
||||||
|
},
|
||||||
isRoot() {
|
isRoot() {
|
||||||
return this.usertype === 'root'
|
return this.usertype === 'root'
|
||||||
},
|
},
|
||||||
|
|
@ -237,11 +253,26 @@ export default {
|
||||||
},
|
},
|
||||||
ereaderDevicesUpdated(ereaderDevices) {
|
ereaderDevicesUpdated(ereaderDevices) {
|
||||||
this.ereaderDevices = ereaderDevices
|
this.ereaderDevices = ereaderDevices
|
||||||
|
},
|
||||||
|
async submitDisplayName() {
|
||||||
|
try {
|
||||||
|
await this.$axios.$patch(`/api/users/${this.user.id}`, {
|
||||||
|
displayName: this.displayNameInput
|
||||||
|
})
|
||||||
|
this.$toast.success('Display name updated successfully')
|
||||||
|
// Update the user in the store
|
||||||
|
const user = { ...this.user, displayName: this.displayNameInput }
|
||||||
|
this.$store.commit('user/setUser', user)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update display name:', error)
|
||||||
|
this.$toast.error('Failed to update display name')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.selectedLanguage = this.$languageCodes.current
|
this.selectedLanguage = this.$languageCodes.current
|
||||||
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
|
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
|
||||||
|
this.displayNameInput = this.displayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
176
client/pages/config/styling.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="$strings.HeaderServerStyling || 'Server Styling'">
|
||||||
|
<div class="w-full max-w-3xl">
|
||||||
|
<div class="pt-4">
|
||||||
|
<h2 class="font-semibold">Server Title</h2>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Customize the name of your server.</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<input type="text" v-model="title" class="w-full bg-primary/20 border border-gray-600 rounded px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-8">
|
||||||
|
<h2 class="font-semibold">Theme Colors</h2>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Customize the appearance of your server by adjusting these colors.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||||
|
<!-- Primary Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Primary Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.primary" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('primary', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.primary" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('primary', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Primary Dark -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Primary Dark</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.primaryDark" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('primaryDark', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.primaryDark" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('primaryDark', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Success Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.success" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('success', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.success" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('success', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Warning Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.warning" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('warning', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.warning" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('warning', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Error Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.error" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('error', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.error" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('error', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Info Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.info" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('info', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.info" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('info', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Background Color -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium">Background Color</label>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<input type="color" v-model="colors.background" class="w-10 h-10 rounded cursor-pointer border border-gray-600" @input="updateColor('background', $event.target.value)" />
|
||||||
|
<input type="text" v-model="colors.background" class="flex-1 bg-primary/20 border border-gray-600 rounded px-3 py-2" @input="updateColor('background', $event.target.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Button -->
|
||||||
|
<div class="mt-8 flex justify-end">
|
||||||
|
<ui-btn color="bg-success" :loading="saving" @click="saveSettings">Save Changes</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="mt-12 border-t border-gray-600 pt-8">
|
||||||
|
<h2 class="font-semibold mb-4">Preview</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium mb-2">Buttons</p>
|
||||||
|
<ui-btn color="bg-primary" class="mr-2">Primary</ui-btn>
|
||||||
|
<ui-btn color="bg-success" class="mr-2">Success</ui-btn>
|
||||||
|
<ui-btn color="bg-warning" class="mr-2">Warning</ui-btn>
|
||||||
|
<ui-btn color="bg-error">Error</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium mb-2">Alerts</p>
|
||||||
|
<widgets-alert type="info" class="mb-2">This is an info alert</widgets-alert>
|
||||||
|
<widgets-alert type="success" class="mb-2">This is a success alert</widgets-alert>
|
||||||
|
<widgets-alert type="warning" class="mb-2">This is a warning alert</widgets-alert>
|
||||||
|
<widgets-alert type="error">This is an error alert</widgets-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'styling',
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
saving: false,
|
||||||
|
title: '',
|
||||||
|
colors: {
|
||||||
|
primary: '#1e293b',
|
||||||
|
primaryDark: '#0f172a',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
info: '#3b82f6',
|
||||||
|
background: '#111827'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['getServerStyling'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateColor(key, value) {
|
||||||
|
// Validate hex color
|
||||||
|
if (!/^#[0-9A-F]{6}$/i.test(value)) return
|
||||||
|
|
||||||
|
this.colors[key] = value
|
||||||
|
},
|
||||||
|
async saveSettings() {
|
||||||
|
this.saving = true
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch('updateServerSettings', {
|
||||||
|
title: this.title,
|
||||||
|
styling: this.colors
|
||||||
|
})
|
||||||
|
this.$toast.success('Server styling updated')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save styling settings:', error)
|
||||||
|
this.$toast.error('Failed to save styling settings')
|
||||||
|
}
|
||||||
|
this.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Load saved colors from server settings
|
||||||
|
const serverStyling = this.getServerStyling
|
||||||
|
if (serverStyling) {
|
||||||
|
this.colors = { ...this.colors, ...serverStyling }
|
||||||
|
}
|
||||||
|
// Load saved title
|
||||||
|
this.title = this.$store.state.serverSettings?.title || 'Bookfire'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -107,7 +107,7 @@ export default {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.displayName || this.user.username
|
||||||
},
|
},
|
||||||
userOnline() {
|
userOnline() {
|
||||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.displayName || this.user.username
|
||||||
},
|
},
|
||||||
userOnline() {
|
userOnline() {
|
||||||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,116 @@
|
||||||
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments section -->
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="flex flex-col lg:flex-row">
|
||||||
|
<div class="w-full lg:w-52" style="min-width: 208px">
|
||||||
|
<!-- Spacer div to match the layout above -->
|
||||||
|
</div>
|
||||||
|
<div class="grow px-2 md:px-10">
|
||||||
|
<div class="mt-12">
|
||||||
|
<div class="comments-section mt-4 border-t border-gray-700 pt-4">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">{{ $strings.LabelRatings }}</h3>
|
||||||
|
|
||||||
|
<!-- Average Rating Display -->
|
||||||
|
<div v-if="comments.length" class="mb-4">
|
||||||
|
<p class="text-lg">
|
||||||
|
{{ $strings.LabelAverageRating.replace('{0}', averageRating.toFixed(1)) }}
|
||||||
|
<span class="inline-flex ml-2">
|
||||||
|
<i v-for="i in 5" :key="i" class="fas fa-star" :class="i <= Math.round(averageRating) ? 'text-yellow-500' : 'text-gray-500'"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Comment Form -->
|
||||||
|
<div v-if="!hasUserComment" class="mb-6">
|
||||||
|
<div class="bg-bg border border-gray-700 rounded-lg p-4">
|
||||||
|
<textarea v-model="newComment" :placeholder="$strings.PlaceholderAddComment" class="w-full p-2 bg-transparent border border-gray-600 rounded resize-none focus:outline-none mb-3" rows="3"></textarea>
|
||||||
|
|
||||||
|
<!-- Star Rating Input -->
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<span class="mr-2">{{ $strings.LabelRating }}:</span>
|
||||||
|
<div class="flex">
|
||||||
|
<button v-for="star in 5" :key="star" class="text-2xl focus:outline-none" :class="star <= (hoverRating || newRating) ? 'text-yellow-500' : 'text-gray-500'" @click="newRating = star" @mouseover="hoverRating = star" @mouseleave="hoverRating = 0">
|
||||||
|
<span class="abs-icons icon-star"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button class="px-4 py-2 bg-primary text-white rounded hover:bg-opacity-75 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!newRating" @click="postComment">
|
||||||
|
{{ $strings.ButtonPost }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments List -->
|
||||||
|
<div v-if="comments.length" class="space-y-4">
|
||||||
|
<div v-for="comment in sortedComments" :key="comment.id" class="bg-primary border rounded-lg p-4" :class="comment.userId === currentUser.id ? 'border-white border-4' : 'border-gray-700'">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="'/user/' + comment.userId" class="font-semibold hover:text-white hover:underline">{{ comment.user.displayName || comment.user.username }}</nuxt-link>
|
||||||
|
<span class="text-sm text-gray-300 ml-2">
|
||||||
|
{{ formatDate(comment.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<!-- Star Rating Display -->
|
||||||
|
<div v-if="comment.rating" class="flex mr-4">
|
||||||
|
<span v-for="star in 5" :key="star" class="abs-icons icon-star" :class="star <= comment.rating ? 'text-yellow-500' : 'text-gray-500'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canEditComment(comment)" class="space-x-2">
|
||||||
|
<button v-if="editingCommentId !== comment.id" class="text-gray-300 hover:text-white" @click="startEditing(comment)">
|
||||||
|
{{ $strings.ButtonEdit }}
|
||||||
|
</button>
|
||||||
|
<button class="text-gray-300 hover:text-white" @click="deleteComment(comment)">
|
||||||
|
{{ $strings.ButtonDelete }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Comment Form -->
|
||||||
|
<div v-if="editingCommentId === comment.id">
|
||||||
|
<textarea v-model="editCommentText" class="w-full p-2 bg-gray-800 rounded mb-2 resize-none focus:outline-none" rows="3"></textarea>
|
||||||
|
|
||||||
|
<!-- Edit Rating -->
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<span class="mr-2">{{ $strings.LabelRating }}:</span>
|
||||||
|
<div class="flex">
|
||||||
|
<button v-for="star in 5" :key="star" class="text-2xl focus:outline-none" :class="star <= editRating ? 'text-yellow-500' : 'text-gray-500'" @click="editRating = star">
|
||||||
|
<span class="abs-icons icon-star"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="px-4 py-2 bg-primary text-white rounded hover:bg-opacity-75 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!editRating" @click="saveEdit(comment)">
|
||||||
|
{{ $strings.ButtonSave }}
|
||||||
|
</button>
|
||||||
|
<button class="px-4 py-2 bg-gray-700 text-white rounded hover:bg-opacity-75" @click="cancelEdit">
|
||||||
|
{{ $strings.ButtonCancel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment Text Display -->
|
||||||
|
<div v-else class="text-gray-200">
|
||||||
|
{{ comment.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">
|
||||||
|
{{ $strings.MessageNoComments }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
|
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
|
||||||
|
|
@ -148,6 +258,9 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
// Remove unused comments component registration
|
||||||
|
},
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
if (!store.state.user.user) {
|
if (!store.state.user.user) {
|
||||||
return redirect(`/login?redirect=${route.path}`)
|
return redirect(`/login?redirect=${route.path}`)
|
||||||
|
|
@ -182,7 +295,14 @@ export default {
|
||||||
episodeDownloadsQueued: [],
|
episodeDownloadsQueued: [],
|
||||||
showBookmarksModal: false,
|
showBookmarksModal: false,
|
||||||
isDescriptionClamped: false,
|
isDescriptionClamped: false,
|
||||||
showFullDescription: false
|
showFullDescription: false,
|
||||||
|
newComment: '',
|
||||||
|
newRating: 0,
|
||||||
|
hoverRating: 0,
|
||||||
|
editingCommentId: null,
|
||||||
|
editCommentText: '',
|
||||||
|
editRating: 0,
|
||||||
|
comments: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -431,6 +551,27 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
},
|
||||||
|
currentUser() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
|
averageRating() {
|
||||||
|
const ratedComments = this.comments.filter((c) => c.rating)
|
||||||
|
if (!ratedComments.length) return 0
|
||||||
|
const sum = ratedComments.reduce((acc, comment) => acc + comment.rating, 0)
|
||||||
|
return sum / ratedComments.length
|
||||||
|
},
|
||||||
|
hasUserComment() {
|
||||||
|
return this.comments.some((comment) => comment.userId === this.$store.state.user.user.id)
|
||||||
|
},
|
||||||
|
sortedComments() {
|
||||||
|
return [...this.comments].sort((a, b) => {
|
||||||
|
// Put current user's comment at the top
|
||||||
|
if (a.userId === this.currentUser.id) return -1
|
||||||
|
if (b.userId === this.currentUser.id) return 1
|
||||||
|
// Sort remaining comments by date, newest first
|
||||||
|
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -462,7 +603,7 @@ export default {
|
||||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess)
|
this.$toast.success(this.$strings.ToastEpisodeDownloadQueueClearSuccess)
|
||||||
this.episodeDownloadQueued = []
|
this.episodeDownloadsQueued = []
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to clear queue', error)
|
console.error('Failed to clear queue', error)
|
||||||
|
|
@ -777,10 +918,86 @@ export default {
|
||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
async postComment() {
|
||||||
|
if (!this.newComment.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.$axios.$post(`/api/items/${this.libraryItemId}/comments`, {
|
||||||
|
text: this.newComment.trim(),
|
||||||
|
rating: this.newRating
|
||||||
|
})
|
||||||
|
|
||||||
|
this.comments.unshift(response)
|
||||||
|
this.newComment = ''
|
||||||
|
this.newRating = 0
|
||||||
|
this.$toast.success(this.$strings.MessageCommentAdded)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error posting comment:', error)
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
this.$toast.error(error.response.data.error)
|
||||||
|
} else {
|
||||||
|
this.$toast.error(this.$strings.ErrorAddingComment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startEditing(comment) {
|
||||||
|
this.editingCommentId = comment.id
|
||||||
|
this.editCommentText = comment.text
|
||||||
|
this.editRating = comment.rating || 0
|
||||||
|
},
|
||||||
|
async saveEdit(comment) {
|
||||||
|
try {
|
||||||
|
const response = await this.$axios.$patch(`/api/items/${this.libraryItemId}/comments/${comment.id}`, {
|
||||||
|
text: this.editCommentText.trim(),
|
||||||
|
rating: this.editRating
|
||||||
|
})
|
||||||
|
|
||||||
|
const index = this.comments.findIndex((c) => c.id === comment.id)
|
||||||
|
this.comments.splice(index, 1, response)
|
||||||
|
this.cancelEdit()
|
||||||
|
this.$toast.success(this.$strings.MessageCommentUpdated)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating comment:', error)
|
||||||
|
this.$toast.error(this.$strings.ErrorUpdatingComment)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelEdit() {
|
||||||
|
this.editingCommentId = null
|
||||||
|
this.editCommentText = ''
|
||||||
|
this.editRating = 0
|
||||||
|
},
|
||||||
|
async deleteComment(comment) {
|
||||||
|
if (!confirm(this.$strings.ConfirmDeleteComment)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.$axios.delete(`/api/items/${this.libraryItem.id}/comments/${comment.id}`)
|
||||||
|
const index = this.comments.findIndex((c) => c.id === comment.id)
|
||||||
|
this.comments.splice(index, 1)
|
||||||
|
this.$toast.success(this.$strings.MessageCommentDeleted)
|
||||||
|
} catch (error) {
|
||||||
|
this.$toast.error(this.$strings.ErrorDeletingComment)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canEditComment(comment) {
|
||||||
|
return this.$store.state.user.user.id === comment.userId || this.$store.state.user.user.type === 'admin'
|
||||||
|
},
|
||||||
|
formatDate(date) {
|
||||||
|
return new Date(date).toLocaleDateString()
|
||||||
|
},
|
||||||
|
async loadComments() {
|
||||||
|
try {
|
||||||
|
const response = await this.$axios.$get(`/api/items/${this.libraryItemId}/comments`)
|
||||||
|
this.comments = response || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading comments:', error)
|
||||||
|
this.$toast.error(this.$strings.ErrorLoadingComments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.checkDescriptionClamped()
|
this.checkDescriptionClamped()
|
||||||
|
await this.loadComments()
|
||||||
|
|
||||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||||
|
|
@ -795,6 +1012,7 @@ export default {
|
||||||
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
|
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
|
||||||
|
this.$root.socket.on('user_updated', this.loadComments)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
|
|
@ -807,6 +1025,7 @@ export default {
|
||||||
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
|
||||||
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
|
||||||
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
|
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
|
||||||
|
this.$root.socket.off('user_updated', this.loadComments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -834,4 +1053,9 @@ export default {
|
||||||
-webkit-line-clamp: unset;
|
-webkit-line-clamp: unset;
|
||||||
max-height: 999rem;
|
max-height: 999rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar :page="id || ''" />
|
<app-book-shelf-toolbar :page="id || ''" @select-all-items="selectAllItems" />
|
||||||
<app-lazy-bookshelf :page="id || ''" />
|
<app-lazy-bookshelf ref="lazyBookshelf" :page="id || ''" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -44,6 +44,13 @@ export default {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {}
|
methods: {
|
||||||
|
selectAllItems() {
|
||||||
|
if (this.$refs.lazyBookshelf) {
|
||||||
|
this.$refs.lazyBookshelf.selectAllItems()
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar is-home />
|
<app-book-shelf-toolbar is-home @select-all-items="selectAllItems" />
|
||||||
<app-book-shelf-categorized />
|
<app-book-shelf-categorized ref="bookShelfCategorized" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -25,7 +25,14 @@ export default {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
selectAllItems() {
|
||||||
|
if (this.$refs.bookShelfCategorized) {
|
||||||
|
this.$refs.bookShelfCategorized.selectAllItems()
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
beforeDestroy() {}
|
beforeDestroy() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
<app-book-shelf-toolbar is-home page="search" :search-query="query" @select-all-items="selectAllItems" />
|
||||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||||
<div v-else class="w-full py-16">
|
<div v-else class="w-full py-16">
|
||||||
<p class="text-xl text-center">{{ $getString('MessageNoSearchResultsFor', [query]) }}</p>
|
<p class="text-xl text-center">{{ $getString('MessageNoSearchResultsFor', [query]) }}</p>
|
||||||
|
|
@ -11,10 +11,13 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, params, redirect, query, app }) {
|
async asyncData({ store, params, redirect, query, app }) {
|
||||||
|
if (!query.q) {
|
||||||
|
return redirect(`/library/${params.library}`)
|
||||||
|
}
|
||||||
const libraryId = params.library
|
const libraryId = params.library
|
||||||
const library = await store.dispatch('libraries/fetch', libraryId)
|
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
return redirect('/oops?message=Library not found')
|
return redirect(`/oops?message=Library "${libraryId}" not found`)
|
||||||
}
|
}
|
||||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${encodeURIComponent(query.q)}`).catch((error) => {
|
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${encodeURIComponent(query.q)}`).catch((error) => {
|
||||||
console.error('Failed to search library', error)
|
console.error('Failed to search library', error)
|
||||||
|
|
@ -74,6 +77,12 @@ export default {
|
||||||
this.$refs.bookshelf.setShelvesFromSearch()
|
this.$refs.bookshelf.setShelvesFromSearch()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
selectAllItems() {
|
||||||
|
if (this.$refs.bookshelf) {
|
||||||
|
this.$refs.bookshelf.selectAllItems()
|
||||||
|
} else {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<div id="page-wrapper" class="w-full h-screen overflow-y-auto">
|
<div id="page-wrapper" class="w-full h-screen overflow-y-auto">
|
||||||
<div class="absolute z-0 top-0 left-0 px-6 py-3">
|
<div class="absolute z-0 top-0 left-0 px-6 py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<img src="~static/icon.svg" alt="Audiobookshelf Logo" class="w-10 min-w-10 h-10" />
|
<img src="~static/bookfire-logo-white.svg" alt="Bookfire Logo" class="w-10 min-w-10 h-10" />
|
||||||
<h1 class="text-xl ml-4 hidden lg:block hover:underline">audiobookshelf</h1>
|
<h1 class="text-xl ml-4 hidden lg:block hover:underline">Bookfire</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
374
client/pages/user/_id/index.vue
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full overflow-y-auto bg-gradient-to-br from-[#2e2e2e] via-[#313131] to-[#1f1f1f]">
|
||||||
|
<div class="max-w-6xl mx-auto px-2 py-6 lg:p-8">
|
||||||
|
<div v-if="loading" class="w-full h-full flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!user" class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-error text-lg">{{ $strings.MessageUserNotFound }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div v-if="user" class="flex border-b border-white/10 mb-6">
|
||||||
|
<button v-if="isCurrentUser" class="px-4 py-2 mr-4 font-semibold rounded-t-lg" :class="activeTab === 'account' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'account'">Account Settings</button>
|
||||||
|
<button class="px-4 py-2 font-semibold rounded-t-lg" :class="activeTab === 'reviews' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'reviews'">Reviews</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reviews Tab -->
|
||||||
|
<div v-if="activeTab === 'reviews'" class="space-y-6 pb-24">
|
||||||
|
<div v-if="reviews.length" class="space-y-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4">Reviews</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<div v-for="review in reviews" :key="review.id" class="bg-primary rounded-lg overflow-hidden shadow-lg flex flex-col">
|
||||||
|
<nuxt-link :to="'/item/' + review.libraryItemId" class="block flex-shrink-0" style="height: 280px">
|
||||||
|
<div class="relative h-full w-full flex items-center justify-center">
|
||||||
|
<covers-book-cover v-if="review.libraryItem" :library-item="review.libraryItem" :width="180" :book-cover-aspect-ratio="bookCoverAspectRatio" class="hover:scale-105 transition-transform duration-200" />
|
||||||
|
<div v-else class="absolute inset-0 bg-gray-700 flex items-center justify-center">
|
||||||
|
<span class="material-symbols text-4xl text-gray-400">book</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="p-4 flex-grow">
|
||||||
|
<nuxt-link :to="'/item/' + review.libraryItemId" class="block">
|
||||||
|
<h3 class="text-lg font-semibold mb-2 hover:text-white transition-colors line-clamp-1">{{ review.libraryItem?.title || 'Unknown Book' }}</h3>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="flex mb-3">
|
||||||
|
<span v-for="star in 5" :key="star" class="abs-icons icon-star" :class="star <= review.rating ? 'text-yellow-500' : 'text-gray-500'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-300 text-sm mb-2 line-clamp-3">{{ review.text }}</p>
|
||||||
|
<p class="text-gray-400 text-xs">{{ formatDate(review.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">{{ user.username }} hasn't written any reviews yet.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Tab -->
|
||||||
|
<div v-if="activeTab === 'account' && isCurrentUser" class="max-w-xl">
|
||||||
|
<div class="my-4">
|
||||||
|
<div class="flex -mx-2">
|
||||||
|
<div class="w-2/3 px-2">
|
||||||
|
<ui-text-input-with-label disabled :value="user.username" label="Username" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/3 px-2">
|
||||||
|
<ui-text-input-with-label disabled :value="user.type" label="Account Type" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submitDisplayName">
|
||||||
|
<div class="flex -mx-2 mt-4">
|
||||||
|
<div class="w-2/3 px-2">
|
||||||
|
<ui-text-input-with-label v-model="displayNameInput" label="Display Name" />
|
||||||
|
</div>
|
||||||
|
<div class="px-2 flex items-end">
|
||||||
|
<ui-btn type="submit" color="bg-success">Update</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<p class="px-1 text-sm font-semibold">Language</p>
|
||||||
|
<ui-dropdown v-model="selectedLanguage" :items="$languageCodeOptions" small class="max-w-48" @input="updateLocalLanguage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full h-px bg-white/10 my-4" />
|
||||||
|
|
||||||
|
<div v-if="showChangePasswordForm">
|
||||||
|
<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="Current 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 password changes affect the server configuration</p>
|
||||||
|
<div class="grow" />
|
||||||
|
<ui-btn v-show="(password && newPassword && confirmPassword) || isRoot" type="submit" :loading="changingPassword" color="bg-success">Submit</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showEreaderTable">
|
||||||
|
<div class="w-full h-px bg-white/10 my-4" />
|
||||||
|
<app-settings-content header-text="E-Reader Devices">
|
||||||
|
<template #header-items>
|
||||||
|
<div class="grow" />
|
||||||
|
<ui-btn color="bg-primary" small @click="addNewDeviceClick">Add Device</ui-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<table v-if="ereaderDevices.length" class="tracksTable mt-4">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">Name</th>
|
||||||
|
<th class="text-left">Email</th>
|
||||||
|
<th class="w-40"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="device in ereaderDevices" :key="device.name">
|
||||||
|
<td>
|
||||||
|
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="w-40">
|
||||||
|
<div class="flex justify-end items-center h-10">
|
||||||
|
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name || device.users?.length !== 1" class="mx-1" @click="editDeviceClick(device)" />
|
||||||
|
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name || device.users?.length !== 1" @click="deleteDeviceClick(device)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-else-if="!loading" class="text-center py-4">
|
||||||
|
<p class="text-lg text-gray-100">No devices added yet</p>
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-4 mt-8 flex">
|
||||||
|
<ui-btn color="bg-primary flex items-center text-lg" @click="logout"> <span class="material-symbols mr-4 icon-text">logout</span>Logout </ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-emails-user-e-reader-device-modal v-if="isCurrentUser" v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
reviews: [],
|
||||||
|
activeTab: 'reviews',
|
||||||
|
loading: true,
|
||||||
|
// Account tab data
|
||||||
|
password: null,
|
||||||
|
newPassword: null,
|
||||||
|
confirmPassword: null,
|
||||||
|
changingPassword: false,
|
||||||
|
selectedLanguage: '',
|
||||||
|
displayNameInput: '',
|
||||||
|
ereaderDevices: [],
|
||||||
|
deletingDeviceName: null,
|
||||||
|
selectedEReaderDevice: null,
|
||||||
|
showEReaderDeviceModal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
isCurrentUser() {
|
||||||
|
return this.user?.id === this.$store.state.user.user?.id
|
||||||
|
},
|
||||||
|
isRoot() {
|
||||||
|
return this.user?.type === 'root'
|
||||||
|
},
|
||||||
|
isGuest() {
|
||||||
|
return this.user?.type === 'guest'
|
||||||
|
},
|
||||||
|
isPasswordAuthEnabled() {
|
||||||
|
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
|
||||||
|
return activeAuthMethods.includes('local')
|
||||||
|
},
|
||||||
|
showChangePasswordForm() {
|
||||||
|
return !this.isGuest && this.isPasswordAuthEnabled
|
||||||
|
},
|
||||||
|
showEreaderTable() {
|
||||||
|
return this.user?.type !== 'root' && this.user?.type !== 'admin' && this.user?.permissions?.createEreader
|
||||||
|
},
|
||||||
|
revisedEreaderDevices() {
|
||||||
|
return this.ereaderDevices.filter((device) => device.users?.length === 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(date) {
|
||||||
|
return new Date(date).toLocaleDateString()
|
||||||
|
},
|
||||||
|
async loadUser() {
|
||||||
|
try {
|
||||||
|
this.user = await this.$axios.$get('/api/users/' + this.$route.params.id)
|
||||||
|
if (this.isCurrentUser) {
|
||||||
|
this.activeTab = 'account'
|
||||||
|
this.displayNameInput = this.user.displayName || ''
|
||||||
|
this.selectedLanguage = this.$languageCodes.current
|
||||||
|
await this.loadEreaderDevices()
|
||||||
|
} else {
|
||||||
|
// Set default tab to reviews for other users
|
||||||
|
this.activeTab = 'reviews'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user:', error)
|
||||||
|
this.$toast.error('Error loading user profile')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadReviews() {
|
||||||
|
try {
|
||||||
|
const reviews = await this.$axios.$get('/api/users/' + this.$route.params.id + '/reviews')
|
||||||
|
this.reviews = reviews
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading reviews:', error)
|
||||||
|
this.$toast.error('Error loading reviews')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadEreaderDevices() {
|
||||||
|
try {
|
||||||
|
const response = await this.$axios.$get('/api/users/' + this.user.id + '/ereader-devices')
|
||||||
|
this.ereaderDevices = response.devices || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading e-reader devices:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateLocalLanguage(lang) {
|
||||||
|
this.$setLanguageCode(lang)
|
||||||
|
},
|
||||||
|
async submitDisplayName() {
|
||||||
|
try {
|
||||||
|
await this.$axios.$patch('/api/users/' + this.user.id, {
|
||||||
|
displayName: this.displayNameInput
|
||||||
|
})
|
||||||
|
this.$toast.success('Display name updated')
|
||||||
|
await this.loadUser()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating display name:', error)
|
||||||
|
this.$toast.error('Error updating display name')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitChangePassword() {
|
||||||
|
if (this.newPassword !== this.confirmPassword) {
|
||||||
|
return this.$toast.error('Passwords do not match')
|
||||||
|
}
|
||||||
|
if (this.password === this.newPassword) {
|
||||||
|
return this.$toast.error('New password must be different')
|
||||||
|
}
|
||||||
|
this.changingPassword = true
|
||||||
|
try {
|
||||||
|
await this.$axios.$patch('/api/me/password', {
|
||||||
|
password: this.password,
|
||||||
|
newPassword: this.newPassword
|
||||||
|
})
|
||||||
|
this.$toast.success('Password updated successfully')
|
||||||
|
this.password = null
|
||||||
|
this.newPassword = null
|
||||||
|
this.confirmPassword = null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating password:', error)
|
||||||
|
this.$toast.error('Error updating password')
|
||||||
|
} finally {
|
||||||
|
this.changingPassword = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addNewDeviceClick() {
|
||||||
|
this.selectedEReaderDevice = null
|
||||||
|
this.showEReaderDeviceModal = true
|
||||||
|
},
|
||||||
|
editDeviceClick(device) {
|
||||||
|
this.selectedEReaderDevice = device
|
||||||
|
this.showEReaderDeviceModal = true
|
||||||
|
},
|
||||||
|
async deleteDeviceClick(device) {
|
||||||
|
try {
|
||||||
|
this.deletingDeviceName = device.name
|
||||||
|
await this.$axios.$delete(`/api/users/${this.user.id}/ereader-devices/${device.name}`)
|
||||||
|
this.$toast.success('Device removed')
|
||||||
|
await this.loadEreaderDevices()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting device:', error)
|
||||||
|
this.$toast.error('Error removing device')
|
||||||
|
} finally {
|
||||||
|
this.deletingDeviceName = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ereaderDevicesUpdated() {
|
||||||
|
await this.loadEreaderDevices()
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
if (this.$root.socket) {
|
||||||
|
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||||
|
this.$root.socket.removeAllListeners()
|
||||||
|
this.$root.socket.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
this.$store.commit('libraries/setUserPlaylists', [])
|
||||||
|
this.$store.commit('libraries/setCollections', [])
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$post('/logout')
|
||||||
|
.then((logoutPayload) => {
|
||||||
|
const redirect_url = logoutPayload.redirect_url
|
||||||
|
if (redirect_url) {
|
||||||
|
window.location.href = redirect_url
|
||||||
|
} else {
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await this.loadUser()
|
||||||
|
await this.loadReviews()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-1 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable th,
|
||||||
|
.tracksTable td {
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
Before Width: | Height: | Size: 9 KiB After Width: | Height: | Size: 6.8 KiB |
12
client/static/bookfire-logo-white.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1499 1499" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-4512.94,-9530.37)">
|
||||||
|
<g id="path314" transform="matrix(4.16667,0,0,4.16667,3984.38,8522.36)">
|
||||||
|
<path d="M246.527,523.584C241.806,521.679 241.171,514.32 245.444,511.039C246.467,510.254 246.341,510.122 244.548,510.102C243.401,510.087 242.325,509.656 242.158,509.14C241.991,508.625 241.291,500.947 240.601,492.078C239.911,483.209 238.962,471.903 238.49,466.953L237.633,457.953L233.545,457.454C228.061,456.784 227.509,455.868 227.509,447.426C227.509,439.625 228.227,438.354 233.079,437.566C235.783,437.128 235.831,437.06 235.61,434.036L235.384,430.953L231.072,430.728L226.759,430.503L226.759,423.828L386.509,423.828L386.509,430.578L382.009,430.578C377.537,430.578 377.509,430.592 377.509,432.766C377.509,433.97 377.322,435.442 377.094,436.037C376.785,436.842 377.503,437.23 379.899,437.551C384.754,438.202 385.759,439.92 385.759,447.57C385.759,453.286 385.582,454.073 383.918,455.737C382.504,457.151 381.288,457.578 378.668,457.578C376.793,457.578 375.263,457.831 375.269,458.14C375.303,460.077 371.397,503.577 370.942,506.328C370.461,509.238 370.114,509.734 368.42,509.93L366.455,510.158L368.607,512.31C373.156,516.859 370.162,523.578 363.586,523.578C361.003,523.578 360.061,523.188 358.814,521.601C356.557,518.733 356.743,514.549 359.236,512.055L361.214,510.078L252.126,510.078L254.067,512.385C257.424,516.374 256.406,521.361 251.848,523.266C248.974,524.467 248.748,524.48 246.527,523.584L246.527,523.584ZM236.872,448.39C236.226,441.037 236.179,440.943 233.327,441.217L230.884,441.453L230.66,446.629C230.367,453.396 230.584,453.828 234.281,453.828L237.35,453.828L236.872,448.39ZM382.145,452.14C382.276,451.212 382.271,448.343 382.133,445.765L381.882,441.078L379.321,441.078C377.797,441.078 376.741,441.457 376.713,442.015C376.688,442.531 376.422,445.4 376.122,448.39L375.577,453.828L378.741,453.828C381.452,453.828 381.94,453.586 382.145,452.14L382.145,452.14ZM259.657,416.89C257.329,412.347 247.722,384.745 248.199,383.972C248.838,382.938 253.568,383.228 256.596,384.487C258.129,385.125 260.978,387.213 262.927,389.127L266.469,392.607L267.69,389.685C269.368,385.669 268.642,381.113 264.979,372.667C263.346,368.901 262.009,365.702 262.009,365.559C262.009,365.031 266.222,366.335 268.981,367.717C270.546,368.501 273.171,370.357 274.813,371.84C276.455,373.324 277.979,374.358 278.2,374.137C278.421,373.916 278.788,371.926 279.016,369.714C279.606,364.001 278.071,355.239 275.521,349.765L273.338,345.078L275.736,345.095C279.036,345.117 283.542,347.296 288.745,351.386L293.134,354.835L293.595,347.894C293.848,344.077 294.482,339.266 295.003,337.203C297.012,329.25 302.735,320.283 306.417,319.32C307.661,318.994 307.825,319.2 307.409,320.567C306.407,323.859 306.09,339.537 306.918,344.849C307.381,347.816 308.633,352.599 309.699,355.477C312.217,362.27 318.259,369.521 318.259,365.751C318.259,365.158 319.816,362.399 321.719,359.62C323.622,356.84 325.511,353.613 325.917,352.447C326.324,351.282 326.921,350.328 327.246,350.328C327.57,350.328 328.775,352.775 329.923,355.766C331.664,360.302 332.083,362.784 332.447,370.739L332.884,380.274L335.24,378.779C336.674,377.869 338.651,375.277 340.29,372.159C343.181,366.658 344.94,364.669 345.939,365.773C347.005,366.95 348.259,373.326 348.259,377.568C348.259,379.81 347.717,384.047 347.053,386.984C346.39,389.922 346.042,392.832 346.28,393.451C346.989,395.299 351.945,394.803 356.895,392.389C362.705,389.555 365.901,388.712 365.368,390.154C365.154,390.731 363.916,394.41 362.616,398.328C361.316,402.247 358.651,408.66 356.693,412.578L353.134,419.703L337.179,419.906C324.042,420.072 321.152,419.923 320.82,419.056C320.307,417.72 322.054,413.516 324.832,409.399C327.355,405.661 329.153,401.472 328.51,400.829C328.262,400.581 326.854,401.437 325.381,402.73C322.851,404.952 317.603,407.672 317.003,407.072C316.414,406.483 316.417,394.074 317.006,391.766C317.553,389.62 317.448,389.328 316.126,389.328C314.523,389.328 310.024,393.355 309.986,394.823C309.944,396.434 308.71,393.502 308.172,390.515C307.491,386.734 304.819,379.578 304.087,379.578C303.777,379.578 303.159,381.534 302.715,383.925C302.27,386.316 301.341,389.607 300.65,391.238L299.394,394.203L297.957,391.438C296.552,388.732 293.94,386.329 292.404,386.328C291.949,386.327 291.626,388.555 291.614,391.765C291.603,394.756 291.447,399.735 291.265,402.828C291.083,405.922 291.209,409.246 291.545,410.215C292.097,411.803 291.984,411.928 290.395,411.481C289.426,411.209 287.088,409.319 285.199,407.282C283.31,405.245 281.49,403.578 281.155,403.578C280.82,403.578 280.722,407.291 280.937,411.828L281.328,420.078L261.289,420.078L259.657,416.89Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.16459,0,0,1.16459,-742.785,-1815.19)">
|
||||||
|
<path d="M5218.36,9745.11C5176.99,9741.09 5135.32,9741.09 5093.95,9745.11L5073.45,9844.87C5033.8,9850.94 4994.93,9861.36 4957.55,9875.93L4889.93,9799.78C4852.09,9816.99 4816,9837.82 4782.18,9861.99L4814.31,9958.63C4783,9983.71 4754.55,10012.2 4729.47,10043.5L4632.83,10011.3C4608.66,10045.2 4587.83,10081.2 4570.62,10119.1L4646.77,10186.7C4632.19,10224.1 4621.78,10263 4615.71,10302.6L4515.95,10323.1C4511.93,10364.5 4511.93,10406.1 4515.95,10447.5L4615.71,10468C4621.78,10507.7 4632.19,10546.5 4646.77,10583.9L4570.62,10651.5C4587.83,10689.4 4608.66,10725.5 4632.83,10759.3L4729.47,10727.2C4754.55,10758.5 4783,10786.9 4814.31,10812L4782.18,10908.6C4816,10932.8 4852.09,10953.6 4889.93,10970.9L4957.55,10894.7C4994.93,10909.3 5033.8,10919.7 5073.45,10925.8L5093.95,11025.5C5135.32,11029.5 5176.99,11029.5 5218.36,11025.5L5238.85,10925.8C5278.51,10919.7 5317.38,10909.3 5354.76,10894.7L5422.38,10970.9C5460.22,10953.6 5496.31,10932.8 5530.13,10908.6L5498,10812C5529.31,10786.9 5557.76,10758.5 5582.84,10727.2L5679.48,10759.3C5703.65,10725.5 5724.48,10689.4 5741.69,10651.5L5665.54,10583.9C5680.11,10546.5 5690.53,10507.7 5696.6,10468L5796.36,10447.5C5800.38,10406.1 5800.38,10364.5 5796.36,10323.1L5696.6,10302.6C5690.53,10263 5680.11,10224.1 5665.54,10186.7L5741.69,10119.1C5724.48,10081.2 5703.65,10045.2 5679.48,10011.3L5582.84,10043.5C5557.76,10012.2 5529.31,9983.71 5498,9958.63L5530.13,9861.99C5496.31,9837.82 5460.22,9816.99 5422.38,9799.78L5354.76,9875.93C5317.38,9861.36 5278.51,9850.94 5238.85,9844.87L5218.36,9745.11ZM5156.15,9906.23C5420.57,9906.23 5635.24,10120.9 5635.24,10385.3C5635.24,10649.7 5420.57,10864.4 5156.15,10864.4C4891.74,10864.4 4677.06,10649.7 4677.06,10385.3C4677.06,10120.9 4891.74,9906.23 5156.15,9906.23Z" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.8 KiB |
BIN
client/static/dumpsterFireLogo.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
15
client/static/dumpsterFireLogo.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 1581 1581" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1,0,0,1,-4471.55,-9488.98)">
|
||||||
|
<g transform="matrix(0.887399,0,0,0.887399,718.244,1171.17)">
|
||||||
|
<circle cx="5120.33" cy="10264" r="890.771" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
<g id="path314" transform="matrix(4.16667,0,0,4.16667,3984.38,8522.36)">
|
||||||
|
<path d="M246.527,523.584C241.806,521.679 241.171,514.32 245.444,511.039C246.467,510.254 246.341,510.122 244.548,510.102C243.401,510.087 242.325,509.656 242.158,509.14C241.991,508.625 241.291,500.947 240.601,492.078C239.911,483.209 238.962,471.903 238.49,466.953L237.633,457.953L233.545,457.454C228.061,456.784 227.509,455.868 227.509,447.426C227.509,439.625 228.227,438.354 233.079,437.566C235.783,437.128 235.831,437.06 235.61,434.036L235.384,430.953L231.072,430.728L226.759,430.503L226.759,423.828L386.509,423.828L386.509,430.578L382.009,430.578C377.537,430.578 377.509,430.592 377.509,432.766C377.509,433.97 377.322,435.442 377.094,436.037C376.785,436.842 377.503,437.23 379.899,437.551C384.754,438.202 385.759,439.92 385.759,447.57C385.759,453.286 385.582,454.073 383.918,455.737C382.504,457.151 381.288,457.578 378.668,457.578C376.793,457.578 375.263,457.831 375.269,458.14C375.303,460.077 371.397,503.577 370.942,506.328C370.461,509.238 370.114,509.734 368.42,509.93L366.455,510.158L368.607,512.31C373.156,516.859 370.162,523.578 363.586,523.578C361.003,523.578 360.061,523.188 358.814,521.601C356.557,518.733 356.743,514.549 359.236,512.055L361.214,510.078L252.126,510.078L254.067,512.385C257.424,516.374 256.406,521.361 251.848,523.266C248.974,524.467 248.748,524.48 246.527,523.584L246.527,523.584ZM236.872,448.39C236.226,441.037 236.179,440.943 233.327,441.217L230.884,441.453L230.66,446.629C230.367,453.396 230.584,453.828 234.281,453.828L237.35,453.828L236.872,448.39ZM382.145,452.14C382.276,451.212 382.271,448.343 382.133,445.765L381.882,441.078L379.321,441.078C377.797,441.078 376.741,441.457 376.713,442.015C376.688,442.531 376.422,445.4 376.122,448.39L375.577,453.828L378.741,453.828C381.452,453.828 381.94,453.586 382.145,452.14L382.145,452.14ZM259.657,416.89C257.329,412.347 247.722,384.745 248.199,383.972C248.838,382.938 253.568,383.228 256.596,384.487C258.129,385.125 260.978,387.213 262.927,389.127L266.469,392.607L267.69,389.685C269.368,385.669 268.642,381.113 264.979,372.667C263.346,368.901 262.009,365.702 262.009,365.559C262.009,365.031 266.222,366.335 268.981,367.717C270.546,368.501 273.171,370.357 274.813,371.84C276.455,373.324 277.979,374.358 278.2,374.137C278.421,373.916 278.788,371.926 279.016,369.714C279.606,364.001 278.071,355.239 275.521,349.765L273.338,345.078L275.736,345.095C279.036,345.117 283.542,347.296 288.745,351.386L293.134,354.835L293.595,347.894C293.848,344.077 294.482,339.266 295.003,337.203C297.012,329.25 302.735,320.283 306.417,319.32C307.661,318.994 307.825,319.2 307.409,320.567C306.407,323.859 306.09,339.537 306.918,344.849C307.381,347.816 308.633,352.599 309.699,355.477C312.217,362.27 318.259,369.521 318.259,365.751C318.259,365.158 319.816,362.399 321.719,359.62C323.622,356.84 325.511,353.613 325.917,352.447C326.324,351.282 326.921,350.328 327.246,350.328C327.57,350.328 328.775,352.775 329.923,355.766C331.664,360.302 332.083,362.784 332.447,370.739L332.884,380.274L335.24,378.779C336.674,377.869 338.651,375.277 340.29,372.159C343.181,366.658 344.94,364.669 345.939,365.773C347.005,366.95 348.259,373.326 348.259,377.568C348.259,379.81 347.717,384.047 347.053,386.984C346.39,389.922 346.042,392.832 346.28,393.451C346.989,395.299 351.945,394.803 356.895,392.389C362.705,389.555 365.901,388.712 365.368,390.154C365.154,390.731 363.916,394.41 362.616,398.328C361.316,402.247 358.651,408.66 356.693,412.578L353.134,419.703L337.179,419.906C324.042,420.072 321.152,419.923 320.82,419.056C320.307,417.72 322.054,413.516 324.832,409.399C327.355,405.661 329.153,401.472 328.51,400.829C328.262,400.581 326.854,401.437 325.381,402.73C322.851,404.952 317.603,407.672 317.003,407.072C316.414,406.483 316.417,394.074 317.006,391.766C317.553,389.62 317.448,389.328 316.126,389.328C314.523,389.328 310.024,393.355 309.986,394.823C309.944,396.434 308.71,393.502 308.172,390.515C307.491,386.734 304.819,379.578 304.087,379.578C303.777,379.578 303.159,381.534 302.715,383.925C302.27,386.316 301.341,389.607 300.65,391.238L299.394,394.203L297.957,391.438C296.552,388.732 293.94,386.329 292.404,386.328C291.949,386.327 291.626,388.555 291.614,391.765C291.603,394.756 291.447,399.735 291.265,402.828C291.083,405.922 291.209,409.246 291.545,410.215C292.097,411.803 291.984,411.928 290.395,411.481C289.426,411.209 287.088,409.319 285.199,407.282C283.31,405.245 281.49,403.578 281.155,403.578C280.82,403.578 280.722,407.291 280.937,411.828L281.328,420.078L261.289,420.078L259.657,416.89Z" style="fill:rgb(72,61,139);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.16459,0,0,1.16459,-742.785,-1815.19)">
|
||||||
|
<path d="M5218.36,9745.11C5176.99,9741.09 5135.32,9741.09 5093.95,9745.11L5073.45,9844.87C5033.8,9850.94 4994.93,9861.36 4957.55,9875.93L4889.93,9799.78C4852.09,9816.99 4816,9837.82 4782.18,9861.99L4814.31,9958.63C4783,9983.71 4754.55,10012.2 4729.47,10043.5L4632.83,10011.3C4608.66,10045.2 4587.83,10081.2 4570.62,10119.1L4646.77,10186.7C4632.19,10224.1 4621.78,10263 4615.71,10302.6L4515.95,10323.1C4511.93,10364.5 4511.93,10406.1 4515.95,10447.5L4615.71,10468C4621.78,10507.7 4632.19,10546.5 4646.77,10583.9L4570.62,10651.5C4587.83,10689.4 4608.66,10725.5 4632.83,10759.3L4729.47,10727.2C4754.55,10758.5 4783,10786.9 4814.31,10812L4782.18,10908.6C4816,10932.8 4852.09,10953.6 4889.93,10970.9L4957.55,10894.7C4994.93,10909.3 5033.8,10919.7 5073.45,10925.8L5093.95,11025.5C5135.32,11029.5 5176.99,11029.5 5218.36,11025.5L5238.85,10925.8C5278.51,10919.7 5317.38,10909.3 5354.76,10894.7L5422.38,10970.9C5460.22,10953.6 5496.31,10932.8 5530.13,10908.6L5498,10812C5529.31,10786.9 5557.76,10758.5 5582.84,10727.2L5679.48,10759.3C5703.65,10725.5 5724.48,10689.4 5741.69,10651.5L5665.54,10583.9C5680.11,10546.5 5690.53,10507.7 5696.6,10468L5796.36,10447.5C5800.38,10406.1 5800.38,10364.5 5796.36,10323.1L5696.6,10302.6C5690.53,10263 5680.11,10224.1 5665.54,10186.7L5741.69,10119.1C5724.48,10081.2 5703.65,10045.2 5679.48,10011.3L5582.84,10043.5C5557.76,10012.2 5529.31,9983.71 5498,9958.63L5530.13,9861.99C5496.31,9837.82 5460.22,9816.99 5422.38,9799.78L5354.76,9875.93C5317.38,9861.36 5278.51,9850.94 5238.85,9844.87L5218.36,9745.11ZM5156.15,9906.23C5420.57,9906.23 5635.24,10120.9 5635.24,10385.3C5635.24,10649.7 5420.57,10864.4 5156.15,10864.4C4891.74,10864.4 4677.06,10649.7 4677.06,10385.3C4677.06,10120.9 4891.74,9906.23 5156.15,9906.23Z" style="fill:rgb(72,61,139);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7 KiB |
BIN
client/static/dumpsterFireLogoWhite.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.8 KiB |
|
|
@ -1,41 +1,12 @@
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
viewBox="0 0 1235.7 1235.4" style="enable-background:new 0 0 6702.7 1277.4;" xml:space="preserve">
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
<style type="text/css">
|
<svg width="100%" height="100%" viewBox="0 0 1499 1499" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
.st0{fill:#FFFFFF;}
|
<g transform="matrix(1,0,0,1,-4512.94,-9530.37)">
|
||||||
.st1{fill:url(#SVGID_1_);}
|
<g id="path314" transform="matrix(4.16667,0,0,4.16667,3984.38,8522.36)">
|
||||||
.st2{fill:#C9C9C9;}
|
<path d="M246.527,523.584C241.806,521.679 241.171,514.32 245.444,511.039C246.467,510.254 246.341,510.122 244.548,510.102C243.401,510.087 242.325,509.656 242.158,509.14C241.991,508.625 241.291,500.947 240.601,492.078C239.911,483.209 238.962,471.903 238.49,466.953L237.633,457.953L233.545,457.454C228.061,456.784 227.509,455.868 227.509,447.426C227.509,439.625 228.227,438.354 233.079,437.566C235.783,437.128 235.831,437.06 235.61,434.036L235.384,430.953L231.072,430.728L226.759,430.503L226.759,423.828L386.509,423.828L386.509,430.578L382.009,430.578C377.537,430.578 377.509,430.592 377.509,432.766C377.509,433.97 377.322,435.442 377.094,436.037C376.785,436.842 377.503,437.23 379.899,437.551C384.754,438.202 385.759,439.92 385.759,447.57C385.759,453.286 385.582,454.073 383.918,455.737C382.504,457.151 381.288,457.578 378.668,457.578C376.793,457.578 375.263,457.831 375.269,458.14C375.303,460.077 371.397,503.577 370.942,506.328C370.461,509.238 370.114,509.734 368.42,509.93L366.455,510.158L368.607,512.31C373.156,516.859 370.162,523.578 363.586,523.578C361.003,523.578 360.061,523.188 358.814,521.601C356.557,518.733 356.743,514.549 359.236,512.055L361.214,510.078L252.126,510.078L254.067,512.385C257.424,516.374 256.406,521.361 251.848,523.266C248.974,524.467 248.748,524.48 246.527,523.584L246.527,523.584ZM236.872,448.39C236.226,441.037 236.179,440.943 233.327,441.217L230.884,441.453L230.66,446.629C230.367,453.396 230.584,453.828 234.281,453.828L237.35,453.828L236.872,448.39ZM382.145,452.14C382.276,451.212 382.271,448.343 382.133,445.765L381.882,441.078L379.321,441.078C377.797,441.078 376.741,441.457 376.713,442.015C376.688,442.531 376.422,445.4 376.122,448.39L375.577,453.828L378.741,453.828C381.452,453.828 381.94,453.586 382.145,452.14L382.145,452.14ZM259.657,416.89C257.329,412.347 247.722,384.745 248.199,383.972C248.838,382.938 253.568,383.228 256.596,384.487C258.129,385.125 260.978,387.213 262.927,389.127L266.469,392.607L267.69,389.685C269.368,385.669 268.642,381.113 264.979,372.667C263.346,368.901 262.009,365.702 262.009,365.559C262.009,365.031 266.222,366.335 268.981,367.717C270.546,368.501 273.171,370.357 274.813,371.84C276.455,373.324 277.979,374.358 278.2,374.137C278.421,373.916 278.788,371.926 279.016,369.714C279.606,364.001 278.071,355.239 275.521,349.765L273.338,345.078L275.736,345.095C279.036,345.117 283.542,347.296 288.745,351.386L293.134,354.835L293.595,347.894C293.848,344.077 294.482,339.266 295.003,337.203C297.012,329.25 302.735,320.283 306.417,319.32C307.661,318.994 307.825,319.2 307.409,320.567C306.407,323.859 306.09,339.537 306.918,344.849C307.381,347.816 308.633,352.599 309.699,355.477C312.217,362.27 318.259,369.521 318.259,365.751C318.259,365.158 319.816,362.399 321.719,359.62C323.622,356.84 325.511,353.613 325.917,352.447C326.324,351.282 326.921,350.328 327.246,350.328C327.57,350.328 328.775,352.775 329.923,355.766C331.664,360.302 332.083,362.784 332.447,370.739L332.884,380.274L335.24,378.779C336.674,377.869 338.651,375.277 340.29,372.159C343.181,366.658 344.94,364.669 345.939,365.773C347.005,366.95 348.259,373.326 348.259,377.568C348.259,379.81 347.717,384.047 347.053,386.984C346.39,389.922 346.042,392.832 346.28,393.451C346.989,395.299 351.945,394.803 356.895,392.389C362.705,389.555 365.901,388.712 365.368,390.154C365.154,390.731 363.916,394.41 362.616,398.328C361.316,402.247 358.651,408.66 356.693,412.578L353.134,419.703L337.179,419.906C324.042,420.072 321.152,419.923 320.82,419.056C320.307,417.72 322.054,413.516 324.832,409.399C327.355,405.661 329.153,401.472 328.51,400.829C328.262,400.581 326.854,401.437 325.381,402.73C322.851,404.952 317.603,407.672 317.003,407.072C316.414,406.483 316.417,394.074 317.006,391.766C317.553,389.62 317.448,389.328 316.126,389.328C314.523,389.328 310.024,393.355 309.986,394.823C309.944,396.434 308.71,393.502 308.172,390.515C307.491,386.734 304.819,379.578 304.087,379.578C303.777,379.578 303.159,381.534 302.715,383.925C302.27,386.316 301.341,389.607 300.65,391.238L299.394,394.203L297.957,391.438C296.552,388.732 293.94,386.329 292.404,386.328C291.949,386.327 291.626,388.555 291.614,391.765C291.603,394.756 291.447,399.735 291.265,402.828C291.083,405.922 291.209,409.246 291.545,410.215C292.097,411.803 291.984,411.928 290.395,411.481C289.426,411.209 287.088,409.319 285.199,407.282C283.31,405.245 281.49,403.578 281.155,403.578C280.82,403.578 280.722,407.291 280.937,411.828L281.328,420.078L261.289,420.078L259.657,416.89Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
.st3{font-family:'GentiumBookBasic';}
|
</g>
|
||||||
.st4{font-size:800px;}
|
<g transform="matrix(1.16459,0,0,1.16459,-742.785,-1815.19)">
|
||||||
.st5{fill:#474747;}
|
<path d="M5218.36,9745.11C5176.99,9741.09 5135.32,9741.09 5093.95,9745.11L5073.45,9844.87C5033.8,9850.94 4994.93,9861.36 4957.55,9875.93L4889.93,9799.78C4852.09,9816.99 4816,9837.82 4782.18,9861.99L4814.31,9958.63C4783,9983.71 4754.55,10012.2 4729.47,10043.5L4632.83,10011.3C4608.66,10045.2 4587.83,10081.2 4570.62,10119.1L4646.77,10186.7C4632.19,10224.1 4621.78,10263 4615.71,10302.6L4515.95,10323.1C4511.93,10364.5 4511.93,10406.1 4515.95,10447.5L4615.71,10468C4621.78,10507.7 4632.19,10546.5 4646.77,10583.9L4570.62,10651.5C4587.83,10689.4 4608.66,10725.5 4632.83,10759.3L4729.47,10727.2C4754.55,10758.5 4783,10786.9 4814.31,10812L4782.18,10908.6C4816,10932.8 4852.09,10953.6 4889.93,10970.9L4957.55,10894.7C4994.93,10909.3 5033.8,10919.7 5073.45,10925.8L5093.95,11025.5C5135.32,11029.5 5176.99,11029.5 5218.36,11025.5L5238.85,10925.8C5278.51,10919.7 5317.38,10909.3 5354.76,10894.7L5422.38,10970.9C5460.22,10953.6 5496.31,10932.8 5530.13,10908.6L5498,10812C5529.31,10786.9 5557.76,10758.5 5582.84,10727.2L5679.48,10759.3C5703.65,10725.5 5724.48,10689.4 5741.69,10651.5L5665.54,10583.9C5680.11,10546.5 5690.53,10507.7 5696.6,10468L5796.36,10447.5C5800.38,10406.1 5800.38,10364.5 5796.36,10323.1L5696.6,10302.6C5690.53,10263 5680.11,10224.1 5665.54,10186.7L5741.69,10119.1C5724.48,10081.2 5703.65,10045.2 5679.48,10011.3L5582.84,10043.5C5557.76,10012.2 5529.31,9983.71 5498,9958.63L5530.13,9861.99C5496.31,9837.82 5460.22,9816.99 5422.38,9799.78L5354.76,9875.93C5317.38,9861.36 5278.51,9850.94 5238.85,9844.87L5218.36,9745.11ZM5156.15,9906.23C5420.57,9906.23 5635.24,10120.9 5635.24,10385.3C5635.24,10649.7 5420.57,10864.4 5156.15,10864.4C4891.74,10864.4 4677.06,10649.7 4677.06,10385.3C4677.06,10120.9 4891.74,9906.23 5156.15,9906.23Z" style="fill:white;"/>
|
||||||
</style>
|
</g>
|
||||||
<title>bgAsset 6</title>
|
</g>
|
||||||
<g id="Layer_2_1_">
|
|
||||||
<g id="Layer_2-2">
|
|
||||||
<g id="Layer_4">
|
|
||||||
<g id="Layer_5">
|
|
||||||
<circle class="st0" cx="618.6" cy="618.6" r="618.6"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="617.37" y1="1257.3" x2="617.37" y2="61.4399" gradientTransform="matrix(1 0 0 -1 0 1278)">
|
|
||||||
<stop offset="0.32" style="stop-color:#CD9D49"/>
|
|
||||||
<stop offset="0.99" style="stop-color:#875D27"/>
|
|
||||||
</linearGradient>
|
|
||||||
<circle class="st1" cx="617.4" cy="618.6" r="597.9"/>
|
|
||||||
</g>
|
|
||||||
<path class="st0" d="M1005.6,574.1c-4.8-4-12.4-10-22.6-17v-79.2c0-201.9-163.7-365.6-365.6-365.6l0,0
|
|
||||||
c-201.9,0-365.6,163.7-365.6,365.6v79.2c-10.2,7-17.7,13-22.6,17c-4.1,3.4-6.5,8.5-6.5,13.9v94.9c0,5.4,2.4,10.5,6.5,14
|
|
||||||
c11.3,9.4,37.2,29.1,77.5,49.3v9.2c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45V527.8c0-24.9-16-45-35.8-45l0,0
|
|
||||||
c-19,0-34.5,18.5-35.8,41.9h-0.1v-46.9c0-171.6,139.1-310.7,310.7-310.7l0,0C789,167.2,928,306.3,928,477.9v46.9H928
|
|
||||||
c-1.3-23.4-16.8-41.9-35.8-41.9l0,0c-19.8,0-35.8,20.2-35.8,45v227.6c0,24.9,16,45,35.8,45l0,0c19.8,0,35.8-20.2,35.8-45v-9.2
|
|
||||||
c40.3-20.2,66.2-39.9,77.5-49.3c4.2-3.5,6.5-8.6,6.5-14V588C1012.1,582.6,1009.7,577.5,1005.6,574.1z"/>
|
|
||||||
<path class="st0" d="M489.9,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
|
||||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L489.9,969.7z M418.2,514.6h98.7v10.3h-98.7V514.6z"/>
|
|
||||||
<path class="st0" d="M639.7,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3H595c-23.9,0-43.3,19.4-43.3,43.3
|
|
||||||
v484.8c0,23.9,19.4,43.3,43.3,43.3H639.7z M568,514.6h98.7v10.3H568V514.6z"/>
|
|
||||||
<path class="st0" d="M789.6,969.7c23.9,0,43.3-19.4,43.3-43.3V441.6c0-23.9-19.4-43.3-43.3-43.3h-44.7
|
|
||||||
c-23.9,0-43.3,19.4-43.3,43.3v484.8c0,23.9,19.4,43.3,43.3,43.3L789.6,969.7z M717.9,514.6h98.7v10.3h-98.7V514.6z"/>
|
|
||||||
<path class="st0" d="M327.1,984.7h580.5c18,0,32.6,14.6,32.6,32.6v0c0,18-14.6,32.6-32.6,32.6H327.1c-18,0-32.6-14.6-32.6-32.6v0
|
|
||||||
C294.5,999.3,309.1,984.7,327.1,984.7z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 6.8 KiB |
|
|
@ -36,6 +36,10 @@ export const getters = {
|
||||||
if (!state.serverSettings) return null
|
if (!state.serverSettings) return null
|
||||||
return state.serverSettings[key]
|
return state.serverSettings[key]
|
||||||
},
|
},
|
||||||
|
getServerStyling: (state) => {
|
||||||
|
if (!state.serverSettings?.styling) return null
|
||||||
|
return state.serverSettings.styling
|
||||||
|
},
|
||||||
getLibraryItemIdStreaming: (state) => {
|
getLibraryItemIdStreaming: (state) => {
|
||||||
return state.streamLibraryItem?.id || null
|
return state.streamLibraryItem?.id || null
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1103,5 +1103,31 @@
|
||||||
"ToastUserPasswordChangeSuccess": "Password changed successfully",
|
"ToastUserPasswordChangeSuccess": "Password changed successfully",
|
||||||
"ToastUserPasswordMismatch": "Passwords do not match",
|
"ToastUserPasswordMismatch": "Passwords do not match",
|
||||||
"ToastUserPasswordMustChange": "New password cannot match old password",
|
"ToastUserPasswordMustChange": "New password cannot match old password",
|
||||||
"ToastUserRootRequireName": "Must enter a root username"
|
"ToastUserRootRequireName": "Must enter a root username",
|
||||||
|
"LabelComments": "Comments",
|
||||||
|
"PlaceholderAddComment": "Add a rating... (Ctrl+Enter to post)",
|
||||||
|
"ButtonPost": "Post",
|
||||||
|
"MessageNoComments": "No rating yet. Be the first to rate!",
|
||||||
|
"MessageCommentAdded": "Rating added successfully",
|
||||||
|
"MessageCommentUpdated": "Rating updated successfully",
|
||||||
|
"MessageCommentDeleted": "Rating deleted successfully",
|
||||||
|
"ErrorLoadingComments": "Error loading ratings",
|
||||||
|
"ErrorAddingComment": "Error adding ratings",
|
||||||
|
"ErrorUpdatingComment": "Error updating ratings",
|
||||||
|
"ErrorDeletingComment": "Error deleting ratings",
|
||||||
|
"ConfirmDeleteComment": "Are you sure you want to delete this rating?",
|
||||||
|
"LabelRating": "Rating",
|
||||||
|
"LabelStarRating": "{0} stars",
|
||||||
|
"LabelNoRating": "No rating",
|
||||||
|
"LabelAverageRating": "Average rating: {0}",
|
||||||
|
"HeaderStyling": "Styling",
|
||||||
|
"HeaderStylingColors": "Colors",
|
||||||
|
"HeaderStylingPreview": "Preview",
|
||||||
|
"LabelBackgroundColor": "Background Color",
|
||||||
|
"LabelErrorColor": "Error Color",
|
||||||
|
"LabelInfoColor": "Info Color",
|
||||||
|
"LabelPrimaryColor": "Primary Color",
|
||||||
|
"LabelPrimaryDarkColor": "Primary Dark Color",
|
||||||
|
"LabelSuccessColor": "Success Color",
|
||||||
|
"LabelWarningColor": "Warning Color"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
client/strings/en.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"HeaderServerStyling": "Server Styling",
|
||||||
|
"DescriptionServerStyling": "Customize server appearance",
|
||||||
|
"HeaderSettings": "Settings",
|
||||||
|
"HeaderLibraries": "Libraries",
|
||||||
|
"HeaderUsers": "Users",
|
||||||
|
"HeaderListeningSessions": "Listening Sessions",
|
||||||
|
"HeaderBackups": "Backups",
|
||||||
|
"HeaderLogs": "Logs",
|
||||||
|
"HeaderNotifications": "Notifications",
|
||||||
|
"HeaderEmail": "Email",
|
||||||
|
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
||||||
|
"HeaderRSSFeeds": "RSS Feeds",
|
||||||
|
"HeaderAuthentication": "Authentication",
|
||||||
|
"HeaderLibraryStats": "Library Stats",
|
||||||
|
"HeaderYourStats": "Your Stats",
|
||||||
|
"DescriptionAuthentication": "Configure authentication methods and settings",
|
||||||
|
"DescriptionLibraries": "Manage your libraries",
|
||||||
|
"DescriptionBackups": "Configure server backups",
|
||||||
|
"DescriptionEmail": "Configure email settings",
|
||||||
|
"DescriptionNotifications": "Configure notification settings",
|
||||||
|
"DescriptionServerLogs": "View server logs",
|
||||||
|
"LabelSelectAll": "Select All",
|
||||||
|
"LabelComments": "Comments",
|
||||||
|
"PlaceholderAddComment": "Add a comment... (Ctrl+Enter to post)",
|
||||||
|
"ButtonPost": "Post",
|
||||||
|
"ButtonEdit": "Edit",
|
||||||
|
"ButtonDelete": "Delete",
|
||||||
|
"ButtonCancel": "Cancel",
|
||||||
|
"ButtonSave": "Save",
|
||||||
|
"MessageNoComments": "No comments yet. Be the first to comment!",
|
||||||
|
"MessageCommentAdded": "Comment added successfully",
|
||||||
|
"MessageCommentUpdated": "Comment updated successfully",
|
||||||
|
"MessageCommentDeleted": "Comment deleted successfully",
|
||||||
|
"ErrorLoadingComments": "Error loading comments",
|
||||||
|
"ErrorAddingComment": "Error adding comment",
|
||||||
|
"ErrorUpdatingComment": "Error updating comment",
|
||||||
|
"ErrorDeletingComment": "Error deleting comment",
|
||||||
|
"ConfirmDeleteComment": "Are you sure you want to delete this comment?"
|
||||||
|
}
|
||||||
51
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
audiobookshelf:
|
||||||
|
build:
|
||||||
|
context: https://github.com/zipben/audiobookshelf.git#workingitout
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: audiobookshelf
|
||||||
|
ports:
|
||||||
|
- "7696:80" # The app will be available on port 7696
|
||||||
|
volumes:
|
||||||
|
# Named volumes for persistent data
|
||||||
|
- audiobooks:/audiobooks
|
||||||
|
- podcasts:/podcasts
|
||||||
|
- metadata:/metadata
|
||||||
|
- config:/config
|
||||||
|
# Mount point for external libraries - customize this path in Portainer
|
||||||
|
- type: bind
|
||||||
|
source: /path/to/your/libraries
|
||||||
|
target: /libraries
|
||||||
|
environment:
|
||||||
|
- TZ=America/New_York # Set your timezone
|
||||||
|
- PUID=1000 # User ID for permissions
|
||||||
|
- PGID=1000 # Group ID for permissions
|
||||||
|
# Optional environment variables
|
||||||
|
- PORT=80 # Internal port (don't change unless necessary)
|
||||||
|
- HOST=0.0.0.0 # Listen on all interfaces
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- abs_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "http://localhost:80/ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
abs_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Define named volumes - these will be created automatically
|
||||||
|
audiobooks:
|
||||||
|
name: audiobookshelf_audiobooks
|
||||||
|
podcasts:
|
||||||
|
name: audiobookshelf_podcasts
|
||||||
|
metadata:
|
||||||
|
name: audiobookshelf_metadata
|
||||||
|
config:
|
||||||
|
name: audiobookshelf_config
|
||||||
100
package-lock.json
generated
|
|
@ -185,15 +185,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.23.3",
|
"version": "7.27.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
|
||||||
"integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==",
|
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.23.3",
|
"@babel/parser": "^7.27.5",
|
||||||
"@jridgewell/gen-mapping": "^0.3.2",
|
"@babel/types": "^7.27.3",
|
||||||
"@jridgewell/trace-mapping": "^0.3.17",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
"jsesc": "^2.5.1"
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
|
"jsesc": "^3.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -329,18 +330,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.22.5",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.22.20",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -425,10 +426,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.23.3",
|
"version": "7.27.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
|
||||||
"integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==",
|
"integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.27.3"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
},
|
},
|
||||||
|
|
@ -495,14 +499,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.23.3",
|
"version": "7.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz",
|
||||||
"integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==",
|
"integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.22.5",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.22.20",
|
"@babel/helper-validator-identifier": "^7.27.1"
|
||||||
"to-fast-properties": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -540,14 +543,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||||
"integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
|
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/set-array": "^1.0.1",
|
"@jridgewell/set-array": "^1.2.1",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||||
"@jridgewell/trace-mapping": "^0.3.9"
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|
@ -563,9 +566,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/set-array": {
|
"node_modules/@jridgewell/set-array": {
|
||||||
"version": "1.1.2",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||||
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
|
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
|
|
@ -578,9 +581,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||||
"integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
|
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
|
@ -1012,12 +1015,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.0.1"
|
"fill-range": "^7.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
|
@ -1926,9 +1929,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.0.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
|
|
@ -2818,15 +2821,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "2.5.2",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
|
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jsesc": "bin/jsesc"
|
"jsesc": "bin/jsesc"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
|
|
@ -5135,15 +5138,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/to-fast-properties": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.24.0",
|
"version": "2.24.1",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
|
||||||
25
readme.md
|
|
@ -450,3 +450,28 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
|
||||||
# How to Support
|
# How to Support
|
||||||
|
|
||||||
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
### Killing Running Processes
|
||||||
|
When developing locally, you might need to kill running instances of the client or server. Here are some helpful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kill process on port 3000 (client)
|
||||||
|
sudo kill $(lsof -t -i:3000)
|
||||||
|
|
||||||
|
# Kill process on port 3333 (server)
|
||||||
|
sudo kill $(lsof -t -i:3333)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also find and kill processes manually:
|
||||||
|
```bash
|
||||||
|
# List all Node.js processes
|
||||||
|
ps aux | grep node
|
||||||
|
|
||||||
|
# Kill processes by PID
|
||||||
|
kill <PID>
|
||||||
|
|
||||||
|
# Force kill if process won't exit
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,11 @@ class Database {
|
||||||
return this.models.device
|
return this.models.device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/Comment')} */
|
||||||
|
get commentModel() {
|
||||||
|
return this.models.comment
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|
@ -333,6 +338,14 @@ class Database {
|
||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
require('./models/CustomMetadataProvider').init(this.sequelize)
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
require('./models/MediaItemShare').init(this.sequelize)
|
require('./models/MediaItemShare').init(this.sequelize)
|
||||||
|
require('./models/Comment').init(this.sequelize)
|
||||||
|
|
||||||
|
// Set up associations after all models are initialized
|
||||||
|
Object.values(this.sequelize.models).forEach(model => {
|
||||||
|
if (typeof model.associate === 'function') {
|
||||||
|
model.associate(this.sequelize.models)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,18 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include comments
|
||||||
|
const comments = await Database.commentModel.findAll({
|
||||||
|
where: { libraryItemId: req.params.id },
|
||||||
|
include: [{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username', 'displayName']
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
item.comments = comments
|
||||||
|
|
||||||
return res.json(item)
|
return res.json(item)
|
||||||
}
|
}
|
||||||
res.json(req.libraryItem.toOldJSON())
|
res.json(req.libraryItem.toOldJSON())
|
||||||
|
|
@ -1158,7 +1170,6 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
|
|
@ -1181,6 +1192,11 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow comment operations for all authenticated users
|
||||||
|
if (req.path.includes('/comments')) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
if (req.path.includes('/play')) {
|
if (req.path.includes('/play')) {
|
||||||
// allow POST requests using /play and /play/:episodeId
|
// allow POST requests using /play and /play/:episodeId
|
||||||
} else if (req.method == 'DELETE' && !req.user.canDelete) {
|
} else if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
|
@ -1193,5 +1209,137 @@ class LibraryItemController {
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/items/:id/comments
|
||||||
|
* Get comments for a library item
|
||||||
|
*
|
||||||
|
* @param {LibraryItemControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getComments(req, res) {
|
||||||
|
try {
|
||||||
|
const comments = await Database.commentModel.findAll({
|
||||||
|
where: { libraryItemId: req.params.id },
|
||||||
|
include: [{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username', 'displayName']
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
res.json(comments)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LibraryItemController] getComments error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to get comments' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/items/:id/comments
|
||||||
|
* Add a comment to a library item
|
||||||
|
*
|
||||||
|
* @param {LibraryItemControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async addComment(req, res) {
|
||||||
|
try {
|
||||||
|
// Check if user already has a comment for this item
|
||||||
|
const existingComment = await Database.commentModel.findOne({
|
||||||
|
where: {
|
||||||
|
libraryItemId: req.params.id,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingComment) {
|
||||||
|
return res.status(400).json({ error: 'You have already submitted a review for this item' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await Database.commentModel.create({
|
||||||
|
text: req.body.text,
|
||||||
|
rating: req.body.rating,
|
||||||
|
libraryItemId: req.params.id,
|
||||||
|
userId: req.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const commentWithUser = await Database.commentModel.findByPk(comment.id, {
|
||||||
|
include: [{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username', 'displayName']
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(commentWithUser)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LibraryItemController] addComment error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to add comment' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH: /api/items/:id/comments/:commentId
|
||||||
|
* Update a comment
|
||||||
|
*
|
||||||
|
* @param {LibraryItemControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async updateComment(req, res) {
|
||||||
|
try {
|
||||||
|
const comment = await Database.commentModel.findByPk(req.params.commentId, {
|
||||||
|
include: [{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username', 'displayName']
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow comment owner or admin to update
|
||||||
|
if (comment.userId !== req.user.id && !req.user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to update this comment' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.update({
|
||||||
|
text: req.body.text,
|
||||||
|
rating: req.body.rating
|
||||||
|
})
|
||||||
|
res.json(comment)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LibraryItemController] updateComment error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to update comment' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: /api/items/:id/comments/:commentId
|
||||||
|
* Delete a comment
|
||||||
|
*
|
||||||
|
* @param {LibraryItemControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async deleteComment(req, res) {
|
||||||
|
try {
|
||||||
|
const comment = await Database.commentModel.findByPk(req.params.commentId)
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow comment owner or admin to delete
|
||||||
|
if (comment.userId !== req.user.id && !req.user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Not authorized to delete this comment' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.destroy()
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[LibraryItemController] deleteComment error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to delete comment' })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new LibraryItemController()
|
module.exports = new LibraryItemController()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ class MeController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
getCurrentUser(req, res) {
|
getCurrentUser(req, res) {
|
||||||
res.json(req.user.toOldJSONForBrowser())
|
const userJson = req.user.toOldJSONForBrowser()
|
||||||
|
Logger.info('[MeController] getCurrentUser response:', {
|
||||||
|
user: userJson
|
||||||
|
})
|
||||||
|
res.json(userJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -50,58 +50,30 @@ class UserController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/users/:id
|
* GET: /api/users/:id
|
||||||
* Get a single user toJSONForBrowser
|
* Get a single user
|
||||||
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
|
|
||||||
*
|
*
|
||||||
* @param {UserControllerRequest} req
|
* @param {UserControllerRequest} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
const user = req.reqUser
|
||||||
Logger.error(`Non-admin user "${req.user.username}" attempted to get user`)
|
const hideRootToken = !req.user.isRoot
|
||||||
return res.sendStatus(403)
|
|
||||||
|
// If requesting user is not admin and not the user themselves, return limited public data
|
||||||
|
if (!req.user.isAdminOrUp && req.user.id !== user.id) {
|
||||||
|
const publicUserData = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
type: user.type,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt
|
||||||
|
}
|
||||||
|
return res.json(publicUserData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user media progress with associated mediaItem
|
res.json(user.toOldJSONForBrowser(hideRootToken))
|
||||||
const mediaProgresses = await Database.mediaProgressModel.findAll({
|
|
||||||
where: {
|
|
||||||
userId: req.reqUser.id
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Database.bookModel,
|
|
||||||
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Database.podcastEpisodeModel,
|
|
||||||
attributes: ['id', 'title'],
|
|
||||||
include: {
|
|
||||||
model: Database.podcastModel,
|
|
||||||
attributes: ['id', 'title', 'coverPath', 'updatedAt']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const oldMediaProgresses = mediaProgresses.map((mp) => {
|
|
||||||
const oldMediaProgress = mp.getOldMediaProgress()
|
|
||||||
oldMediaProgress.displayTitle = mp.mediaItem?.title
|
|
||||||
if (mp.mediaItem?.podcast) {
|
|
||||||
oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title
|
|
||||||
oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath
|
|
||||||
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt
|
|
||||||
} else if (mp.mediaItem) {
|
|
||||||
oldMediaProgress.coverPath = mp.mediaItem.coverPath
|
|
||||||
oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt
|
|
||||||
}
|
|
||||||
return oldMediaProgress
|
|
||||||
})
|
|
||||||
|
|
||||||
const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot)
|
|
||||||
|
|
||||||
userJson.mediaProgress = oldMediaProgresses
|
|
||||||
|
|
||||||
res.json(userJson)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -172,6 +144,7 @@ class UserController {
|
||||||
type: userType,
|
type: userType,
|
||||||
username: req.body.username,
|
username: req.body.username,
|
||||||
email: typeof req.body.email === 'string' ? req.body.email : null,
|
email: typeof req.body.email === 'string' ? req.body.email : null,
|
||||||
|
displayName: typeof req.body.displayName === 'string' ? req.body.displayName : null,
|
||||||
pash,
|
pash,
|
||||||
token,
|
token,
|
||||||
isActive: !!req.body.isActive,
|
isActive: !!req.body.isActive,
|
||||||
|
|
@ -203,6 +176,13 @@ class UserController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
|
Logger.info('[UserController] Update request:', {
|
||||||
|
params: req.params,
|
||||||
|
body: req.body,
|
||||||
|
user: req.user?.username,
|
||||||
|
reqUser: req.reqUser?.username
|
||||||
|
})
|
||||||
|
|
||||||
const user = req.reqUser
|
const user = req.reqUser
|
||||||
|
|
||||||
if (user.isRoot && !req.user.isRoot) {
|
if (user.isRoot && !req.user.isRoot) {
|
||||||
|
|
@ -228,6 +208,9 @@ class UserController {
|
||||||
if (updatePayload.username && typeof updatePayload.username !== 'string') {
|
if (updatePayload.username && typeof updatePayload.username !== 'string') {
|
||||||
return res.status(400).send('Invalid username')
|
return res.status(400).send('Invalid username')
|
||||||
}
|
}
|
||||||
|
if (updatePayload.displayName && typeof updatePayload.displayName !== 'string') {
|
||||||
|
return res.status(400).send('Invalid display name')
|
||||||
|
}
|
||||||
if (updatePayload.type && !Database.userModel.accountTypes.includes(updatePayload.type)) {
|
if (updatePayload.type && !Database.userModel.accountTypes.includes(updatePayload.type)) {
|
||||||
return res.status(400).send('Invalid account type')
|
return res.status(400).send('Invalid account type')
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +305,10 @@ class UserController {
|
||||||
user.lastSeen = updatePayload.lastSeen
|
user.lastSeen = updatePayload.lastSeen
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
if (updatePayload.displayName !== undefined) {
|
||||||
|
user.displayName = updatePayload.displayName
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
if (shouldUpdateToken) {
|
if (shouldUpdateToken) {
|
||||||
|
|
@ -474,9 +461,19 @@ class UserController {
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
*/
|
*/
|
||||||
async middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
Logger.info('[UserController] Middleware request:', {
|
||||||
return res.sendStatus(403)
|
method: req.method,
|
||||||
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) {
|
path: req.path,
|
||||||
|
params: req.params,
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username
|
||||||
|
})
|
||||||
|
|
||||||
|
// For GET requests, allow viewing any user's public profile
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
// Continue to next middleware
|
||||||
|
} else if (!req.user.isAdminOrUp && req.user.id !== req.params.id) {
|
||||||
|
// For non-GET requests, require admin or self
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,5 +486,72 @@ class UserController {
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/users/:id/reviews
|
||||||
|
* Get reviews for a user with associated library items
|
||||||
|
*
|
||||||
|
* @param {UserControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getUserReviews(req, res) {
|
||||||
|
try {
|
||||||
|
// First get all reviews for the user
|
||||||
|
const reviews = await Database.commentModel.findAll({
|
||||||
|
where: { userId: req.params.id },
|
||||||
|
include: [{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username', 'displayName']
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all libraryItemIds from the reviews
|
||||||
|
const libraryItemIds = reviews.map(review => review.libraryItemId)
|
||||||
|
|
||||||
|
// Get all library items with their books
|
||||||
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
|
where: {
|
||||||
|
id: libraryItemIds
|
||||||
|
},
|
||||||
|
attributes: ['id', 'title', 'mediaType', 'path', 'relPath', 'libraryId', 'libraryFolderId', 'isFile', 'updatedAt'],
|
||||||
|
include: [{
|
||||||
|
model: Database.bookModel,
|
||||||
|
as: 'book',
|
||||||
|
attributes: ['id', 'title', 'coverPath']
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a map of library items by id for easy lookup
|
||||||
|
const libraryItemsMap = libraryItems.reduce((map, item) => {
|
||||||
|
map[item.id] = item
|
||||||
|
return map
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
// Merge the data
|
||||||
|
const reviewsWithItems = reviews.map(review => {
|
||||||
|
const reviewJson = review.toJSON()
|
||||||
|
const libraryItem = libraryItemsMap[reviewJson.libraryItemId]
|
||||||
|
|
||||||
|
if (libraryItem) {
|
||||||
|
const libraryItemJson = libraryItem.toJSON()
|
||||||
|
reviewJson.libraryItem = libraryItemJson
|
||||||
|
if (libraryItemJson.book) {
|
||||||
|
reviewJson.libraryItem.media = {
|
||||||
|
coverPath: libraryItemJson.book.coverPath
|
||||||
|
}
|
||||||
|
reviewJson.book = libraryItemJson.book
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reviewJson
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(reviewsWithItems)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[UserController] getUserReviews error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to get user reviews' })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new UserController()
|
module.exports = new UserController()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { notificationData } = require('../utils/notifications')
|
const { notificationData } = require('../utils/notifications')
|
||||||
|
const Json = require('../libs/archiver/lib/plugins/json')
|
||||||
|
|
||||||
class NotificationManager {
|
class NotificationManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -90,6 +91,30 @@ class NotificationManager {
|
||||||
this.triggerNotification('onBackupFailed', eventData)
|
this.triggerNotification('onBackupFailed', eventData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
*/
|
||||||
|
async onLibraryItemAdded(libraryItem) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Database.notificationSettings.isUseable) return
|
||||||
|
|
||||||
|
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onLibraryItemAdded')) {
|
||||||
|
Logger.debug(`[NotificationManager] onLibraryItemAdded: No active notifications`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = {
|
||||||
|
itemTitle: libraryItem.title,
|
||||||
|
itemAuthor: libraryItem.authorNamesFirstLast,
|
||||||
|
mediaType: libraryItem.mediaType
|
||||||
|
}
|
||||||
|
this.triggerNotification('onLibraryItemAdded', eventData)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[NotificationManager] Error in onLibraryItemAdded:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onTest() {
|
onTest() {
|
||||||
this.triggerNotification('onTest')
|
this.triggerNotification('onTest')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
server/migrations/v2.20.1-add-comments.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const { DataTypes } = require('sequelize')
|
||||||
|
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info('[2.20.1 migration] Creating comments table')
|
||||||
|
|
||||||
|
await queryInterface.createTable('comments', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
libraryItemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'libraryItems',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add indexes for faster lookups
|
||||||
|
await queryInterface.addIndex('comments', ['libraryItemId'])
|
||||||
|
await queryInterface.addIndex('comments', ['userId'])
|
||||||
|
|
||||||
|
logger.info('[2.20.1 migration] Comments table created successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info('[2.20.1 migration] Dropping comments table')
|
||||||
|
await queryInterface.dropTable('comments')
|
||||||
|
logger.info('[2.20.1 migration] Comments table dropped successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
18
server/migrations/v2.20.2-add-comment-ratings.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const { DataTypes } = require('sequelize')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('comments', 'rating', {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 1,
|
||||||
|
max: 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('comments', 'rating')
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server/migrations/v2.24.1-add-user-display-name.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
const { DataTypes } = require('sequelize')
|
||||||
|
|
||||||
|
async function up({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info('[Migration] Adding displayName column to users table')
|
||||||
|
|
||||||
|
await queryInterface.addColumn('users', 'displayName', {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('[Migration] Successfully added displayName column to users table')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function down({ context: { queryInterface, logger } }) {
|
||||||
|
logger.info('[Migration] Removing displayName column from users table')
|
||||||
|
|
||||||
|
await queryInterface.removeColumn('users', 'displayName')
|
||||||
|
|
||||||
|
logger.info('[Migration] Successfully removed displayName column from users table')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { up, down }
|
||||||
93
server/models/Comment.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
const { Model, DataTypes } = require('sequelize')
|
||||||
|
|
||||||
|
class Comment extends Model {
|
||||||
|
static init(sequelize) {
|
||||||
|
return super.init(
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 1,
|
||||||
|
max: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
libraryItemId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'libraryItems',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'comment',
|
||||||
|
tableName: 'comments',
|
||||||
|
timestamps: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static associate(models) {
|
||||||
|
Comment.belongsTo(models.user, {
|
||||||
|
foreignKey: 'userId',
|
||||||
|
as: 'user'
|
||||||
|
})
|
||||||
|
Comment.belongsTo(models.libraryItem, {
|
||||||
|
foreignKey: 'libraryItemId',
|
||||||
|
as: 'libraryItem'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
text: this.text,
|
||||||
|
rating: this.rating,
|
||||||
|
userId: this.userId,
|
||||||
|
libraryItemId: this.libraryItemId,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt,
|
||||||
|
user: this.user ? {
|
||||||
|
id: this.user.id,
|
||||||
|
username: this.user.username,
|
||||||
|
displayName: this.user.displayName
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Comment
|
||||||
|
|
@ -191,7 +191,20 @@ class LibraryItem extends Model {
|
||||||
static async getExpandedById(libraryItemId) {
|
static async getExpandedById(libraryItemId) {
|
||||||
if (!libraryItemId) return null
|
if (!libraryItemId) return null
|
||||||
|
|
||||||
const libraryItem = await this.findByPk(libraryItemId)
|
const libraryItem = await this.findByPk(libraryItemId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: this.sequelize.models.comment,
|
||||||
|
as: 'comments',
|
||||||
|
include: [{
|
||||||
|
model: this.sequelize.models.user,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
|
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
|
||||||
return null
|
return null
|
||||||
|
|
@ -734,31 +747,6 @@ class LibraryItem extends Model {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const { library, libraryFolder, book, podcast } = sequelize.models
|
|
||||||
library.hasMany(LibraryItem)
|
|
||||||
LibraryItem.belongsTo(library)
|
|
||||||
|
|
||||||
libraryFolder.hasMany(LibraryItem)
|
|
||||||
LibraryItem.belongsTo(libraryFolder)
|
|
||||||
|
|
||||||
book.hasOne(LibraryItem, {
|
|
||||||
foreignKey: 'mediaId',
|
|
||||||
constraints: false,
|
|
||||||
scope: {
|
|
||||||
mediaType: 'book'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
|
|
||||||
|
|
||||||
podcast.hasOne(LibraryItem, {
|
|
||||||
foreignKey: 'mediaId',
|
|
||||||
constraints: false,
|
|
||||||
scope: {
|
|
||||||
mediaType: 'podcast'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
|
|
||||||
|
|
||||||
LibraryItem.addHook('afterFind', (findResult) => {
|
LibraryItem.addHook('afterFind', (findResult) => {
|
||||||
if (!findResult) return
|
if (!findResult) return
|
||||||
|
|
||||||
|
|
@ -786,6 +774,40 @@ class LibraryItem extends Model {
|
||||||
media.destroy()
|
media.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
static associate(models) {
|
||||||
|
const { library, libraryFolder, book, podcast, comment } = models
|
||||||
|
library.hasMany(LibraryItem)
|
||||||
|
LibraryItem.belongsTo(library)
|
||||||
|
|
||||||
|
libraryFolder.hasMany(LibraryItem)
|
||||||
|
LibraryItem.belongsTo(libraryFolder)
|
||||||
|
|
||||||
|
book.hasOne(LibraryItem, {
|
||||||
|
foreignKey: 'mediaId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
mediaType: 'book'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
|
||||||
|
|
||||||
|
podcast.hasOne(LibraryItem, {
|
||||||
|
foreignKey: 'mediaId',
|
||||||
|
constraints: false,
|
||||||
|
scope: {
|
||||||
|
mediaType: 'podcast'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
|
||||||
|
|
||||||
|
LibraryItem.hasMany(comment, {
|
||||||
|
foreignKey: 'libraryItemId',
|
||||||
|
as: 'comments'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get isBook() {
|
get isBook() {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ class User extends Model {
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.username
|
this.username
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
|
this.displayName
|
||||||
|
/** @type {string} */
|
||||||
this.email
|
this.email
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.pash
|
this.pash
|
||||||
|
|
@ -425,6 +427,7 @@ class User extends Model {
|
||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
username: DataTypes.STRING,
|
username: DataTypes.STRING,
|
||||||
|
displayName: DataTypes.STRING,
|
||||||
email: DataTypes.STRING,
|
email: DataTypes.STRING,
|
||||||
pash: DataTypes.STRING,
|
pash: DataTypes.STRING,
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
|
|
@ -518,6 +521,7 @@ class User extends Model {
|
||||||
const json = {
|
const json = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
username: this.username,
|
username: this.username,
|
||||||
|
displayName: this.displayName,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
type: this.type,
|
type: this.type,
|
||||||
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ class ServerSettings {
|
||||||
this.id = 'server-settings'
|
this.id = 'server-settings'
|
||||||
this.tokenSecret = null
|
this.tokenSecret = null
|
||||||
|
|
||||||
|
// Server Title
|
||||||
|
this.title = 'Bookfire'
|
||||||
|
|
||||||
|
// Styling
|
||||||
|
this.styling = {
|
||||||
|
primary: '#1e293b',
|
||||||
|
primaryDark: '#0f172a',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
info: '#3b82f6',
|
||||||
|
background: '#111827'
|
||||||
|
}
|
||||||
|
|
||||||
// Scanner
|
// Scanner
|
||||||
this.scannerParseSubtitle = false
|
this.scannerParseSubtitle = false
|
||||||
this.scannerFindCovers = false
|
this.scannerFindCovers = false
|
||||||
|
|
@ -88,6 +102,18 @@ class ServerSettings {
|
||||||
|
|
||||||
construct(settings) {
|
construct(settings) {
|
||||||
this.tokenSecret = settings.tokenSecret
|
this.tokenSecret = settings.tokenSecret
|
||||||
|
|
||||||
|
// Server Title
|
||||||
|
this.title = settings.title || 'Bookfire'
|
||||||
|
|
||||||
|
// Styling
|
||||||
|
if (settings.styling) {
|
||||||
|
this.styling = {
|
||||||
|
...this.styling,
|
||||||
|
...settings.styling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||||
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
this.scannerCoverProvider = settings.scannerCoverProvider || 'google'
|
||||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||||
|
|
@ -204,6 +230,8 @@ class ServerSettings {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
tokenSecret: this.tokenSecret, // Do not return to client
|
tokenSecret: this.tokenSecret, // Do not return to client
|
||||||
|
title: this.title,
|
||||||
|
styling: { ...this.styling },
|
||||||
scannerFindCovers: this.scannerFindCovers,
|
scannerFindCovers: this.scannerFindCovers,
|
||||||
scannerCoverProvider: this.scannerCoverProvider,
|
scannerCoverProvider: this.scannerCoverProvider,
|
||||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,12 @@ class ApiRouter {
|
||||||
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
||||||
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
|
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
|
||||||
|
|
||||||
|
// Comment routes
|
||||||
|
this.router.get('/items/:id/comments', LibraryItemController.middleware.bind(this), LibraryItemController.getComments.bind(this))
|
||||||
|
this.router.post('/items/:id/comments', LibraryItemController.middleware.bind(this), LibraryItemController.addComment.bind(this))
|
||||||
|
this.router.patch('/items/:id/comments/:commentId', LibraryItemController.middleware.bind(this), LibraryItemController.updateComment.bind(this))
|
||||||
|
this.router.delete('/items/:id/comments/:commentId', LibraryItemController.middleware.bind(this), LibraryItemController.deleteComment.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// User Routes
|
// User Routes
|
||||||
//
|
//
|
||||||
|
|
@ -138,6 +144,7 @@ class ApiRouter {
|
||||||
this.router.patch('/users/:id/openid-unlink', UserController.middleware.bind(this), UserController.unlinkFromOpenID.bind(this))
|
this.router.patch('/users/:id/openid-unlink', UserController.middleware.bind(this), UserController.unlinkFromOpenID.bind(this))
|
||||||
this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
|
this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
|
||||||
this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))
|
this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))
|
||||||
|
this.router.get('/users/:id/reviews', UserController.middleware.bind(this), UserController.getUserReviews.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Collection Routes
|
// Collection Routes
|
||||||
|
|
|
||||||
98
server/routers/LibraryItemRouter.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
// Get library item by id
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const libraryItem = await req.ctx.models.libraryItem.getExpandedById(req.params.id)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.status(404).send({ error: 'Library item not found' })
|
||||||
|
}
|
||||||
|
res.json(libraryItem.toOldJSONExpanded())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get comments for a library item
|
||||||
|
router.get('/:id/comments', async (req, res) => {
|
||||||
|
const libraryItem = await req.ctx.models.libraryItem.findByPk(req.params.id)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.status(404).send({ error: 'Library item not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await req.ctx.models.comment.findAll({
|
||||||
|
where: { libraryItemId: req.params.id },
|
||||||
|
include: [{
|
||||||
|
model: req.ctx.models.user,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
}],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(comments)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a comment to a library item
|
||||||
|
router.post('/:id/comments', async (req, res) => {
|
||||||
|
const libraryItem = await req.ctx.models.libraryItem.findByPk(req.params.id)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.status(404).send({ error: 'Library item not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await req.ctx.models.comment.create({
|
||||||
|
text: req.body.text,
|
||||||
|
libraryItemId: req.params.id,
|
||||||
|
userId: req.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const commentWithUser = await req.ctx.models.comment.findByPk(comment.id, {
|
||||||
|
include: [{
|
||||||
|
model: req.ctx.models.user,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(commentWithUser)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update a comment
|
||||||
|
router.patch('/:itemId/comments/:commentId', async (req, res) => {
|
||||||
|
const comment = await req.ctx.models.comment.findByPk(req.params.commentId)
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).send({ error: 'Comment not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.userId !== req.user.id && !req.user.isAdmin) {
|
||||||
|
return res.status(403).send({ error: 'Not authorized to update this comment' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.update({
|
||||||
|
text: req.body.text
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedComment = await req.ctx.models.comment.findByPk(comment.id, {
|
||||||
|
include: [{
|
||||||
|
model: req.ctx.models.user,
|
||||||
|
as: 'user',
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json(updatedComment)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete a comment
|
||||||
|
router.delete('/:itemId/comments/:commentId', async (req, res) => {
|
||||||
|
const comment = await req.ctx.models.comment.findByPk(req.params.commentId)
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).send({ error: 'Comment not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.userId !== req.user.id && !req.user.isAdmin) {
|
||||||
|
return res.status(403).send({ error: 'Not authorized to delete this comment' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.destroy()
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
|
|
@ -14,6 +14,7 @@ const LibraryItemScanner = require('./LibraryItemScanner')
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||||
const Task = require('../objects/Task')
|
const Task = require('../objects/Task')
|
||||||
|
const NotificationManager = require('../managers/NotificationManager')
|
||||||
|
|
||||||
class LibraryScanner {
|
class LibraryScanner {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -141,6 +142,7 @@ class LibraryScanner {
|
||||||
* @returns {Promise<boolean>} true if scan canceled
|
* @returns {Promise<boolean>} true if scan canceled
|
||||||
*/
|
*/
|
||||||
async scanLibrary(libraryScan, forceRescan) {
|
async scanLibrary(libraryScan, forceRescan) {
|
||||||
|
|
||||||
// Make sure library filter data is set
|
// Make sure library filter data is set
|
||||||
// this is used to check for existing authors & series
|
// this is used to check for existing authors & series
|
||||||
await libraryFilters.getFilterData(libraryScan.libraryMediaType, libraryScan.libraryId)
|
await libraryFilters.getFilterData(libraryScan.libraryMediaType, libraryScan.libraryId)
|
||||||
|
|
@ -628,6 +630,7 @@ class LibraryScanner {
|
||||||
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||||
if (newLibraryItem) {
|
if (newLibraryItem) {
|
||||||
SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)
|
SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)
|
||||||
|
NotificationManager.onLibraryItemAdded(newLibraryItem)
|
||||||
}
|
}
|
||||||
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,22 @@ module.exports.notificationData = {
|
||||||
errorMsg: 'Example error message'
|
errorMsg: 'Example error message'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'onLibraryItemAdded',
|
||||||
|
requiresLibrary: true,
|
||||||
|
description: 'Triggered when a new item is added to a library',
|
||||||
|
descriptionKey: 'NotificationOnLibraryItemAddedDescription',
|
||||||
|
variables: ['itemTitle', 'itemAuthor', 'mediaType'],
|
||||||
|
defaults: {
|
||||||
|
title: 'New {{mediaType}} Added!',
|
||||||
|
body: '{{itemTitle}} by {{itemAuthor}} has been added to one of your libraries.'
|
||||||
|
},
|
||||||
|
testData: {
|
||||||
|
itemTitle: 'Test Item',
|
||||||
|
itemAuthor: 'Test Author',
|
||||||
|
mediaType: 'Book'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'onTest',
|
name: 'onTest',
|
||||||
requiresLibrary: false,
|
requiresLibrary: false,
|
||||||
|
|
@ -67,11 +83,19 @@ module.exports.notificationData = {
|
||||||
descriptionKey: 'NotificationOnTestDescription',
|
descriptionKey: 'NotificationOnTestDescription',
|
||||||
variables: ['version'],
|
variables: ['version'],
|
||||||
defaults: {
|
defaults: {
|
||||||
title: 'Test Notification on Abs {{version}}',
|
title: 'New Book Added!',
|
||||||
body: 'Test notificataion body for abs {{version}}.'
|
body: '{{bookTitle}} by {{bookAuthor}} has been added to {{libraryName}} library.'
|
||||||
},
|
},
|
||||||
testData: {
|
testData: {
|
||||||
version: 'v' + version
|
libraryItemId: 'li_notification_test',
|
||||||
|
libraryId: 'lib_test',
|
||||||
|
libraryName: 'Audiobooks',
|
||||||
|
mediaTags: 'TestTag1, TestTag2',
|
||||||
|
bookTitle: 'Test Book',
|
||||||
|
bookAuthor: 'Test Author',
|
||||||
|
bookDescription: 'Description of the test book.',
|
||||||
|
bookGenres: 'TestGenre1, TestGenre2',
|
||||||
|
bookNarrator: 'Test Narrator'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||