mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
290 lines
12 KiB
Vue
290 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, shallowRef, watch, computed } from 'vue'
|
|
import {
|
|
X,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Library,
|
|
Paintbrush,
|
|
BookOpen,
|
|
FileText,
|
|
BookImage,
|
|
Settings,
|
|
Info,
|
|
Users,
|
|
ShieldCheck,
|
|
KeyRound,
|
|
LogIn,
|
|
Rss,
|
|
Tablet,
|
|
Wrench,
|
|
FolderPen,
|
|
} from 'lucide-vue-next'
|
|
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
|
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
|
import { useThemeStore, BACKGROUND_OPTIONS } from '@/stores/theme'
|
|
import LibrariesSettings from './LibrariesSettings.vue'
|
|
import AppearanceSettings from './AppearanceSettings.vue'
|
|
import ReaderSettings from './ReaderSettings.vue'
|
|
import EbookSettings from './EbookSettings.vue'
|
|
import PdfSettings from './PdfSettings.vue'
|
|
import ComicsSettings from './ComicsSettings.vue'
|
|
import AboutSettings from './AboutSettings.vue'
|
|
import UsersPage from '@/features/admin/UsersPage.vue'
|
|
import RolesPage from '@/features/admin/RolesPage.vue'
|
|
import PermissionsPage from '@/features/admin/PermissionsPage.vue'
|
|
import FileNamingSettings from './FileNamingSettings.vue'
|
|
import OidcSettings from './OidcSettings.vue'
|
|
import OpdsSettings from './OpdsSettings.vue'
|
|
import KoboSettings from './KoboSettings.vue'
|
|
import MaintenanceSettings from './MaintenanceSettings.vue'
|
|
|
|
const { isOpen, close } = useSettingsDrawer()
|
|
const { isSuperuser, userPermissions } = usePermissions()
|
|
const themeStore = useThemeStore()
|
|
const bgClass = computed(() => BACKGROUND_OPTIONS.find((o) => o.id === themeStore.background)?.cssClass ?? '')
|
|
|
|
type SectionId =
|
|
| 'libraries'
|
|
| 'appearance'
|
|
| 'opds'
|
|
| 'kobo'
|
|
| 'reader'
|
|
| 'ebook'
|
|
| 'pdf'
|
|
| 'comics'
|
|
| 'about'
|
|
| 'users'
|
|
| 'roles'
|
|
| 'permissions'
|
|
| 'oidc'
|
|
| 'file-naming'
|
|
| 'maintenance'
|
|
|
|
const navGroups = computed(() => {
|
|
const perms = userPermissions.value
|
|
const su = isSuperuser.value
|
|
const groups = [
|
|
{
|
|
label: 'General',
|
|
items: [
|
|
{ id: 'libraries' as SectionId, label: 'Libraries', icon: Library, component: LibrariesSettings },
|
|
{ id: 'appearance' as SectionId, label: 'Appearance', icon: Paintbrush, component: AppearanceSettings },
|
|
...(su || perms.includes('opds_access') ? [{ id: 'opds' as SectionId, label: 'OPDS', icon: Rss, component: OpdsSettings }] : []),
|
|
...(su || perms.includes('kobo_sync') ? [{ id: 'kobo' as SectionId, label: 'Kobo', icon: Tablet, component: KoboSettings }] : []),
|
|
],
|
|
},
|
|
{
|
|
label: 'Reader',
|
|
items: [
|
|
{ id: 'reader' as SectionId, label: 'General', icon: Settings, component: ReaderSettings },
|
|
{ id: 'ebook' as SectionId, label: 'eBook', icon: BookOpen, component: EbookSettings },
|
|
{ id: 'pdf' as SectionId, label: 'PDF', icon: FileText, component: PdfSettings },
|
|
{ id: 'comics' as SectionId, label: 'Comics', icon: BookImage, component: ComicsSettings },
|
|
],
|
|
},
|
|
]
|
|
const adminItems: { id: SectionId; label: string; icon: unknown; component: unknown }[] = []
|
|
if (su || perms.includes('manage_users')) {
|
|
adminItems.push({ id: 'users', label: 'Users', icon: Users, component: UsersPage })
|
|
}
|
|
if (su || perms.includes('manage_roles')) {
|
|
adminItems.push({ id: 'roles', label: 'Roles', icon: ShieldCheck, component: RolesPage })
|
|
adminItems.push({ id: 'permissions', label: 'Permissions', icon: KeyRound, component: PermissionsPage })
|
|
}
|
|
if (su || perms.includes('manage_app_settings')) {
|
|
adminItems.push({ id: 'oidc', label: 'OIDC / SSO', icon: LogIn, component: OidcSettings })
|
|
adminItems.push({ id: 'file-naming', label: 'File Naming', icon: FolderPen, component: FileNamingSettings })
|
|
adminItems.push({ id: 'maintenance', label: 'Maintenance', icon: Wrench, component: MaintenanceSettings })
|
|
}
|
|
if (adminItems.length) {
|
|
groups.push({ label: 'Administration', items: adminItems })
|
|
}
|
|
groups.push({ label: 'System', items: [{ id: 'about' as SectionId, label: 'About', icon: Info, component: AboutSettings }] })
|
|
return groups
|
|
})
|
|
|
|
const allItems = computed(() => navGroups.value.flatMap((g) => g.items))
|
|
const activeId = ref<SectionId>('libraries')
|
|
const activeLabel = ref('Libraries')
|
|
const ActiveComponent = shallowRef(LibrariesSettings)
|
|
const mobileView = ref<'nav' | 'content'>('nav')
|
|
|
|
function navigate(id: SectionId) {
|
|
const item = allItems.value.find((i) => i.id === id)!
|
|
activeId.value = id
|
|
activeLabel.value = item.label
|
|
ActiveComponent.value = item.component
|
|
mobileView.value = 'content'
|
|
}
|
|
|
|
watch(isOpen, (v) => {
|
|
if (v) {
|
|
activeId.value = 'libraries'
|
|
activeLabel.value = 'Libraries'
|
|
ActiveComponent.value = LibrariesSettings
|
|
mobileView.value = 'nav'
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="drawer-fade">
|
|
<div v-if="isOpen" class="fixed inset-0 z-50 flex justify-end">
|
|
<!-- Backdrop -->
|
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-[2px]" @click="close" />
|
|
|
|
<!-- Panel -->
|
|
<Transition name="drawer-slide">
|
|
<div
|
|
v-if="isOpen"
|
|
class="relative flex h-full w-full md:max-w-[66.666vw] shadow-2xl overflow-hidden bg-background"
|
|
style="border-left: 1px solid var(--border)"
|
|
>
|
|
<!-- ── MOBILE: Nav list ─────────────────────────── -->
|
|
<Transition name="mobile-nav">
|
|
<div v-if="mobileView === 'nav'" class="md:hidden absolute inset-0 flex flex-col bg-background z-10">
|
|
<div class="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
|
|
<span class="text-base font-semibold text-foreground font-serif">Settings</span>
|
|
<button
|
|
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
@click="close"
|
|
>
|
|
<X :size="16" />
|
|
</button>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div v-for="group in navGroups" :key="group.label" class="mt-6 mb-1">
|
|
<p class="px-4 pb-1 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-widest">
|
|
{{ group.label }}
|
|
</p>
|
|
<button
|
|
v-for="item in group.items"
|
|
:key="item.id"
|
|
class="w-full flex items-center gap-3 px-4 py-3.5 border-b border-border/60 hover:bg-muted/50 active:bg-muted transition-colors"
|
|
@click="navigate(item.id)"
|
|
>
|
|
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 shrink-0">
|
|
<component :is="item.icon" :size="15" class="text-primary" />
|
|
</div>
|
|
<span class="flex-1 text-sm font-medium text-foreground text-left">{{ item.label }}</span>
|
|
<ChevronRight :size="15" class="text-muted-foreground/40" />
|
|
</button>
|
|
</div>
|
|
<p class="px-4 py-6 text-[11px] text-muted-foreground/40">projectx · v0.1.0</p>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- ── MOBILE: Content view ─────────────────────── -->
|
|
<Transition name="mobile-content">
|
|
<div v-if="mobileView === 'content'" class="md:hidden absolute inset-0 flex flex-col bg-background z-10">
|
|
<div class="flex items-center gap-1 px-2 h-14 border-b border-border shrink-0">
|
|
<button
|
|
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
@click="mobileView = 'nav'"
|
|
>
|
|
<ChevronLeft :size="18" />
|
|
<span class="text-sm">Settings</span>
|
|
</button>
|
|
<span class="flex-1 text-sm font-medium text-foreground text-center pr-16">{{ activeLabel }}</span>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto" :class="bgClass">
|
|
<component :is="ActiveComponent" />
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- ── DESKTOP: Two-column ─────────────────────── -->
|
|
<nav class="hidden md:flex flex-col w-52 shrink-0 bg-muted/40 border-r border-border h-full">
|
|
<div class="px-4 pt-5 pb-4 border-b border-border flex items-center justify-between">
|
|
<span class="text-sm font-semibold text-foreground font-serif">Settings</span>
|
|
<button
|
|
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
@click="close"
|
|
>
|
|
<X :size="14" />
|
|
</button>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto py-3 px-2">
|
|
<div v-for="group in navGroups" :key="group.label" class="mb-4">
|
|
<p class="px-2 mb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-widest">
|
|
{{ group.label }}
|
|
</p>
|
|
<button
|
|
v-for="item in group.items"
|
|
:key="item.id"
|
|
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors relative"
|
|
:class="
|
|
activeId === item.id
|
|
? 'bg-background text-foreground font-medium shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
|
|
"
|
|
@click="navigate(item.id)"
|
|
>
|
|
<component :is="item.icon" :size="14" :class="activeId === item.id ? 'text-primary' : 'text-muted-foreground/70'" />
|
|
{{ item.label }}
|
|
<div v-if="activeId === item.id" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r bg-primary" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="px-4 py-3 border-t border-border">
|
|
<p class="text-[10px] text-muted-foreground/40">projectx · v0.1.0</p>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="hidden md:block flex-1 overflow-y-auto" :class="bgClass">
|
|
<component :is="ActiveComponent" />
|
|
</main>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.drawer-fade-enter-active,
|
|
.drawer-fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.drawer-fade-enter-from,
|
|
.drawer-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.drawer-slide-enter-active,
|
|
.drawer-slide-leave-active {
|
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.drawer-slide-enter-from,
|
|
.drawer-slide-leave-to {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
/* Mobile nav → content: nav slides left, content enters from right */
|
|
.mobile-nav-leave-active,
|
|
.mobile-content-enter-active {
|
|
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.mobile-nav-leave-to {
|
|
transform: translateX(-30%);
|
|
}
|
|
.mobile-content-enter-from {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
/* Mobile content → nav: content slides right, nav enters from left */
|
|
.mobile-content-leave-active,
|
|
.mobile-nav-enter-active {
|
|
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.mobile-content-leave-to {
|
|
transform: translateX(100%);
|
|
}
|
|
.mobile-nav-enter-from {
|
|
transform: translateX(-30%);
|
|
}
|
|
</style>
|