mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): translate dashboard widget names in customization modal
Use vue-i18n locale keys for dashboard widget and shelf labels in the dashboard customization sheet and view. This resolves hardcoded English strings when viewing dashboard settings in non-English languages. Closes #796
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import type { WidgetConfig } from '@bookorbit/types'
|
||||
import { WIDGET_TYPES, type WidgetConfig } from '@bookorbit/types'
|
||||
|
||||
import { setI18nLocale } from '@/i18n'
|
||||
import en from '@/locales/en.json'
|
||||
import pt from '@/locales/pt.json'
|
||||
|
||||
type UseSmartScopesMock = () => {
|
||||
smartScopes: Ref<unknown[]>
|
||||
@@ -11,10 +15,11 @@ type UseSmartScopesMock = () => {
|
||||
type UseDashboardWidgetsMock = () => {
|
||||
widgets: Ref<WidgetConfig[]>
|
||||
saveWidgets: (widgets: WidgetConfig[]) => Promise<void>
|
||||
WIDGET_LABELS: Record<string, string>
|
||||
DEFAULT_WIDGETS: WidgetConfig[]
|
||||
}
|
||||
|
||||
const widgetsRef = ref<WidgetConfig[]>([])
|
||||
|
||||
vi.mock('@/components/ui/sheet', () => {
|
||||
const passthrough = { template: '<div><slot /></div>' }
|
||||
return {
|
||||
@@ -34,39 +39,127 @@ vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
|
||||
|
||||
vi.mock('../composables/useDashboardWidgets', () => ({
|
||||
useDashboardWidgets: vi.fn<UseDashboardWidgetsMock>(() => ({
|
||||
widgets: ref<WidgetConfig[]>([]),
|
||||
widgets: widgetsRef,
|
||||
saveWidgets: vi.fn<(widgets: WidgetConfig[]) => Promise<void>>(),
|
||||
WIDGET_LABELS: {},
|
||||
DEFAULT_WIDGETS: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
import DashboardSettingsSheet from './DashboardSettingsSheet.vue'
|
||||
|
||||
const ALL_WIDGETS: WidgetConfig[] = WIDGET_TYPES.map((type, index) => ({
|
||||
id: String(index + 1),
|
||||
type,
|
||||
enabled: true,
|
||||
order: index + 1,
|
||||
}))
|
||||
|
||||
async function openSheet(): Promise<VueWrapper> {
|
||||
const wrapper = mount(DashboardSettingsSheet, { props: { open: false } })
|
||||
await wrapper.setProps({ open: true })
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openShelvesTab(wrapper: VueWrapper): Promise<void> {
|
||||
const shelvesTab = wrapper.findAll('button').find((button) => button.text() === en.dashboard.settings.tabs.shelves)
|
||||
await shelvesTab?.trigger('click')
|
||||
}
|
||||
|
||||
function widgetRowLabels(wrapper: VueWrapper): string[] {
|
||||
return wrapper.findAll('span.flex-1').map((span) => span.text())
|
||||
}
|
||||
|
||||
function shelfOptionLabels(wrapper: VueWrapper): string[] {
|
||||
return wrapper
|
||||
.find('select')
|
||||
.findAll('option')
|
||||
.map((option) => option.text())
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
widgetsRef.value = []
|
||||
await setI18nLocale('en')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await setI18nLocale('en')
|
||||
})
|
||||
|
||||
describe('DashboardSettingsSheet', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
it('includes continue-listening and want-to-read in the shelf selector', async () => {
|
||||
const wrapper = await openSheet()
|
||||
await openShelvesTab(wrapper)
|
||||
|
||||
const optionLabels = shelfOptionLabels(wrapper)
|
||||
|
||||
expect(optionLabels).toContain(en.dashboard.settings.shelfNames.continueListening)
|
||||
expect(optionLabels).toContain(en.dashboard.settings.shelfNames.wantToRead)
|
||||
})
|
||||
|
||||
it('includes continue-listening and want-to-read in the shelf selector', async () => {
|
||||
const wrapper = mount(DashboardSettingsSheet, {
|
||||
props: { open: false },
|
||||
})
|
||||
it('lists every shelf type in the selector using catalog names', async () => {
|
||||
const wrapper = await openSheet()
|
||||
await openShelvesTab(wrapper)
|
||||
|
||||
await wrapper.setProps({ open: true })
|
||||
expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(en.dashboard.settings.shelfNames).sort())
|
||||
})
|
||||
|
||||
it('translates the shelf selector when the locale changes', async () => {
|
||||
const wrapper = await openSheet()
|
||||
await openShelvesTab(wrapper)
|
||||
|
||||
await setI18nLocale('pt')
|
||||
await nextTick()
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Shelves')
|
||||
?.trigger('click')
|
||||
|
||||
const optionLabels = wrapper
|
||||
.find('select')
|
||||
.findAll('option')
|
||||
.map((option) => option.text())
|
||||
expect(shelfOptionLabels(wrapper).sort()).toEqual(Object.values(pt.dashboard.settings.shelfNames).sort())
|
||||
})
|
||||
|
||||
expect(optionLabels).toContain('Continue Listening')
|
||||
expect(optionLabels).toContain('Want to Read')
|
||||
it('renders every widget name from the catalog rather than a hardcoded map', async () => {
|
||||
widgetsRef.value = ALL_WIDGETS
|
||||
const wrapper = await openSheet()
|
||||
|
||||
expect(widgetRowLabels(wrapper)).toEqual([
|
||||
en.dashboard.settings.widgetNames.readingStreak,
|
||||
en.dashboard.settings.widgetNames.currentlyReading,
|
||||
en.dashboard.settings.widgetNames.readingGoal,
|
||||
en.dashboard.settings.widgetNames.readingDna,
|
||||
en.dashboard.settings.widgetNames.monthlyChallenge,
|
||||
en.dashboard.settings.widgetNames.highlightOfTheDay,
|
||||
en.dashboard.settings.widgetNames.neglectedGems,
|
||||
en.dashboard.settings.widgetNames.readingRhythm,
|
||||
en.dashboard.settings.widgetNames.diversityScore,
|
||||
en.dashboard.settings.widgetNames.libraryOverview,
|
||||
en.dashboard.settings.widgetNames.yearProjection,
|
||||
en.dashboard.settings.widgetNames.longWait,
|
||||
])
|
||||
})
|
||||
|
||||
it('translates widget names when the locale changes (issue #796)', async () => {
|
||||
widgetsRef.value = ALL_WIDGETS
|
||||
const wrapper = await openSheet()
|
||||
|
||||
await setI18nLocale('pt')
|
||||
await nextTick()
|
||||
|
||||
const labels = widgetRowLabels(wrapper)
|
||||
|
||||
expect(labels).toEqual([
|
||||
pt.dashboard.settings.widgetNames.readingStreak,
|
||||
pt.dashboard.settings.widgetNames.currentlyReading,
|
||||
pt.dashboard.settings.widgetNames.readingGoal,
|
||||
pt.dashboard.settings.widgetNames.readingDna,
|
||||
pt.dashboard.settings.widgetNames.monthlyChallenge,
|
||||
pt.dashboard.settings.widgetNames.highlightOfTheDay,
|
||||
pt.dashboard.settings.widgetNames.neglectedGems,
|
||||
pt.dashboard.settings.widgetNames.readingRhythm,
|
||||
pt.dashboard.settings.widgetNames.diversityScore,
|
||||
pt.dashboard.settings.widgetNames.libraryOverview,
|
||||
pt.dashboard.settings.widgetNames.yearProjection,
|
||||
pt.dashboard.settings.widgetNames.longWait,
|
||||
])
|
||||
expect(labels).not.toContain(en.dashboard.settings.widgetNames.readingStreak)
|
||||
expect(labels).not.toContain(en.dashboard.settings.widgetNames.currentlyReading)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ScrollerConfig, ScrollerType, WidgetConfig } from '@bookorbit/type
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
|
||||
import { DEFAULT_SCROLLERS, SCROLLER_LABELS, useDashboardConfig } from '../composables/useDashboardConfig'
|
||||
import { useDashboardLabels } from '../composables/useDashboardLabels'
|
||||
import { useDashboardWidgets } from '../composables/useDashboardWidgets'
|
||||
import { useDraggableList } from '../composables/useDraggableList'
|
||||
|
||||
@@ -16,8 +17,9 @@ const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { scrollers, saveScrollers, MAX_SCROLLERS } = useDashboardConfig()
|
||||
const { widgets, saveWidgets, WIDGET_LABELS, DEFAULT_WIDGETS } = useDashboardWidgets()
|
||||
const { widgets, saveWidgets, DEFAULT_WIDGETS } = useDashboardWidgets()
|
||||
const { smartScopes, fetchSmartScopes } = useSmartScopes()
|
||||
const { widgetName, shelfTypeName } = useDashboardLabels()
|
||||
|
||||
const activeTab = ref<'widgets' | 'shelves'>('widgets')
|
||||
const draft = ref<ScrollerConfig[]>([])
|
||||
@@ -211,7 +213,7 @@ function resetToDefault() {
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span class="flex-1 text-sm font-medium">{{ WIDGET_LABELS[widget.type] }}</span>
|
||||
<span class="flex-1 text-sm font-medium">{{ widgetName(widget.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,8 +279,8 @@ function resetToDefault() {
|
||||
class="h-8 flex-1 appearance-none rounded-md border border-input bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@change="onTypeChange(scroller)"
|
||||
>
|
||||
<option v-for="t in ALL_TYPES" :key="t" :value="t">
|
||||
{{ SCROLLER_LABELS[t] }}
|
||||
<option v-for="scrollerType in ALL_TYPES" :key="scrollerType" :value="scrollerType">
|
||||
{{ shelfTypeName(scrollerType) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { SCROLLER_TYPES, WIDGET_TYPES, type ScrollerConfig, type ScrollerType, type WidgetType } from '@bookorbit/types'
|
||||
|
||||
import { i18n, setI18nLocale } from '@/i18n'
|
||||
import en from '@/locales/en.json'
|
||||
import pt from '@/locales/pt.json'
|
||||
import { useDashboardLabels } from '../useDashboardLabels'
|
||||
|
||||
// The composable calls useI18n(), so it has to run inside a component setup.
|
||||
function mountComposable(): ReturnType<typeof useDashboardLabels> {
|
||||
let result!: ReturnType<typeof useDashboardLabels>
|
||||
mount(
|
||||
{
|
||||
setup() {
|
||||
result = useDashboardLabels()
|
||||
return () => null
|
||||
},
|
||||
},
|
||||
{ global: { plugins: [i18n] } },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
type CatalogNames = { widgetNames: Record<string, string>; shelfNames: Record<string, string> }
|
||||
|
||||
const CATALOGS: Record<'en' | 'pt', CatalogNames> = {
|
||||
en: en.dashboard.settings,
|
||||
pt: pt.dashboard.settings,
|
||||
}
|
||||
|
||||
// Mirrors the kebab-case type to camel-case message key mapping the composable owns.
|
||||
// Declared independently so a mistake in either map fails the test rather than cancelling out.
|
||||
const WIDGET_KEY_BY_TYPE: Record<WidgetType, string> = {
|
||||
'reading-streak': 'readingStreak',
|
||||
'currently-reading': 'currentlyReading',
|
||||
'reading-goal': 'readingGoal',
|
||||
'reading-dna': 'readingDna',
|
||||
'monthly-challenge': 'monthlyChallenge',
|
||||
'highlight-of-the-day': 'highlightOfTheDay',
|
||||
'neglected-gems': 'neglectedGems',
|
||||
'reading-rhythm': 'readingRhythm',
|
||||
'diversity-score': 'diversityScore',
|
||||
'library-overview': 'libraryOverview',
|
||||
'year-projection': 'yearProjection',
|
||||
'long-wait': 'longWait',
|
||||
}
|
||||
|
||||
const SHELF_KEY_BY_TYPE: Record<ScrollerType, string> = {
|
||||
'recently-added': 'recentlyAdded',
|
||||
'continue-reading': 'continueReading',
|
||||
'continue-listening': 'continueListening',
|
||||
'want-to-read': 'wantToRead',
|
||||
'up-next-in-series': 'upNextInSeries',
|
||||
random: 'random',
|
||||
'smart-scope': 'smartScope',
|
||||
}
|
||||
|
||||
function smartScopeShelf(overrides: Partial<ScrollerConfig> = {}): ScrollerConfig {
|
||||
return { id: '1', type: 'smart-scope', label: 'Unread Favorites', enabled: true, order: 1, limit: 20, smartScopeId: 42, ...overrides }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await setI18nLocale('en')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await setI18nLocale('en')
|
||||
})
|
||||
|
||||
describe('useDashboardLabels', () => {
|
||||
describe('widgetName', () => {
|
||||
it('resolves every widget type through the English catalog', () => {
|
||||
const { widgetName } = mountComposable()
|
||||
|
||||
expect(WIDGET_TYPES.length).toBe(Object.keys(CATALOGS.en.widgetNames).length)
|
||||
for (const type of WIDGET_TYPES) {
|
||||
const key = WIDGET_KEY_BY_TYPE[type]
|
||||
expect(widgetName(type), `widget name for ${type}`).toBe(CATALOGS.en.widgetNames[key])
|
||||
}
|
||||
})
|
||||
|
||||
it('never leaks a raw message path for any widget type', () => {
|
||||
const { widgetName } = mountComposable()
|
||||
|
||||
for (const type of WIDGET_TYPES) {
|
||||
const name = widgetName(type)
|
||||
expect(name.length, `widget name for ${type}`).toBeGreaterThan(0)
|
||||
expect(name, `widget name for ${type}`).not.toContain('dashboard.settings')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every widget type a distinct name so the customization list is unambiguous', () => {
|
||||
const { widgetName } = mountComposable()
|
||||
|
||||
const names = WIDGET_TYPES.map((type) => widgetName(type))
|
||||
|
||||
expect(new Set(names).size).toBe(WIDGET_TYPES.length)
|
||||
})
|
||||
|
||||
it('follows the active locale instead of rendering hardcoded English (issue #796)', async () => {
|
||||
const { widgetName } = mountComposable()
|
||||
|
||||
await setI18nLocale('pt')
|
||||
|
||||
for (const type of WIDGET_TYPES) {
|
||||
const key = WIDGET_KEY_BY_TYPE[type]
|
||||
expect(widgetName(type), `widget name for ${type}`).toBe(CATALOGS.pt.widgetNames[key])
|
||||
}
|
||||
expect(widgetName('reading-streak')).not.toBe(CATALOGS.en.widgetNames.readingStreak)
|
||||
expect(widgetName('currently-reading')).not.toBe(CATALOGS.en.widgetNames.currentlyReading)
|
||||
})
|
||||
|
||||
it('re-resolves when the locale changes back', async () => {
|
||||
const { widgetName } = mountComposable()
|
||||
|
||||
await setI18nLocale('pt')
|
||||
const translated = widgetName('reading-streak')
|
||||
await setI18nLocale('en')
|
||||
|
||||
expect(translated).not.toBe(widgetName('reading-streak'))
|
||||
expect(widgetName('reading-streak')).toBe(CATALOGS.en.widgetNames.readingStreak)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shelfTypeName', () => {
|
||||
it('resolves every scroller type through the English catalog', () => {
|
||||
const { shelfTypeName } = mountComposable()
|
||||
|
||||
expect(SCROLLER_TYPES.length).toBe(Object.keys(CATALOGS.en.shelfNames).length)
|
||||
for (const type of SCROLLER_TYPES) {
|
||||
const key = SHELF_KEY_BY_TYPE[type]
|
||||
expect(shelfTypeName(type), `shelf name for ${type}`).toBe(CATALOGS.en.shelfNames[key])
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every scroller type a distinct name so the type selector is unambiguous', () => {
|
||||
const { shelfTypeName } = mountComposable()
|
||||
|
||||
const names = SCROLLER_TYPES.map((type) => shelfTypeName(type))
|
||||
|
||||
expect(new Set(names).size).toBe(SCROLLER_TYPES.length)
|
||||
})
|
||||
|
||||
it('follows the active locale', async () => {
|
||||
const { shelfTypeName } = mountComposable()
|
||||
|
||||
await setI18nLocale('pt')
|
||||
|
||||
for (const type of SCROLLER_TYPES) {
|
||||
const key = SHELF_KEY_BY_TYPE[type]
|
||||
expect(shelfTypeName(type), `shelf name for ${type}`).toBe(CATALOGS.pt.shelfNames[key])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shelfTitle', () => {
|
||||
it('ignores the persisted English label and resolves built-in shelves from their type', async () => {
|
||||
const { shelfTitle, shelfTypeName } = mountComposable()
|
||||
// Configs saved before localization stored a fixed English label that would otherwise
|
||||
// survive a language change.
|
||||
const stored: ScrollerConfig = { id: '1', type: 'continue-reading', label: 'Continue Reading', enabled: true, order: 1, limit: 20 }
|
||||
|
||||
expect(shelfTitle(stored)).toBe(CATALOGS.en.shelfNames.continueReading)
|
||||
|
||||
await setI18nLocale('pt')
|
||||
|
||||
expect(shelfTitle(stored)).toBe(CATALOGS.pt.shelfNames.continueReading)
|
||||
expect(shelfTitle(stored)).toBe(shelfTypeName('continue-reading'))
|
||||
})
|
||||
|
||||
it('resolves every built-in shelf type from its type rather than its label', () => {
|
||||
const { shelfTitle, shelfTypeName } = mountComposable()
|
||||
|
||||
for (const type of SCROLLER_TYPES) {
|
||||
if (type === 'smart-scope') continue
|
||||
const stored: ScrollerConfig = { id: '1', type, label: 'Stale Stored Label', enabled: true, order: 1, limit: 20 }
|
||||
expect(shelfTitle(stored), `shelf title for ${type}`).toBe(shelfTypeName(type))
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the user-authored smart scope name untranslated', async () => {
|
||||
const { shelfTitle } = mountComposable()
|
||||
const shelf = smartScopeShelf({ label: 'Unread Favorites' })
|
||||
|
||||
expect(shelfTitle(shelf)).toBe('Unread Favorites')
|
||||
|
||||
await setI18nLocale('pt')
|
||||
|
||||
expect(shelfTitle(shelf)).toBe('Unread Favorites')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace from a smart scope name', () => {
|
||||
const { shelfTitle } = mountComposable()
|
||||
|
||||
expect(shelfTitle(smartScopeShelf({ label: ' Unread Favorites ' }))).toBe('Unread Favorites')
|
||||
})
|
||||
|
||||
it('falls back to the localized generic name when a smart scope shelf references no scope', async () => {
|
||||
const { shelfTitle } = mountComposable()
|
||||
const shelf = smartScopeShelf({ smartScopeId: undefined, label: 'SmartScope' })
|
||||
|
||||
expect(shelfTitle(shelf)).toBe(CATALOGS.en.shelfNames.smartScope)
|
||||
|
||||
await setI18nLocale('pt')
|
||||
|
||||
expect(shelfTitle(shelf)).toBe(CATALOGS.pt.shelfNames.smartScope)
|
||||
})
|
||||
|
||||
it('falls back to the localized generic name when the smart scope name is blank', () => {
|
||||
const { shelfTitle } = mountComposable()
|
||||
|
||||
expect(shelfTitle(smartScopeShelf({ label: ' ' }))).toBe(CATALOGS.en.shelfNames.smartScope)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ export const DEFAULT_SCROLLERS: ScrollerConfig[] = [
|
||||
{ id: '4', type: 'up-next-in-series', label: 'Up Next in Series', enabled: false, order: 6, limit: 20 },
|
||||
]
|
||||
|
||||
// Persisted-only. Shelf headings and the type selector resolve their text from the active
|
||||
// locale via useDashboardLabels(); these values just keep stored configs shaped as before.
|
||||
export const SCROLLER_LABELS: Record<ScrollerType, string> = {
|
||||
'continue-reading': 'Continue Reading',
|
||||
'continue-listening': 'Continue Listening',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ScrollerConfig, ScrollerType, WidgetType } from '@bookorbit/types'
|
||||
import type { MessageSchema } from '@/i18n'
|
||||
|
||||
type WidgetNameKey = keyof MessageSchema['dashboard']['settings']['widgetNames']
|
||||
type ShelfNameKey = keyof MessageSchema['dashboard']['settings']['shelfNames']
|
||||
|
||||
// Typed against the English catalog so a renamed or removed message fails typecheck
|
||||
// instead of silently rendering a raw key path.
|
||||
const WIDGET_NAME_KEYS: Record<WidgetType, WidgetNameKey> = {
|
||||
'reading-streak': 'readingStreak',
|
||||
'currently-reading': 'currentlyReading',
|
||||
'reading-goal': 'readingGoal',
|
||||
'reading-dna': 'readingDna',
|
||||
'monthly-challenge': 'monthlyChallenge',
|
||||
'highlight-of-the-day': 'highlightOfTheDay',
|
||||
'neglected-gems': 'neglectedGems',
|
||||
'reading-rhythm': 'readingRhythm',
|
||||
'diversity-score': 'diversityScore',
|
||||
'library-overview': 'libraryOverview',
|
||||
'year-projection': 'yearProjection',
|
||||
'long-wait': 'longWait',
|
||||
}
|
||||
|
||||
const SHELF_NAME_KEYS: Record<ScrollerType, ShelfNameKey> = {
|
||||
'recently-added': 'recentlyAdded',
|
||||
'continue-reading': 'continueReading',
|
||||
'continue-listening': 'continueListening',
|
||||
'want-to-read': 'wantToRead',
|
||||
'up-next-in-series': 'upNextInSeries',
|
||||
random: 'random',
|
||||
'smart-scope': 'smartScope',
|
||||
}
|
||||
|
||||
export function useDashboardLabels() {
|
||||
const { t } = useI18n()
|
||||
|
||||
function widgetName(type: WidgetType): string {
|
||||
return t(`dashboard.settings.widgetNames.${WIDGET_NAME_KEYS[type]}`)
|
||||
}
|
||||
|
||||
function shelfTypeName(type: ScrollerType): string {
|
||||
return t(`dashboard.settings.shelfNames.${SHELF_NAME_KEYS[type]}`)
|
||||
}
|
||||
|
||||
// A Smart Scope shelf is titled with the user's own scope name, which is content and stays
|
||||
// untranslated. Every other shelf resolves from its type: the persisted `label` predates
|
||||
// localization and holds a fixed English string that would survive a language change.
|
||||
function shelfTitle(scroller: ScrollerConfig): string {
|
||||
if (scroller.type !== 'smart-scope') return shelfTypeName(scroller.type)
|
||||
const smartScopeName = scroller.smartScopeId ? scroller.label.trim() : ''
|
||||
return smartScopeName || shelfTypeName('smart-scope')
|
||||
}
|
||||
|
||||
return { widgetName, shelfTypeName, shelfTitle }
|
||||
}
|
||||
@@ -5,21 +5,6 @@ import { WIDGET_TYPES } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
|
||||
const WIDGET_LABELS: Record<WidgetType, string> = {
|
||||
'reading-goal': 'Reading Goal',
|
||||
'currently-reading': 'Currently Reading',
|
||||
'reading-streak': 'Reading Streak',
|
||||
'library-overview': 'Library Overview',
|
||||
'highlight-of-the-day': 'Highlight of the Day',
|
||||
'monthly-challenge': 'Monthly Challenge',
|
||||
'year-projection': 'Year Projection',
|
||||
'neglected-gems': 'Neglected Gems',
|
||||
'reading-dna': 'Reading DNA',
|
||||
'long-wait': 'The Long Wait',
|
||||
'diversity-score': 'Diversity Score',
|
||||
'reading-rhythm': 'Reading Rhythm',
|
||||
}
|
||||
|
||||
const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||
{ id: '1', type: 'reading-streak', enabled: true, order: 1 },
|
||||
{ id: '2', type: 'currently-reading', enabled: true, order: 2 },
|
||||
@@ -134,7 +119,6 @@ export function useDashboardWidgets() {
|
||||
readingGoal,
|
||||
saveWidgets,
|
||||
saveReadingGoal,
|
||||
WIDGET_LABELS,
|
||||
DEFAULT_WIDGETS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Regale"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Lese-Serie",
|
||||
"currentlyReading": "Aktuell gelesen",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Lese-DNA",
|
||||
"monthlyChallenge": "Monatliche Herausforderung",
|
||||
"highlightOfTheDay": "Highlight des Tages",
|
||||
"neglectedGems": "Vernachlässigte Schätze",
|
||||
"readingRhythm": "Leserhythmus",
|
||||
"diversityScore": "Diversitätswert",
|
||||
"libraryOverview": "Bibliotheksübersicht",
|
||||
"yearProjection": "Jahresprognose",
|
||||
"longWait": "Die lange Wartezeit"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Ziehen zum Neuanordnen. Umschalten, um ein Widget ein- oder auszublenden.",
|
||||
"reorderHintShelf": "Ziehen zum Neuanordnen. Umschalten, um ein Regal ein- oder auszublenden.",
|
||||
"smartScope": "Smart Scope",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Shelves"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Reading Streak",
|
||||
"currentlyReading": "Currently Reading",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Reading DNA",
|
||||
"monthlyChallenge": "Monthly Challenge",
|
||||
"highlightOfTheDay": "Highlight of the Day",
|
||||
"neglectedGems": "Neglected Gems",
|
||||
"readingRhythm": "Reading Rhythm",
|
||||
"diversityScore": "Diversity Score",
|
||||
"libraryOverview": "Library Overview",
|
||||
"yearProjection": "Year Projection",
|
||||
"longWait": "The Long Wait"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Drag to reorder. Toggle to show or hide a widget.",
|
||||
"reorderHintShelf": "Drag to reorder. Toggle to show or hide a shelf.",
|
||||
"smartScope": "Smart Scope",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "widgets",
|
||||
"shelves": "estantes"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Racha de lectura",
|
||||
"currentlyReading": "Actualmente leyendo",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Lectura de ADN",
|
||||
"monthlyChallenge": "Reto Mensual",
|
||||
"highlightOfTheDay": "Lo más destacado del día",
|
||||
"neglectedGems": "Gemas olvidadas",
|
||||
"readingRhythm": "Ritmo de lectura",
|
||||
"diversityScore": "Puntuación de diversidad",
|
||||
"libraryOverview": "Descripción general de la biblioteca",
|
||||
"yearProjection": "Proyección del año",
|
||||
"longWait": "La larga espera"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Arrastre para reordenar. Alternar para mostrar u ocultar un widget.",
|
||||
"reorderHintShelf": "Arrastre para reordenar. Alternar para mostrar u ocultar un estante.",
|
||||
"smartScope": "Alcance inteligente",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Étagères"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Série de lecture",
|
||||
"currentlyReading": "Lecture en cours",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Lire l'ADN",
|
||||
"monthlyChallenge": "Défi mensuel",
|
||||
"highlightOfTheDay": "Le moment fort de la journée",
|
||||
"neglectedGems": "Joyaux négligés",
|
||||
"readingRhythm": "Rythme de lecture",
|
||||
"diversityScore": "Score de diversité",
|
||||
"libraryOverview": "Présentation de la bibliothèque",
|
||||
"yearProjection": "Année projetée",
|
||||
"longWait": "La longue attente"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Faites glisser pour réorganiser. Basculez pour afficher ou masquer un widget.",
|
||||
"reorderHintShelf": "Faites glisser pour réorganiser. Basculez pour afficher ou masquer une étagère.",
|
||||
"smartScope": "Portée intelligente",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widget",
|
||||
"shelves": "Scaffali"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Striscia di lettura",
|
||||
"currentlyReading": "Attualmente sto leggendo",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Leggere il DNA",
|
||||
"monthlyChallenge": "Sfida mensile",
|
||||
"highlightOfTheDay": "Momento clou della giornata",
|
||||
"neglectedGems": "Gemme trascurate",
|
||||
"readingRhythm": "Ritmo di lettura",
|
||||
"diversityScore": "Punteggio di diversità",
|
||||
"libraryOverview": "Panoramica della biblioteca",
|
||||
"yearProjection": "Proiezione dell'anno",
|
||||
"longWait": "La lunga attesa"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Trascina per riordinare. Seleziona per mostrare o nascondere un widget.",
|
||||
"reorderHintShelf": "Trascina per riordinare. Attiva/disattiva per mostrare o nascondere uno scaffale.",
|
||||
"smartScope": "Ambito intelligente",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Planken"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Leesreeks",
|
||||
"currentlyReading": "Nu aan het lezen",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Lees-DNA",
|
||||
"monthlyChallenge": "Maandelijkse uitdaging",
|
||||
"highlightOfTheDay": "Markering van de dag",
|
||||
"neglectedGems": "Vergeten pareltjes",
|
||||
"readingRhythm": "Leesritme",
|
||||
"diversityScore": "Diversiteitsscore",
|
||||
"libraryOverview": "Bibliotheekoverzicht",
|
||||
"yearProjection": "Jaarprognose",
|
||||
"longWait": "Het lange wachten"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Sleep om te herordenen. Schakel in of uit om een widget te tonen of verbergen.",
|
||||
"reorderHintShelf": "Sleep om te herordenen. Schakel in of uit om een plank te tonen of verbergen.",
|
||||
"smartScope": "Smart Scope",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widżety",
|
||||
"shelves": "Półki"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Czytelnicza passa",
|
||||
"currentlyReading": "Aktualnie czytam",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Czytanie DNA",
|
||||
"monthlyChallenge": "Comiesięczne wyzwanie",
|
||||
"highlightOfTheDay": "Najważniejsza atrakcja dnia",
|
||||
"neglectedGems": "Zaniedbane klejnoty",
|
||||
"readingRhythm": "Rytm czytania",
|
||||
"diversityScore": "Wynik różnorodności",
|
||||
"libraryOverview": "Przegląd biblioteki",
|
||||
"yearProjection": "Projekcja roku",
|
||||
"longWait": "Długie oczekiwanie"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Przeciągnij, aby zmienić kolejność. Przełącz, aby pokazać lub ukryć widżet.",
|
||||
"reorderHintShelf": "Przeciągnij, aby zmienić kolejność. Przełącz, aby pokazać lub ukryć półkę.",
|
||||
"smartScope": "Inteligentny zakres",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Widgets",
|
||||
"shelves": "Prateleiras"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Sequência de Leitura",
|
||||
"currentlyReading": "Lendo Atualmente",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "DNA de Leitura",
|
||||
"monthlyChallenge": "Desafio Mensal",
|
||||
"highlightOfTheDay": "Destaque do Dia",
|
||||
"neglectedGems": "Joias Negligenciadas",
|
||||
"readingRhythm": "Ritmo de Leitura",
|
||||
"diversityScore": "Score de Diversidade",
|
||||
"libraryOverview": "Visão Geral da Biblioteca",
|
||||
"yearProjection": "Projeção Anual",
|
||||
"longWait": "O Longo Esperar"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Arraste para reordenar. Alterne para mostrar ou ocultar um widget.",
|
||||
"reorderHintShelf": "Arraste para reordenar. Alterne para mostrar ou ocultar uma prateleira.",
|
||||
"smartScope": "SmartScope",
|
||||
|
||||
@@ -4584,6 +4584,29 @@
|
||||
"widgets": "Gradniki",
|
||||
"shelves": "Police"
|
||||
},
|
||||
"widgetNames": {
|
||||
"readingStreak": "Bralni niz",
|
||||
"currentlyReading": "Trenutno bereš",
|
||||
"readingGoal": "Reading Goal",
|
||||
"readingDna": "Bralni DNK",
|
||||
"monthlyChallenge": "Mesečni izziv",
|
||||
"highlightOfTheDay": "Poudarek dneva",
|
||||
"neglectedGems": "Zapostavljeni biseri",
|
||||
"readingRhythm": "Bralni ritem",
|
||||
"diversityScore": "Ocena raznolikosti",
|
||||
"libraryOverview": "Pregled knjižnice",
|
||||
"yearProjection": "Letna napoved",
|
||||
"longWait": "Dolgo čakanje"
|
||||
},
|
||||
"shelfNames": {
|
||||
"recentlyAdded": "Recently Added",
|
||||
"continueReading": "Continue Reading",
|
||||
"continueListening": "Continue Listening",
|
||||
"wantToRead": "Want to Read",
|
||||
"upNextInSeries": "Up Next in Series",
|
||||
"random": "Discover Something New",
|
||||
"smartScope": "Smart Scope"
|
||||
},
|
||||
"reorderHintWidget": "Povleci za spremembo vrstnega reda. Preklopi za prikaz ali skritje gradnika.",
|
||||
"reorderHintShelf": "Povleci za spremembo vrstnega reda. Preklopi za prikaz ali skritje police.",
|
||||
"smartScope": "Pametna zbirka",
|
||||
|
||||
@@ -13,6 +13,7 @@ import DashboardSettingsSheet from '@/features/dashboard/components/DashboardSet
|
||||
import DashboardWelcome from '@/features/dashboard/components/DashboardWelcome.vue'
|
||||
import DashboardWidgetRow from '@/features/dashboard/components/DashboardWidgetRow.vue'
|
||||
import { useDashboardConfig } from '@/features/dashboard/composables/useDashboardConfig'
|
||||
import { useDashboardLabels } from '@/features/dashboard/composables/useDashboardLabels'
|
||||
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
|
||||
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
|
||||
|
||||
@@ -21,6 +22,7 @@ const { hasPermission } = usePermissions()
|
||||
const { user } = useAuth()
|
||||
const { libraries, loading: librariesLoading, fetchLibraries } = useLibraries()
|
||||
const { scrollers, pruneDeletedSmartScopeScrollers } = useDashboardConfig()
|
||||
const { shelfTitle } = useDashboardLabels()
|
||||
const { maybeStartTour } = useOnboardingTour()
|
||||
const { smartScopes, loaded: smartScopesLoaded, fetchSmartScopes } = useSmartScopes()
|
||||
|
||||
@@ -108,7 +110,7 @@ onUnmounted(() => {
|
||||
v-for="(scroller, index) in enabledScrollers"
|
||||
:key="`${scroller.id}-${scroller.type}-${scroller.smartScopeId ?? 0}`"
|
||||
:type="scroller.type"
|
||||
:title="scroller.label"
|
||||
:title="shelfTitle(scroller)"
|
||||
:limit="scroller.limit"
|
||||
:smartScope-id="scroller.smartScopeId"
|
||||
class="animate-fade-up"
|
||||
|
||||
Reference in New Issue
Block a user