added server styling settings

This commit is contained in:
zipben 2025-06-05 10:34:06 +00:00
parent 76ea038f5a
commit 32ab726fe9
11 changed files with 466 additions and 3 deletions

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

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

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

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

@ -0,0 +1,164 @@
<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">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,
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', {
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 }
}
}
}
</script>

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

@ -1119,5 +1119,15 @@
"LabelRating": "Rating", "LabelRating": "Rating",
"LabelStarRating": "{0} stars", "LabelStarRating": "{0} stars",
"LabelNoRating": "No rating", "LabelNoRating": "No rating",
"LabelAverageRating": "Average rating: {0}" "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"
} }

View file

@ -1,4 +1,25 @@
{ {
"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", "LabelSelectAll": "Select All",
"LabelComments": "Comments", "LabelComments": "Comments",
"PlaceholderAddComment": "Add a comment... (Ctrl+Enter to post)", "PlaceholderAddComment": "Add a comment... (Ctrl+Enter to post)",

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

@ -9,6 +9,17 @@ class ServerSettings {
this.id = 'server-settings' this.id = 'server-settings'
this.tokenSecret = null this.tokenSecret = null
// 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 +99,15 @@ class ServerSettings {
construct(settings) { construct(settings) {
this.tokenSecret = settings.tokenSecret this.tokenSecret = settings.tokenSecret
// 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 +224,7 @@ 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
styling: { ...this.styling },
scannerFindCovers: this.scannerFindCovers, scannerFindCovers: this.scannerFindCovers,
scannerCoverProvider: this.scannerCoverProvider, scannerCoverProvider: this.scannerCoverProvider,
scannerParseSubtitle: this.scannerParseSubtitle, scannerParseSubtitle: this.scannerParseSubtitle,