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:
neon
2026-07-28 21:43:09 +00:00
parent 75a7cd58b2
commit 648a6cacf2
16 changed files with 608 additions and 45 deletions
@@ -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,
}
}
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+23
View File
@@ -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",
+3 -1
View File
@@ -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"