Merge pull request #1 from zipben/workingitout

Workingitout
This commit is contained in:
zipben 2025-06-09 18:12:00 -03:00 committed by GitHub
commit 8c2eb04598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 6033 additions and 4016 deletions

View file

@ -11,17 +11,17 @@
"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",
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
],
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, "features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": true,
"username": "vscode",
"upgradePackages": true
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ "forwardPorts": [3000, 3333],
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.
@ -29,12 +29,19 @@
// 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",
"terminal.integrated.profiles.linux": {
"zsh": {
"path": "/bin/zsh"
} }
} }
},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root" "remoteUser": "vscode",
"updateRemoteUserUID": true
} }

1
.gitignore vendored
View file

@ -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
View 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

View file

@ -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 {

View file

@ -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';

View file

@ -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">
<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="items-center hidden md:flex"> <span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</span> </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 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 class="material-symbols text-xl text-gray-100">&#xe7fd;</span> <span class="material-symbols text-xl text-gray-100">&#xe7fd;</span>
</span> </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">&#xe7fd;</span>
Profile
</div>
</nuxt-link> </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">&#xe9ba;</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>

View file

@ -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() {

View file

@ -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;

View file

@ -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'
} }
] ]

View 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>

View file

@ -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() {

View 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>

View file

@ -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: {

View 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>

View file

@ -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

View file

@ -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

View file

@ -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">

View file

@ -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()

View file

@ -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 {

View file

@ -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>

View file

@ -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,

7159
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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>

View 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>

View file

@ -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)

View file

@ -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)

View file

@ -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)
}
} }
}, },
mounted() { 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)
}
}
},
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>

View file

@ -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>

View file

@ -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() {}
} }

View file

@ -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() {},

View file

@ -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>

View 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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

View file

@ -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';}
.st4{font-size:800px;}
.st5{fill:#474747;}
</style>
<title>bgAsset 6</title>
<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> </g>
<g transform="matrix(1.16459,0,0,1.16459,-742.785,-1815.19)">
<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)"> <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;"/>
<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> </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>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

View file

@ -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
}, },

View file

@ -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
View 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
View 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
View file

@ -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",

View file

@ -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",

View file

@ -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>
```

View file

@ -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 })
} }

View file

@ -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()

View file

@ -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)
} }
/** /**

View file

@ -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()

View file

@ -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')
} }

View 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 }

View 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')
}
}

View 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
View 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

View file

@ -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() {

View file

@ -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,

View file

@ -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,

View file

@ -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

View 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

View file

@ -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
} }

View file

@ -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'
} }
} }
] ]