mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): match entity names that carry non-collapsed whitespace
Author, narrator, genre and tag names written before whitespace normalization existed can hold a non-breaking space, tab, newline or doubled space. HTML collapses all of those, so the name renders correctly in the UI while the ILIKE search predicate never matches it; unaccent() does not touch whitespace either. Re-entering an identical name by hand fixed a single book because the write path canonicalizes the row it reuses, but nothing ever ran for untouched books. Collapse stored names to the form normalizeMetadataTextSql() produces, merging rows that collide once collapsed and remapping every junction and content-filter reference before a duplicate is dropped. Case variants are left alone, since search is already case-insensitive. Close the two write paths that still produced un-collapsed names, and normalize the typed search term so a pasted non-breaking space still matches. Also carries staged client appearance work: the surface brightness picker and related controls. Closes #1014
This commit is contained in:
@@ -5,14 +5,35 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
const accentOptions = ACCENT_ROWS.flat()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="props.touch" class="grid grid-cols-10 gap-0.5">
|
||||
<Tooltip v-for="opt in accentOptions" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex aspect-square w-full items-center justify-center rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.accent === opt.id ? 'bg-muted ring-2 ring-primary' : 'hover:bg-muted/60'"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
>
|
||||
<span class="block size-5 rounded-full" :class="opt.swatchClass" :style="{ backgroundColor: opt.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-0.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="w-3.5 h-3.5 rounded-full transition-transform hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
|
||||
@@ -32,17 +32,15 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import AccentPicker from '@/components/AccentPicker.vue'
|
||||
import LanguagePicker from '@/components/LanguagePicker.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import RadiusPicker from '@/components/RadiusPicker.vue'
|
||||
import BackgroundPicker from '@/components/BackgroundPicker.vue'
|
||||
import ThemePicker from '@/components/ThemePicker.vue'
|
||||
import SurfaceBrightnessPicker from '@/components/SurfaceBrightnessPicker.vue'
|
||||
import SurfacePicker from '@/components/SurfacePicker.vue'
|
||||
import { useGlobalSearch, type GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
|
||||
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
@@ -123,6 +121,7 @@ function navigateToSettings() {
|
||||
router.push({ name: 'settings-libraries' })
|
||||
}
|
||||
|
||||
const appearanceSheetOpen = ref(false)
|
||||
const languageSheetOpen = ref(false)
|
||||
const languagePopoverOpen = ref(false)
|
||||
|
||||
@@ -141,6 +140,10 @@ function openLanguageSheet() {
|
||||
languageSheetOpen.value = true
|
||||
}
|
||||
|
||||
function openAppearanceSheet() {
|
||||
appearanceSheetOpen.value = true
|
||||
}
|
||||
|
||||
function navigateToWhatsNew() {
|
||||
router.push({ name: 'whats-new' })
|
||||
}
|
||||
@@ -636,36 +639,10 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette :size="15" class="mr-2 text-muted-foreground" />
|
||||
{{ t('components.appHeader.appearance') }}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent class="w-72 p-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.theme') }}</span>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.accent') }}</span>
|
||||
<AccentPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.radius') }}</span>
|
||||
<RadiusPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfaceOpacity') }}</span>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.background') }}</span>
|
||||
<BackgroundPicker />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="openAppearanceSheet">
|
||||
<Palette :size="15" class="mr-2 text-muted-foreground" />
|
||||
{{ t('components.appHeader.appearance') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem @click="openLanguageSheet">
|
||||
<Languages :size="15" class="mr-2 text-muted-foreground" />
|
||||
@@ -798,6 +775,10 @@ function formatBadgeStyle(fmt: string) {
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfaceOpacity') }}</span>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</span>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.background') }}</span>
|
||||
<BackgroundPicker />
|
||||
@@ -883,10 +864,52 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<BookUploadModal v-if="uploadOpen" @close="uploadOpen = false" @uploaded="uploadOpen = false" />
|
||||
|
||||
<Sheet v-model:open="appearanceSheetOpen">
|
||||
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl p-0">
|
||||
<SheetHeader class="shrink-0 border-b border-border px-4 py-3 text-start">
|
||||
<SheetTitle class="text-base">{{ t('components.appHeader.appearance') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('components.appHeader.appearanceDescription') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.theme') }}</h3>
|
||||
<ThemePicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.accent') }}</h3>
|
||||
<AccentPicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.radius') }}</h3>
|
||||
<RadiusPicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card p-3">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.surfaceOpacity') }}</h3>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-2 border-t border-border pt-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</h3>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.background') }}</h3>
|
||||
<BackgroundPicker touch />
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet v-model:open="languageSheetOpen">
|
||||
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl px-2 pb-2">
|
||||
<SheetHeader class="pb-1">
|
||||
<SheetTitle>{{ t('settings.appearance.language.label') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('settings.appearance.language.description') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<LanguagePicker :autofocus="false" class="min-h-0 flex-1" @select="selectLanguage" />
|
||||
</SheetContent>
|
||||
|
||||
@@ -3,16 +3,27 @@ import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div class="grid" :class="props.touch ? 'grid-cols-4 gap-2' : 'grid-cols-5 gap-2'">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" class="flex flex-col items-center cursor-pointer focus:outline-none" @click="themeStore.setBackground(opt.id)">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="opt.label"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div
|
||||
class="w-full h-8 rounded overflow-hidden ring-2 transition-all"
|
||||
:class="themeStore.background === opt.id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40'"
|
||||
class="w-full overflow-hidden ring-2 transition-all"
|
||||
:class="[
|
||||
props.touch ? 'h-10 rounded-md' : 'h-8 rounded',
|
||||
themeStore.background === opt.id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40',
|
||||
]"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
const shapes = [
|
||||
{ id: 'sharp' as const, rx: '0' },
|
||||
{ id: 'default' as const, rx: '3' },
|
||||
{ id: 'rounded' as const, rx: '8' },
|
||||
{ id: 'pill' as const, rx: '99' },
|
||||
{ id: 'sharp' as const, rx: '0', labelKey: 'components.radiusPicker.sharp' },
|
||||
{ id: 'default' as const, rx: '3', labelKey: 'components.radiusPicker.default' },
|
||||
{ id: 'rounded' as const, rx: '8', labelKey: 'components.radiusPicker.rounded' },
|
||||
{ id: 'pill' as const, rx: '99', labelKey: 'components.radiusPicker.pill' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div v-if="props.touch" class="grid grid-cols-4 gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.radius === s.id ? 'border-primary bg-primary/8 text-primary' : 'border-border bg-muted/30 text-muted-foreground'"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
>
|
||||
<span class="block h-4 w-8 border-2 border-current" :style="{ borderRadius: `${s.rx}px` }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-5 border-2 transition-colors focus:outline-none"
|
||||
:style="{
|
||||
borderRadius: `${s.rx}px`,
|
||||
borderColor: themeStore.radius === s.id ? 'var(--primary)' : 'var(--muted-foreground)',
|
||||
opacity: themeStore.radius === s.id ? '1' : '0.5',
|
||||
}"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ s.id }}</TooltipContent>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function handleSurfaceBrightnessInput(event: Event) {
|
||||
themeStore.setBrightness(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="themeStore.brightness"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:aria-label="t('settings.appearance.theme.surfaceBrightness.label')"
|
||||
:aria-valuetext="t('components.appHeader.surfaceBrightnessValue', { value: formatNumber(themeStore.brightness) })"
|
||||
@input="handleSurfaceBrightnessInput"
|
||||
/>
|
||||
<span class="w-10 shrink-0 text-right text-[13px] tabular-nums text-muted-foreground">
|
||||
{{ t('components.appHeader.surfaceBrightnessValue', { value: formatNumber(themeStore.brightness) }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,9 @@ import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
function handleLightTheme() {
|
||||
themeStore.setTheme('light')
|
||||
@@ -20,24 +23,36 @@ function handleSystemTheme() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1 p-1 rounded-md border border-border bg-muted/50 w-fit">
|
||||
<div class="flex items-center rounded-md border border-border bg-muted/50 p-1" :class="props.touch ? 'w-full gap-0.5' : 'w-fit gap-1'">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleLightTheme"
|
||||
>
|
||||
<Sun :size="12" /> {{ t('components.themePicker.light') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleDarkTheme"
|
||||
>
|
||||
<Moon :size="12" /> {{ t('components.themePicker.dark') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'system' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'system' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleSystemTheme"
|
||||
>
|
||||
<Monitor :size="12" /> {{ t('components.themePicker.system') }}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SurfaceBrightnessPicker from '../SurfaceBrightnessPicker.vue'
|
||||
|
||||
const themeStore = reactive({
|
||||
brightness: 35,
|
||||
setBrightness: vi.fn<(brightness: number) => void>((brightness: number) => {
|
||||
themeStore.brightness = brightness
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
describe('SurfaceBrightnessPicker', () => {
|
||||
beforeEach(() => {
|
||||
themeStore.brightness = 35
|
||||
themeStore.setBrightness.mockClear()
|
||||
})
|
||||
|
||||
it('renders the current brightness as an accessible percentage', () => {
|
||||
const wrapper = mount(SurfaceBrightnessPicker)
|
||||
const input = wrapper.get('input[type="range"]')
|
||||
|
||||
expect(input.attributes('min')).toBe('0')
|
||||
expect(input.attributes('max')).toBe('100')
|
||||
expect(input.attributes('step')).toBe('5')
|
||||
expect(input.attributes('aria-label')).toBe('Surface brightness')
|
||||
expect(input.attributes('aria-valuetext')).toBe('35%')
|
||||
expect(wrapper.text()).toBe('35%')
|
||||
})
|
||||
|
||||
it('updates the shared theme brightness preference', async () => {
|
||||
const wrapper = mount(SurfaceBrightnessPicker)
|
||||
|
||||
await wrapper.get('input[type="range"]').setValue(60)
|
||||
|
||||
expect(themeStore.setBrightness).toHaveBeenCalledWith(60)
|
||||
expect(wrapper.text()).toBe('60%')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Bell, BellOff, CheckCheck, Trash2 } from '@lucide/vue'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNotifications } from '../composables/useNotifications'
|
||||
import NotificationItemVue from './NotificationItem.vue'
|
||||
@@ -63,7 +63,10 @@ function handleLoadMore() {
|
||||
|
||||
<SheetContent side="right" class="w-[90vw] sm:max-w-md p-0 flex flex-col gap-0">
|
||||
<SheetHeader class="flex flex-row items-center justify-between border-b px-4 py-3 h-14 shrink-0 space-y-0">
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<div>
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('notifications.description') }}</SheetDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pr-8">
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleMarkAllRead">
|
||||
<CheckCheck :size="14" />
|
||||
|
||||
@@ -4754,6 +4754,7 @@
|
||||
"annotations": "Annotations",
|
||||
"uploadBooks": "Upload books",
|
||||
"appearance": "Appearance",
|
||||
"appearanceDescription": "Choose theme, accent, corner radius, surface, and background options.",
|
||||
"theme": "Theme",
|
||||
"accent": "Accent",
|
||||
"radius": "Radius",
|
||||
@@ -4774,7 +4775,8 @@
|
||||
"cta": "Star BookOrbit on GitHub"
|
||||
},
|
||||
"surfaceOpacity": "Surface opacity",
|
||||
"surfaceOpacityValue": "{value}%"
|
||||
"surfaceOpacityValue": "{value}%",
|
||||
"surfaceBrightnessValue": "{value}%"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -4912,6 +4914,12 @@
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"radiusPicker": {
|
||||
"sharp": "Sharp corners",
|
||||
"default": "Default corners",
|
||||
"rounded": "Rounded corners",
|
||||
"pill": "Pill-shaped corners"
|
||||
},
|
||||
"languagePicker": {
|
||||
"searchPlaceholder": "Search languages",
|
||||
"suggested": "Suggested",
|
||||
@@ -6145,6 +6153,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Review and manage your notifications.",
|
||||
"markAllRead": "Mark all read",
|
||||
"clear": "Clear",
|
||||
"empty": "No notifications yet",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: 'account/privacy',
|
||||
name: 'settings-account-privacy',
|
||||
component: () => import('@/features/settings/PrivacySharingSettings.vue'),
|
||||
meta: { title: () => t('titles.account.privacy') },
|
||||
meta: { maxWidth: 'max-w-4xl', title: () => t('titles.account.privacy') },
|
||||
},
|
||||
{
|
||||
path: 'account/notifications',
|
||||
@@ -285,7 +285,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: 'metadata/providers',
|
||||
name: 'settings-metadata-providers',
|
||||
component: () => import('@/features/settings/metadata-preferences/MetadataPreferencesSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: () => t('titles.metadata.providers') },
|
||||
meta: { maxWidth: 'max-w-4xl', title: () => t('titles.metadata.providers') },
|
||||
},
|
||||
{
|
||||
path: 'metadata/field-rules',
|
||||
|
||||
Reference in New Issue
Block a user