From 4698ec0945cfd9e893e21f9bd212f94375e0b0c6 Mon Sep 17 00:00:00 2001 From: Yevhen Husak Date: Sun, 8 Feb 2026 01:10:44 +0000 Subject: [PATCH 01/12] feat: add server-synced user preferences infrastructure (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - introduce the foundational layer for persisting user preferences to the server - add UserPreferencesSchema and shared types for user preferences - add client-only sync composable with debounced saves, route guard flush, and sendBeacon fallback - integrate server sync into useSettings and migrate to shared UserPreferences type - extract generic localStorage helpers, migrate consumers, remove usePreferencesProvider --- .../useLocalStorageHashProvider.ts | 42 +++ app/composables/usePackageListPreferences.ts | 7 +- app/composables/usePreferencesProvider.ts | 109 -------- app/composables/useSettings.ts | 255 ------------------ .../useUserPreferencesSync.client.ts | 154 +++++++++++ app/utils/storage.ts | 34 +++ modules/cache.ts | 3 + nuxt.config.ts | 5 + server/api/user/preferences.get.ts | 15 ++ server/api/user/preferences.put.ts | 20 ++ .../preferences/user-preferences-store.ts | 49 ++++ shared/schemas/userPreferences.ts | 43 +++ 12 files changed, 368 insertions(+), 368 deletions(-) create mode 100644 app/composables/useLocalStorageHashProvider.ts delete mode 100644 app/composables/usePreferencesProvider.ts delete mode 100644 app/composables/useSettings.ts create mode 100644 app/composables/useUserPreferencesSync.client.ts create mode 100644 app/utils/storage.ts create mode 100644 server/api/user/preferences.get.ts create mode 100644 server/api/user/preferences.put.ts create mode 100644 server/utils/preferences/user-preferences-store.ts create mode 100644 shared/schemas/userPreferences.ts diff --git a/app/composables/useLocalStorageHashProvider.ts b/app/composables/useLocalStorageHashProvider.ts new file mode 100644 index 0000000000..f86281897e --- /dev/null +++ b/app/composables/useLocalStorageHashProvider.ts @@ -0,0 +1,42 @@ +import { createDefu } from 'defu' +import { createLocalStorageProvider } from '~/utils/storage' + +const defu = createDefu((object, key, value) => { + if (Array.isArray(object[key]) && Array.isArray(value)) { + object[key] = value + return true + } +}) + +export function useLocalStorageHashProvider(key: string, defaultValue: T) { + const provider = createLocalStorageProvider(key) + const data = ref(defaultValue) + + onMounted(() => { + const stored = provider.get() + if (stored) { + data.value = defu(stored, defaultValue) + } + }) + + function save() { + provider.set(data.value) + } + + function reset() { + data.value = { ...defaultValue } + provider.remove() + } + + function update(key: K, value: T[K]) { + data.value[key] = value + save() + } + + return { + data, + save, + reset, + update, + } +} diff --git a/app/composables/usePackageListPreferences.ts b/app/composables/usePackageListPreferences.ts index 9d6540e0c0..ae8eaca599 100644 --- a/app/composables/usePackageListPreferences.ts +++ b/app/composables/usePackageListPreferences.ts @@ -11,18 +11,18 @@ import type { } from '#shared/types/preferences' import { DEFAULT_COLUMNS, DEFAULT_PREFERENCES } from '#shared/types/preferences' +const STORAGE_KEY = 'npmx-list-prefs' + /** * Composable for managing package list display preferences * Persists to localStorage and provides reactive state - * */ export function usePackageListPreferences() { const { data: preferences, - isHydrated, save, reset, - } = usePreferencesProvider(DEFAULT_PREFERENCES) + } = useLocalStorageHashProvider(STORAGE_KEY, DEFAULT_PREFERENCES) // Computed accessors for common properties const viewMode = computed({ @@ -106,7 +106,6 @@ export function usePackageListPreferences() { return { // Raw preferences preferences, - isHydrated, // Individual properties with setters viewMode, diff --git a/app/composables/usePreferencesProvider.ts b/app/composables/usePreferencesProvider.ts deleted file mode 100644 index 06955aeabd..0000000000 --- a/app/composables/usePreferencesProvider.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createDefu } from 'defu' - -/** - * Abstraction for preferences storage - * Currently uses localStorage, designed for future user prefs API - */ - -const STORAGE_KEY = 'npmx-list-prefs' - -interface StorageProvider { - get: () => T | null - set: (value: T) => void - remove: () => void -} - -const defu = createDefu((object, key, value) => { - if (Array.isArray(object[key]) && Array.isArray(value)) { - object[key] = value - return true - } -}) - -/** - * Creates a localStorage-based storage provider - */ -function createLocalStorageProvider(key: string): StorageProvider { - return { - get: () => { - if (import.meta.server) return null - try { - const stored = localStorage.getItem(key) - if (stored) { - return JSON.parse(stored) as T - } - } catch { - // Corrupted data, remove it - localStorage.removeItem(key) - } - return null - }, - set: (value: T) => { - if (import.meta.server) return - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - // Storage full or other error, fail silently - } - }, - remove: () => { - if (import.meta.server) return - localStorage.removeItem(key) - }, - } -} - -// Future: API-based provider would look like this: -// function createApiStorageProvider(endpoint: string): StorageProvider { -// return { -// get: async () => { /* fetch from API */ }, -// set: async (value) => { /* POST to API */ }, -// remove: async () => { /* DELETE from API */ }, -// } -// } - -/** - * Composable for managing preferences storage - * Abstracts the storage mechanism to allow future migration to API-based storage - * - */ -export function usePreferencesProvider(defaultValue: T) { - const provider = createLocalStorageProvider(STORAGE_KEY) - const data = ref(defaultValue) - const isHydrated = shallowRef(false) - - // Load from storage on client - onMounted(() => { - const stored = provider.get() - if (stored) { - // Merge stored values with defaults to handle schema evolution - data.value = defu(stored, defaultValue) - } - isHydrated.value = true - }) - - // Persist changes - function save() { - provider.set(data.value) - } - - // Reset to defaults - function reset() { - data.value = { ...defaultValue } - provider.remove() - } - - // Update specific keys - function update(key: K, value: T[K]) { - data.value[key] = value - save() - } - - return { - data, - isHydrated, - save, - reset, - update, - } -} diff --git a/app/composables/useSettings.ts b/app/composables/useSettings.ts deleted file mode 100644 index 533c03042b..0000000000 --- a/app/composables/useSettings.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { RemovableRef } from '@vueuse/core' -import { useLocalStorage } from '@vueuse/core' -import { ACCENT_COLORS, type AccentColorId } from '#shared/utils/constants' -import type { LocaleObject } from '@nuxtjs/i18n' -import { BACKGROUND_THEMES } from '#shared/utils/constants' - -type BackgroundThemeId = keyof typeof BACKGROUND_THEMES - -/** Available search providers */ -export type SearchProvider = 'npm' | 'algolia' - -/** - * Application settings stored in localStorage - */ -export interface AppSettings { - /** Display dates as relative (e.g., "3 days ago") instead of absolute */ - relativeDates: boolean - /** Include @types/* package in install command for packages without built-in types */ - includeTypesInInstall: boolean - /** Accent color theme */ - accentColorId: AccentColorId | null - /** Preferred background shade */ - preferredBackgroundTheme: BackgroundThemeId | null - /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ - hidePlatformPackages: boolean - /** Enable weekly download graph pulse looping animation */ - enableGraphPulseLooping: boolean - /** User-selected locale */ - selectedLocale: LocaleObject['code'] | null - /** Search provider for package search */ - searchProvider: SearchProvider - /** Show search results as you type */ - instantSearch: boolean - /** Enable/disable keyboard shortcuts */ - keyboardShortcuts: boolean - /** Connector preferences */ - connector: { - /** Automatically open the web auth page in the browser */ - autoOpenURL: boolean - } - codeContainerFull: boolean - sidebar: { - collapsed: string[] - } - chartFilter: { - averageWindow: number - smoothingTau: number - anomaliesFixed: boolean - predictionPoints: number - } -} - -const DEFAULT_SETTINGS: AppSettings = { - relativeDates: false, - includeTypesInInstall: true, - accentColorId: null, - hidePlatformPackages: true, - enableGraphPulseLooping: false, - selectedLocale: null, - preferredBackgroundTheme: null, - searchProvider: import.meta.test ? 'npm' : 'algolia', - instantSearch: true, - keyboardShortcuts: true, - connector: { - autoOpenURL: false, - }, - codeContainerFull: false, - sidebar: { - collapsed: [], - }, - chartFilter: { - averageWindow: 0, - smoothingTau: 1, - anomaliesFixed: true, - predictionPoints: 4, - }, -} - -const STORAGE_KEY = 'npmx-settings' - -// Shared settings instance (singleton per app) -let settingsRef: RemovableRef | null = null - -/** - * Composable for managing application settings with localStorage persistence. - * Settings are shared across all components that use this composable. - */ -export function useSettings() { - if (!settingsRef) { - settingsRef = useLocalStorage(STORAGE_KEY, DEFAULT_SETTINGS, { - mergeDefaults: true, - }) - } - - return { - settings: settingsRef, - } -} - -/** - * Composable for accessing just the relative dates setting. - * Useful for components that only need to read this specific setting. - */ -export function useRelativeDates() { - const { settings } = useSettings() - return computed(() => settings.value.relativeDates) -} - -/** - * Composable for accessing just the keyboard shortcuts setting. - * Useful for components that only need to read this specific setting. - */ -export const useKeyboardShortcuts = createSharedComposable(function useKeyboardShortcuts() { - const { settings } = useSettings() - const enabled = computed(() => settings.value.keyboardShortcuts) - - if (import.meta.client) { - watch( - enabled, - value => { - if (value) { - delete document.documentElement.dataset.kbdShortcuts - } else { - document.documentElement.dataset.kbdShortcuts = 'false' - } - }, - { immediate: true }, - ) - } - - return enabled -}) - -/** - * Composable for managing accent color. - */ -export function useAccentColor() { - const { settings } = useSettings() - const colorMode = useColorMode() - const { t } = useI18n() - - const accentColorLabels = computed>(() => ({ - sky: t('settings.accent_colors.sky'), - coral: t('settings.accent_colors.coral'), - amber: t('settings.accent_colors.amber'), - emerald: t('settings.accent_colors.emerald'), - violet: t('settings.accent_colors.violet'), - magenta: t('settings.accent_colors.magenta'), - neutral: t('settings.clear_accent'), - })) - - const accentColors = computed(() => { - const isDark = colorMode.value === 'dark' - const colors = isDark ? ACCENT_COLORS.dark : ACCENT_COLORS.light - - return Object.entries(colors).map(([id, value]) => ({ - id: id as AccentColorId, - label: accentColorLabels.value[id as AccentColorId], - value, - })) - }) - - function setAccentColor(id: AccentColorId | null) { - if (id) { - document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`) - } else { - document.documentElement.style.removeProperty('--accent-color') - } - settings.value.accentColorId = id - } - - return { - accentColors, - selectedAccentColor: computed(() => settings.value.accentColorId), - setAccentColor, - } -} - -/** - * Composable for managing the search provider setting. - */ -export function useSearchProvider() { - const { settings } = useSettings() - - const searchProvider = computed({ - get: () => settings.value.searchProvider, - set: (value: SearchProvider) => { - settings.value.searchProvider = value - }, - }) - - const isAlgolia = computed(() => searchProvider.value === 'algolia') - - function toggle() { - searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm' - } - - return { - searchProvider, - isAlgolia, - toggle, - } -} - -export function useBackgroundTheme() { - const { t } = useI18n() - - const bgThemeLabels = computed>(() => ({ - neutral: t('settings.background_themes.neutral'), - stone: t('settings.background_themes.stone'), - zinc: t('settings.background_themes.zinc'), - slate: t('settings.background_themes.slate'), - black: t('settings.background_themes.black'), - })) - - const backgroundThemes = computed(() => - Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({ - id: id as BackgroundThemeId, - label: bgThemeLabels.value[id as BackgroundThemeId], - value, - })), - ) - - const { settings } = useSettings() - - function setBackgroundTheme(id: BackgroundThemeId | null) { - if (id) { - document.documentElement.dataset.bgTheme = id - } else { - document.documentElement.removeAttribute('data-bg-theme') - } - settings.value.preferredBackgroundTheme = id - } - - return { - backgroundThemes, - selectedBackgroundTheme: computed(() => settings.value.preferredBackgroundTheme), - setBackgroundTheme, - } -} - -export function useCodeContainer() { - const { settings } = useSettings() - - const codeContainerFull = computed(() => settings.value.codeContainerFull) - - function toggleCodeContainer() { - settings.value.codeContainerFull = !settings.value.codeContainerFull - } - - return { - codeContainerFull, - toggleCodeContainer, - } -} diff --git a/app/composables/useUserPreferencesSync.client.ts b/app/composables/useUserPreferencesSync.client.ts new file mode 100644 index 0000000000..9b67711538 --- /dev/null +++ b/app/composables/useUserPreferencesSync.client.ts @@ -0,0 +1,154 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences' + +const SYNC_DEBOUNCE_MS = 2000 + +type SyncStatus = 'idle' | 'syncing' | 'synced' | 'error' + +interface PreferencesSyncState { + status: Ref + lastSyncedAt: Ref + error: Ref +} + +let syncStateInstance: PreferencesSyncState | null = null +let pendingSavePromise: Promise | null = null +let hasPendingChanges = false +let debounceTimeoutId: ReturnType | null = null + +function getSyncState(): PreferencesSyncState { + if (!syncStateInstance) { + syncStateInstance = { + status: ref('idle'), + lastSyncedAt: ref(null), + error: ref(null), + } + } + return syncStateInstance +} + +async function fetchServerPreferences(): Promise { + try { + const response = await $fetch('/api/user/preferences', { + method: 'GET', + }) + return response + } catch { + return null + } +} + +async function saveToServer(preferences: UserPreferences): Promise { + const state = getSyncState() + state.status.value = 'syncing' + state.error.value = null + + try { + await $fetch('/api/user/preferences', { + method: 'PUT', + body: preferences, + }) + state.status.value = 'synced' + state.lastSyncedAt.value = new Date() + hasPendingChanges = false + return true + } catch (err) { + state.status.value = 'error' + state.error.value = err instanceof Error ? err.message : 'Failed to save preferences' + return false + } +} + +function cancelPendingDebounce(): void { + if (debounceTimeoutId) { + clearTimeout(debounceTimeoutId) + debounceTimeoutId = null + } +} + +export function useUserPreferencesSync() { + const { user } = useAtproto() + const state = getSyncState() + const router = useRouter() + + const isAuthenticated = computed(() => !!user.value?.did) + + function scheduleSync(preferences: UserPreferences): void { + if (!isAuthenticated.value) return + + hasPendingChanges = true + cancelPendingDebounce() + + debounceTimeoutId = setTimeout(async () => { + debounceTimeoutId = null + pendingSavePromise = saveToServer(preferences) + await pendingSavePromise + pendingSavePromise = null + }, SYNC_DEBOUNCE_MS) + } + + async function loadFromServer(): Promise { + if (!isAuthenticated.value) { + return { ...DEFAULT_USER_PREFERENCES } + } + + state.status.value = 'syncing' + const serverPreferences = await fetchServerPreferences() + + if (serverPreferences) { + state.status.value = 'synced' + state.lastSyncedAt.value = new Date() + return serverPreferences + } + + state.status.value = 'idle' + return { ...DEFAULT_USER_PREFERENCES } + } + + async function flushPendingSync(preferences: UserPreferences): Promise { + if (!isAuthenticated.value || !hasPendingChanges) return + + cancelPendingDebounce() + + if (pendingSavePromise) { + await pendingSavePromise + } else { + await saveToServer(preferences) + } + } + + function setupRouteGuard(getPreferences: () => UserPreferences): void { + router.beforeEach(async (_to, _from, next) => { + if (hasPendingChanges && isAuthenticated.value) { + await flushPendingSync(getPreferences()) + } + next() + }) + } + + function setupBeforeUnload(getPreferences: () => UserPreferences): void { + if (import.meta.server) return + + window.addEventListener('beforeunload', () => { + if (hasPendingChanges && isAuthenticated.value) { + const preferences = getPreferences() + navigator.sendBeacon( + '/api/user/preferences', + new Blob([JSON.stringify(preferences)], { type: 'application/json' }), + ) + } + }) + } + + return { + isAuthenticated, + status: state.status, + lastSyncedAt: state.lastSyncedAt, + error: state.error, + loadFromServer, + scheduleSync, + flushPendingSync, + setupRouteGuard, + setupBeforeUnload, + } +} diff --git a/app/utils/storage.ts b/app/utils/storage.ts new file mode 100644 index 0000000000..9706226a3f --- /dev/null +++ b/app/utils/storage.ts @@ -0,0 +1,34 @@ +export interface StorageProvider { + get: () => T | null + set: (value: T) => void + remove: () => void +} + +export function createLocalStorageProvider(key: string): StorageProvider { + return { + get: () => { + if (import.meta.server) return null + try { + const stored = localStorage.getItem(key) + if (stored) { + return JSON.parse(stored) as T + } + } catch { + localStorage.removeItem(key) + } + return null + }, + set: (value: T) => { + if (import.meta.server) return + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Storage full or other error, fail silently + } + }, + remove: () => { + if (import.meta.server) return + localStorage.removeItem(key) + }, + } +} diff --git a/modules/cache.ts b/modules/cache.ts index ab317475af..9a89eb1f6e 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -51,6 +51,9 @@ export default defineNuxtModule({ const env = process.env.VERCEL_ENV nitroConfig.storage.atproto = env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } + + nitroConfig.storage['user-preferences'] = + env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } }) }, }) diff --git a/nuxt.config.ts b/nuxt.config.ts index 6a317eed0d..1a7011f6ee 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -137,6 +137,7 @@ export default defineNuxtConfig({ // never cache '/api/auth/**': { isr: false, cache: false }, '/api/social/**': { isr: false, cache: false }, + '/api/user/**': { isr: false, cache: false }, '/api/atproto/bluesky-comments': { isr: { expiration: 60 * 60 /* one hour */, @@ -249,6 +250,10 @@ export default defineNuxtConfig({ driver: 'fsLite', base: './.cache/atproto', }, + 'user-preferences': { + driver: 'fsLite', + base: './.cache/user-preferences', + }, }, typescript: { tsConfig: { diff --git a/server/api/user/preferences.get.ts b/server/api/user/preferences.get.ts new file mode 100644 index 0000000000..3f660ff571 --- /dev/null +++ b/server/api/user/preferences.get.ts @@ -0,0 +1,15 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const preferences = await useUserPreferencesStore().get(session.output.did) + + return preferences ?? { ...DEFAULT_USER_PREFERENCES, updatedAt: new Date().toISOString() } +}) diff --git a/server/api/user/preferences.put.ts b/server/api/user/preferences.put.ts new file mode 100644 index 0000000000..9ab6e85582 --- /dev/null +++ b/server/api/user/preferences.put.ts @@ -0,0 +1,20 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { UserPreferencesSchema } from '#shared/schemas/userPreferences' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const settings = safeParse(UserPreferencesSchema, await readBody(event)) + if (!settings.success) { + throw createError({ statusCode: 400, message: 'Invalid settings format' }) + } + + await useUserPreferencesStore().set(session.output.did, settings.output) + + return { success: true } +}) diff --git a/server/utils/preferences/user-preferences-store.ts b/server/utils/preferences/user-preferences-store.ts new file mode 100644 index 0000000000..5fed6ff387 --- /dev/null +++ b/server/utils/preferences/user-preferences-store.ts @@ -0,0 +1,49 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import { + USER_PREFERENCES_STORAGE_BASE, + DEFAULT_USER_PREFERENCES, +} from '#shared/schemas/userPreferences' + +export class UserPreferencesStore { + private readonly storage = useStorage(USER_PREFERENCES_STORAGE_BASE) + + async get(did: string): Promise { + const result = await this.storage.getItem(did) + return result ?? null + } + + async set(did: string, preferences: UserPreferences): Promise { + const withTimestamp: UserPreferences = { + ...preferences, + updatedAt: new Date().toISOString(), + } + await this.storage.setItem(did, withTimestamp) + } + + async merge(did: string, partial: Partial): Promise { + const existing = await this.get(did) + const base = existing ?? { ...DEFAULT_USER_PREFERENCES } + + const merged: UserPreferences = { + ...base, + ...partial, + updatedAt: new Date().toISOString(), + } + + await this.set(did, merged) + return merged + } + + async delete(did: string): Promise { + await this.storage.removeItem(did) + } +} + +let storeInstance: UserPreferencesStore | null = null + +export function useUserPreferencesStore(): UserPreferencesStore { + if (!storeInstance) { + storeInstance = new UserPreferencesStore() + } + return storeInstance +} diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts new file mode 100644 index 0000000000..241016fc8f --- /dev/null +++ b/shared/schemas/userPreferences.ts @@ -0,0 +1,43 @@ +import { object, string, boolean, nullable, optional, picklist, type InferOutput } from 'valibot' +import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants' + +const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string, ...string[]]) + +const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]]) + +export const UserPreferencesSchema = object({ + /** Display dates as relative (e.g., "3 days ago") instead of absolute */ + relativeDates: optional(boolean()), + /** Include @types/* package in install command for packages without built-in types */ + includeTypesInInstall: optional(boolean()), + /** Accent color theme ID (e.g., "rose", "amber", "emerald") */ + accentColorId: optional(nullable(AccentColorIdSchema)), + /** Preferred background shade */ + preferredBackgroundTheme: optional(nullable(BackgroundThemeIdSchema)), + /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ + hidePlatformPackages: optional(boolean()), + /** User-selected locale code (e.g., "en", "de", "ja") */ + selectedLocale: optional(nullable(string())), + /** Timestamp of last update (ISO 8601) - managed by server */ + updatedAt: optional(string()), +}) + +export type UserPreferences = InferOutput + +export type AccentColorId = keyof typeof ACCENT_COLORS.light +export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES + +/** + * Default user preferences. + * Used when creating new user preferences or merging with partial updates. + */ +export const DEFAULT_USER_PREFERENCES: Required> = { + relativeDates: false, + includeTypesInInstall: true, + accentColorId: null, + preferredBackgroundTheme: null, + hidePlatformPackages: true, + selectedLocale: null, +} + +export const USER_PREFERENCES_STORAGE_BASE = 'npmx-kv-user-preferences' From 726bd04db79dfd4b2bc97527ccb34424c48f196d Mon Sep 17 00:00:00 2001 From: Yevhen Husak Date: Mon, 9 Feb 2026 01:03:45 +0000 Subject: [PATCH 02/12] feat: replace useSettings with useUserPreferences - extract sidebar collapsed state into separate `usePackageSidebarPreferences` composable - add `preferences-sync.client.ts` plugin for early color mode + server sync init - wrap theme select in `` to prevent SSR hydration mismatch - show sync status indicator on settings page for authenticated users - add `useColorModePreference` composable to sync color mode with `@nuxtjs/color-mode` --- app/components/CollapsibleSection.vue | 15 +- app/components/Settings/AccentColorPicker.vue | 6 +- app/components/Settings/BgThemePicker.vue | 4 +- app/composables/useInstallCommand.ts | 4 +- .../usePackageSidebarPreferences.ts | 24 +++ app/composables/useUserPreferences.ts | 132 +++++++++++++ app/composables/useUserPreferencesProvider.ts | 100 ++++++++++ app/pages/search.vue | 4 +- app/pages/settings.vue | 177 +++++++++++------- app/plugins/i18n-loader.client.ts | 18 +- app/plugins/preferences-sync.client.ts | 13 ++ app/utils/prehydrate.ts | 16 +- i18n/locales/en.json | 6 +- shared/schemas/userPreferences.ts | 6 + test/nuxt/components/DateTime.spec.ts | 6 +- test/nuxt/components/compare/FacetRow.spec.ts | 6 +- .../composables/use-install-command.spec.ts | 6 +- 17 files changed, 429 insertions(+), 114 deletions(-) create mode 100644 app/composables/usePackageSidebarPreferences.ts create mode 100644 app/composables/useUserPreferences.ts create mode 100644 app/composables/useUserPreferencesProvider.ts create mode 100644 app/plugins/preferences-sync.client.ts diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue index 961e3502e5..42aae32f87 100644 --- a/app/components/CollapsibleSection.vue +++ b/app/components/CollapsibleSection.vue @@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), { headingLevel: 'h2', }) -const appSettings = useSettings() +const { sidebarPreferences } = usePackageSidebarPreferences() const buttonId = `${props.id}-collapsible-button` const contentId = `${props.id}-collapsible-content` @@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content` const isOpen = shallowRef(true) onPrehydrate(() => { - const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') - const collapsed: string[] = settings?.sidebar?.collapsed || [] + const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}') + const collapsed: string[] = sidebar?.collapsed || [] for (const id of collapsed) { if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) { document.documentElement.dataset.collapsed = ( @@ -48,17 +48,16 @@ onMounted(() => { function toggle() { isOpen.value = !isOpen.value - const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id) + const removed = sidebarPreferences.value.collapsed.filter(c => c !== props.id) if (isOpen.value) { - appSettings.settings.value.sidebar.collapsed = removed + sidebarPreferences.value.collapsed = removed } else { removed.push(props.id) - appSettings.settings.value.sidebar.collapsed = removed + sidebarPreferences.value.collapsed = removed } - document.documentElement.dataset.collapsed = - appSettings.settings.value.sidebar.collapsed.join(' ') + document.documentElement.dataset.collapsed = sidebarPreferences.value.collapsed.join(' ') } const ariaLabel = computed(() => { diff --git a/app/components/Settings/AccentColorPicker.vue b/app/components/Settings/AccentColorPicker.vue index 6d0d4ad217..af39588052 100644 --- a/app/components/Settings/AccentColorPicker.vue +++ b/app/components/Settings/AccentColorPicker.vue @@ -1,12 +1,12 @@
@@ -277,7 +316,7 @@ const setLocale: typeof setNuxti18nLocale = newLocale => { class="font-sans text-fg-muted text-sm" >
@@ -286,13 +325,13 @@ const setLocale: typeof setNuxti18nLocale = newLocale => {

- {{ $t('settings.sections.keyboard_shortcuts') }} + {{ $t("settings.sections.keyboard_shortcuts") }}

diff --git a/app/plugins/i18n-loader.client.ts b/app/plugins/i18n-loader.client.ts index b34894396f..81f88ce724 100644 --- a/app/plugins/i18n-loader.client.ts +++ b/app/plugins/i18n-loader.client.ts @@ -1,20 +1,18 @@ export default defineNuxtPlugin({ + name: 'i18n-loader', + dependsOn: ['preferences-sync'], enforce: 'post', env: { islands: false }, setup() { const { $i18n } = useNuxtApp() const { locale, locales, setLocale } = $i18n - const { settings } = useSettings() - const settingsLocale = settings.value.selectedLocale + const { preferences } = useUserPreferences() + const settingsLocale = preferences.value.selectedLocale - if ( - settingsLocale && - // Check if the value is a supported locale - locales.value.map(l => l.code).includes(settingsLocale) && - // Check if the value is not a current locale - settingsLocale !== locale.value - ) { - setLocale(settingsLocale) + const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) + + if (matchedLocale && matchedLocale !== locale.value) { + setLocale(matchedLocale) } }, }) diff --git a/app/plugins/preferences-sync.client.ts b/app/plugins/preferences-sync.client.ts new file mode 100644 index 0000000000..9c71b7c258 --- /dev/null +++ b/app/plugins/preferences-sync.client.ts @@ -0,0 +1,13 @@ +export default defineNuxtPlugin({ + name: 'preferences-sync', + setup() { + const { initSync } = useUserPreferences() + const { applyStoredColorMode } = useColorModePreference() + + // Apply stored color mode preference early (before components mount) + applyStoredColorMode() + + // Initialize server sync for authenticated users + initSync() + }, +}) diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts index 075941a674..6b3b8b6db2 100644 --- a/app/utils/prehydrate.ts +++ b/app/utils/prehydrate.ts @@ -23,18 +23,16 @@ export function initPreferencesOnPrehydrate() { // Valid package manager IDs const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt']) - // Read settings from localStorage - const settings = JSON.parse( - localStorage.getItem('npmx-settings') || '{}', - ) as Partial + // Read user preferences from localStorage + const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') - const accentColorId = settings.accentColorId + const accentColorId = preferences.accentColorId if (accentColorId && accentColorIds.has(accentColorId)) { document.documentElement.style.setProperty('--accent-color', `var(--swatch-${accentColorId})`) } // Apply background accent - const preferredBackgroundTheme = settings.preferredBackgroundTheme + const preferredBackgroundTheme = preferences.preferredBackgroundTheme if (preferredBackgroundTheme) { document.documentElement.dataset.bgTheme = preferredBackgroundTheme } @@ -60,10 +58,12 @@ export function initPreferencesOnPrehydrate() { // Set data attribute for CSS-based visibility document.documentElement.dataset.pm = pm - document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? '' + // Read sidebar preferences from separate localStorage key + const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}') + document.documentElement.dataset.collapsed = sidebar.collapsed?.join(' ') ?? '' // Keyboard shortcuts (default: true) - if (settings.keyboardShortcuts === false) { + if (preferences.keyboardShortcuts === false) { document.documentElement.dataset.kbdShortcuts = 'false' } }) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 66f0eb39b5..a04ebfea84 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -169,7 +169,11 @@ "black": "Black" }, "keyboard_shortcuts_enabled": "Enable keyboard shortcuts", - "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts" + "keyboard_shortcuts_enabled_description": "Keyboard shortcuts can be disabled if they conflict with other browser or system shortcuts", + "syncing": "Syncing...", + "synced": "Settings synced", + "sync_error": "Sync failed", + "sync_enabled": "Cloud sync enabled" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts index 241016fc8f..ef615bff4a 100644 --- a/shared/schemas/userPreferences.ts +++ b/shared/schemas/userPreferences.ts @@ -5,6 +5,8 @@ const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]]) +const ColorModePreferenceSchema = picklist(['light', 'dark', 'system']) + export const UserPreferencesSchema = object({ /** Display dates as relative (e.g., "3 days ago") instead of absolute */ relativeDates: optional(boolean()), @@ -18,6 +20,8 @@ export const UserPreferencesSchema = object({ hidePlatformPackages: optional(boolean()), /** User-selected locale code (e.g., "en", "de", "ja") */ selectedLocale: optional(nullable(string())), + /** Color mode preference: 'light', 'dark', or 'system' */ + colorModePreference: optional(nullable(ColorModePreferenceSchema)), /** Timestamp of last update (ISO 8601) - managed by server */ updatedAt: optional(string()), }) @@ -26,6 +30,7 @@ export type UserPreferences = InferOutput export type AccentColorId = keyof typeof ACCENT_COLORS.light export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES +export type ColorModePreference = 'light' | 'dark' | 'system' /** * Default user preferences. @@ -38,6 +43,7 @@ export const DEFAULT_USER_PREFERENCES: Required ({ +vi.mock('~/composables/useUserPreferences', () => ({ useRelativeDates: () => mockRelativeDates, - useSettings: () => ({ - settings: ref({ relativeDates: mockRelativeDates.value }), + useUserPreferences: () => ({ + preferences: ref({ relativeDates: mockRelativeDates.value }), }), useAccentColor: () => ({}), })) diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts index cfa30adc74..11467b5c08 100644 --- a/test/nuxt/components/compare/FacetRow.spec.ts +++ b/test/nuxt/components/compare/FacetRow.spec.ts @@ -3,10 +3,10 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import FacetRow from '~/components/Compare/FacetRow.vue' // Mock useRelativeDates for DateTime component -vi.mock('~/composables/useSettings', () => ({ +vi.mock('~/composables/useUserPreferences', () => ({ useRelativeDates: () => ref(false), - useSettings: () => ({ - settings: ref({ relativeDates: false }), + useUserPreferences: () => ({ + preferences: ref({ relativeDates: false }), }), useAccentColor: () => ({}), initAccentOnPrehydrate: () => {}, diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 5799427945..83fa40ba89 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -217,9 +217,9 @@ describe('useInstallCommand', () => { }) it('should only include main command when @types disabled via settings', () => { - // Get settings and disable includeTypesInInstall directly - const { settings } = useSettings() - settings.value.includeTypesInInstall = false + // Get preferences and disable includeTypesInInstall directly + const { preferences } = useUserPreferences() + preferences.value.includeTypesInInstall = false const { fullInstallCommand, showTypesInInstall } = useInstallCommand( 'express', From 6007c0b2fd050ed5740e765297927b6a9cb2c57d Mon Sep 17 00:00:00 2001 From: Yevhen Husak Date: Sat, 14 Feb 2026 14:54:13 +0000 Subject: [PATCH 03/12] fix: add search provider to user-preferences --- app/composables/useUserPreferences.ts | 27 +++++++++++++++++++++++++++ shared/schemas/userPreferences.ts | 6 ++++++ 2 files changed, 33 insertions(+) diff --git a/app/composables/useUserPreferences.ts b/app/composables/useUserPreferences.ts index 0d5b2b412c..b8a44275f2 100644 --- a/app/composables/useUserPreferences.ts +++ b/app/composables/useUserPreferences.ts @@ -4,6 +4,7 @@ import { type AccentColorId, type BackgroundThemeId, type ColorModePreference, + type SearchProvider, } from '#shared/schemas/userPreferences' /** @@ -87,6 +88,32 @@ export function useBackgroundTheme() { } } +/** + * Composable for managing the search provider preference. + */ +export function useSearchProvider() { + const { preferences } = useUserPreferences() + + const searchProvider = computed({ + get: () => preferences.value.searchProvider ?? 'algolia', + set: (value: SearchProvider) => { + preferences.value.searchProvider = value + }, + }) + + const isAlgolia = computed(() => searchProvider.value === 'algolia') + + function toggle() { + searchProvider.value = searchProvider.value === 'npm' ? 'algolia' : 'npm' + } + + return { + searchProvider, + isAlgolia, + toggle, + } +} + /** * Composable for syncing color mode preference. * Keeps the user preference in sync with @nuxtjs/color-mode's own LS key (`npmx-color-mode`) diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts index ef615bff4a..cfbd520d93 100644 --- a/shared/schemas/userPreferences.ts +++ b/shared/schemas/userPreferences.ts @@ -7,6 +7,8 @@ const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [stri const ColorModePreferenceSchema = picklist(['light', 'dark', 'system']) +const SearchProviderSchema = picklist(['npm', 'algolia']) + export const UserPreferencesSchema = object({ /** Display dates as relative (e.g., "3 days ago") instead of absolute */ relativeDates: optional(boolean()), @@ -22,6 +24,8 @@ export const UserPreferencesSchema = object({ selectedLocale: optional(nullable(string())), /** Color mode preference: 'light', 'dark', or 'system' */ colorModePreference: optional(nullable(ColorModePreferenceSchema)), + /** Search provider for package search: 'npm' or 'algolia' */ + searchProvider: optional(SearchProviderSchema), /** Timestamp of last update (ISO 8601) - managed by server */ updatedAt: optional(string()), }) @@ -31,6 +35,7 @@ export type UserPreferences = InferOutput export type AccentColorId = keyof typeof ACCENT_COLORS.light export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES export type ColorModePreference = 'light' | 'dark' | 'system' +export type SearchProvider = 'npm' | 'algolia' /** * Default user preferences. @@ -44,6 +49,7 @@ export const DEFAULT_USER_PREFERENCES: Required Date: Sat, 14 Feb 2026 15:38:32 +0000 Subject: [PATCH 04/12] fix: adapt connector settings --- app/components/CollapsibleSection.vue | 14 +++---- app/components/Header/ConnectorModal.vue | 6 +-- app/composables/useConnector.ts | 4 +- .../usePackageSidebarPreferences.ts | 24 ----------- app/composables/useUserLocalSettings.ts | 42 +++++++++++++++++++ app/utils/prehydrate.ts | 5 --- .../components/HeaderConnectorModal.spec.ts | 21 ++++------ 7 files changed, 61 insertions(+), 55 deletions(-) delete mode 100644 app/composables/usePackageSidebarPreferences.ts create mode 100644 app/composables/useUserLocalSettings.ts diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue index 42aae32f87..4f9cf0622f 100644 --- a/app/components/CollapsibleSection.vue +++ b/app/components/CollapsibleSection.vue @@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), { headingLevel: 'h2', }) -const { sidebarPreferences } = usePackageSidebarPreferences() +const { userLocalSettings } = useUserLocalSettings() const buttonId = `${props.id}-collapsible-button` const contentId = `${props.id}-collapsible-content` @@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content` const isOpen = shallowRef(true) onPrehydrate(() => { - const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}') - const collapsed: string[] = sidebar?.collapsed || [] + const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + const collapsed: string[] = sidebar?.sidebar?.collapsed || [] for (const id of collapsed) { if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) { document.documentElement.dataset.collapsed = ( @@ -48,16 +48,16 @@ onMounted(() => { function toggle() { isOpen.value = !isOpen.value - const removed = sidebarPreferences.value.collapsed.filter(c => c !== props.id) + const removed = userLocalSettings.value.sidebar.collapsed.filter(c => c !== props.id) if (isOpen.value) { - sidebarPreferences.value.collapsed = removed + userLocalSettings.value.sidebar.collapsed = removed } else { removed.push(props.id) - sidebarPreferences.value.collapsed = removed + userLocalSettings.value.sidebar.collapsed = removed } - document.documentElement.dataset.collapsed = sidebarPreferences.value.collapsed.join(' ') + document.documentElement.dataset.collapsed = userLocalSettings.value.sidebar.collapsed.join(' ') } const ariaLabel = computed(() => { diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue index d157ea31cc..733d33eb84 100644 --- a/app/components/Header/ConnectorModal.vue +++ b/app/components/Header/ConnectorModal.vue @@ -2,7 +2,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = useConnector() -const { settings } = useSettings() +const { userLocalSettings } = useUserLocalSettings() const tokenInput = shallowRef('') const portInput = shallowRef('31415') @@ -68,7 +68,7 @@ const executeNpmxConnectorCommand = computed(() => {
@@ -157,7 +157,7 @@ const executeNpmxConnectorCommand = computed(() => {
diff --git a/app/composables/useConnector.ts b/app/composables/useConnector.ts index d1ef4296ef..3ec5ed16b7 100644 --- a/app/composables/useConnector.ts +++ b/app/composables/useConnector.ts @@ -57,7 +57,7 @@ const STORAGE_KEY = 'npmx-connector' const DEFAULT_PORT = 31415 export const useConnector = createSharedComposable(function useConnector() { - const { settings } = useSettings() + const { userLocalSettings } = useUserLocalSettings() // Persisted connection config const config = useState<{ token: string; port: number } | null>('connector-config', () => null) @@ -308,7 +308,7 @@ export const useConnector = createSharedComposable(function useConnector() { body: { otp, interactive: !otp, - openUrls: settings.value.connector.autoOpenURL, + openUrls: userLocalSettings.value.connector.autoOpenURL, }, }) if (response?.success) { diff --git a/app/composables/usePackageSidebarPreferences.ts b/app/composables/usePackageSidebarPreferences.ts deleted file mode 100644 index 7edf3e551a..0000000000 --- a/app/composables/usePackageSidebarPreferences.ts +++ /dev/null @@ -1,24 +0,0 @@ -interface SidebarPreferences { - collapsed: string[] -} - -const STORAGE_KEY = 'npmx-sidebar-preferences' -const DEFAULT_SIDEBAR_PREFERENCES: SidebarPreferences = { collapsed: [] } - -let sidebarRef: Ref | null = null - -/** - * Composable for managing sidebar section collapsed state. - * This is local-only and uses its own LS key. - */ -export function usePackageSidebarPreferences() { - if (!sidebarRef) { - sidebarRef = useLocalStorage(STORAGE_KEY, DEFAULT_SIDEBAR_PREFERENCES, { - mergeDefaults: true, - }) - } - - return { - sidebarPreferences: sidebarRef, - } -} diff --git a/app/composables/useUserLocalSettings.ts b/app/composables/useUserLocalSettings.ts new file mode 100644 index 0000000000..3ef9cd55bd --- /dev/null +++ b/app/composables/useUserLocalSettings.ts @@ -0,0 +1,42 @@ +interface UserLocalSettings { + sidebar: { + collapsed: string[] + } + connector: { + autoOpenURL: boolean + } +} + +const STORAGE_KEY = 'npmx-settings' +const DEFAULT_USER_LOCAL_SETTINGS: UserLocalSettings = { + sidebar: { + collapsed: [], + }, + connector: { + autoOpenURL: false, + }, +} + +let userLocalSettingsRef: Ref | null = null + +/** + * Composable for managing local user settings. + * Uses its own LS key. + * + * This is for settings that are purely local and don't need to be synced + */ +export function useUserLocalSettings() { + if (!userLocalSettingsRef) { + userLocalSettingsRef = useLocalStorage( + STORAGE_KEY, + DEFAULT_USER_LOCAL_SETTINGS, + { + mergeDefaults: true, + }, + ) + } + + return { + userLocalSettings: userLocalSettingsRef, + } +} diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts index 6b3b8b6db2..814bf899d8 100644 --- a/app/utils/prehydrate.ts +++ b/app/utils/prehydrate.ts @@ -61,10 +61,5 @@ export function initPreferencesOnPrehydrate() { // Read sidebar preferences from separate localStorage key const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}') document.documentElement.dataset.collapsed = sidebar.collapsed?.join(' ') ?? '' - - // Keyboard shortcuts (default: true) - if (preferences.keyboardShortcuts === false) { - document.documentElement.dataset.kbdShortcuts = 'false' - } }) } diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts index d152ec540b..9f60f1c08f 100644 --- a/test/nuxt/components/HeaderConnectorModal.spec.ts +++ b/test/nuxt/components/HeaderConnectorModal.spec.ts @@ -101,7 +101,7 @@ function resetMockState() { error: null, lastExecutionTime: null, } - mockSettings.value.connector = { + mockUserLocalSettings.value.connector = { autoOpenURL: false, } } @@ -112,28 +112,21 @@ function simulateConnect() { mockState.value.avatar = 'https://example.com/avatar.png' } -const mockSettings = ref({ - relativeDates: false, - includeTypesInInstall: true, - accentColorId: null, - hidePlatformPackages: true, - selectedLocale: null, - preferredBackgroundTheme: null, - searchProvider: 'npm', - connector: { - autoOpenURL: false, - }, +const mockUserLocalSettings = ref({ sidebar: { collapsed: [], }, + connector: { + autoOpenURL: false, + }, }) mockNuxtImport('useConnector', () => { return createMockUseConnector }) -mockNuxtImport('useSettings', () => { - return () => ({ settings: mockSettings }) +mockNuxtImport('useUserLocalSettings', () => { + return () => ({ userLocalSettings: mockUserLocalSettings }) }) mockNuxtImport('useSelectedPackageManager', () => { From 86128b9068c73f26103ff9a32974bbda07d29ab5 Mon Sep 17 00:00:00 2001 From: Yevhen Husak Date: Sun, 15 Feb 2026 22:44:47 +0000 Subject: [PATCH 05/12] refactor: split user-preferences composables and streamline sync flow --- app/components/CollapsibleSection.vue | 14 +- app/components/Header/ConnectorModal.vue | 6 +- app/components/Package/TrendsChart.vue | 1238 +++++++++-------- .../Package/WeeklyDownloadStats.vue | 6 +- app/components/Settings/AccentColorPicker.vue | 7 +- app/components/Settings/BgThemePicker.vue | 7 +- app/composables/npm/useSearch.ts | 348 ++--- app/composables/useConnector.ts | 4 +- app/composables/useInstallCommand.ts | 2 +- app/composables/useUserLocalSettings.ts | 24 +- app/composables/useUserPreferences.ts | 159 --- app/composables/useUserPreferencesProvider.ts | 24 +- .../useUserPreferencesSync.client.ts | 29 +- .../userPreferences/useAccentColor.ts | 33 + .../userPreferences/useBackgroundTheme.ts | 27 + .../userPreferences/useColorModePreference.ts | 35 + .../useInitUserPreferencesSync.ts | 10 + .../userPreferences/useKeyboardShortcuts.ts | 20 + .../userPreferences/useRelativeDates.ts | 4 + .../userPreferences/useSearchProvider.ts | 24 + .../useUserPreferencesState.ts | 10 + .../useUserPreferencesSyncStatus.ts | 15 + app/pages/search.vue | 2 +- app/pages/settings.vue | 70 +- app/plugins/i18n-loader.client.ts | 2 +- app/plugins/preferences-sync.client.ts | 2 +- app/utils/prehydrate.ts | 16 +- i18n/schema.json | 12 + server/api/user/preferences.post.ts | 1 + shared/schemas/userPreferences.ts | 5 +- test/e2e/interactions.spec.ts | 2 +- test/nuxt/components/DateTime.spec.ts | 6 +- .../components/HeaderConnectorModal.spec.ts | 2 +- test/nuxt/components/compare/FacetRow.spec.ts | 7 +- .../compare/PackageSelector.spec.ts | 70 +- .../composables/use-install-command.spec.ts | 2 +- .../use-keyboard-shortcuts.spec.ts | 56 + .../use-package-list-preferences.spec.ts | 64 + .../use-preferences-provider.spec.ts | 63 - test/nuxt/composables/use-settings.spec.ts | 48 - 40 files changed, 1321 insertions(+), 1155 deletions(-) delete mode 100644 app/composables/useUserPreferences.ts create mode 100644 app/composables/userPreferences/useAccentColor.ts create mode 100644 app/composables/userPreferences/useBackgroundTheme.ts create mode 100644 app/composables/userPreferences/useColorModePreference.ts create mode 100644 app/composables/userPreferences/useInitUserPreferencesSync.ts create mode 100644 app/composables/userPreferences/useKeyboardShortcuts.ts create mode 100644 app/composables/userPreferences/useRelativeDates.ts create mode 100644 app/composables/userPreferences/useSearchProvider.ts create mode 100644 app/composables/userPreferences/useUserPreferencesState.ts create mode 100644 app/composables/userPreferences/useUserPreferencesSyncStatus.ts create mode 100644 server/api/user/preferences.post.ts create mode 100644 test/nuxt/composables/use-keyboard-shortcuts.spec.ts create mode 100644 test/nuxt/composables/use-package-list-preferences.spec.ts delete mode 100644 test/nuxt/composables/use-preferences-provider.spec.ts delete mode 100644 test/nuxt/composables/use-settings.spec.ts diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue index 4f9cf0622f..8509a4c353 100644 --- a/app/components/CollapsibleSection.vue +++ b/app/components/CollapsibleSection.vue @@ -16,7 +16,7 @@ const props = withDefaults(defineProps(), { headingLevel: 'h2', }) -const { userLocalSettings } = useUserLocalSettings() +const { localSettings } = useUserLocalSettings() const buttonId = `${props.id}-collapsible-button` const contentId = `${props.id}-collapsible-content` @@ -24,8 +24,8 @@ const contentId = `${props.id}-collapsible-content` const isOpen = shallowRef(true) onPrehydrate(() => { - const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}') - const collapsed: string[] = sidebar?.sidebar?.collapsed || [] + const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + const collapsed: string[] = settings?.sidebar?.collapsed || [] for (const id of collapsed) { if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) { document.documentElement.dataset.collapsed = ( @@ -48,16 +48,16 @@ onMounted(() => { function toggle() { isOpen.value = !isOpen.value - const removed = userLocalSettings.value.sidebar.collapsed.filter(c => c !== props.id) + const removed = localSettings.value.sidebar.collapsed.filter(c => c !== props.id) if (isOpen.value) { - userLocalSettings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } else { removed.push(props.id) - userLocalSettings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } - document.documentElement.dataset.collapsed = userLocalSettings.value.sidebar.collapsed.join(' ') + document.documentElement.dataset.collapsed = localSettings.value.sidebar.collapsed.join(' ') } const ariaLabel = computed(() => { diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue index 733d33eb84..646242cc4c 100644 --- a/app/components/Header/ConnectorModal.vue +++ b/app/components/Header/ConnectorModal.vue @@ -2,7 +2,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = useConnector() -const { userLocalSettings } = useUserLocalSettings() +const { localSettings } = useUserLocalSettings() const tokenInput = shallowRef('') const portInput = shallowRef('31415') @@ -68,7 +68,7 @@ const executeNpmxConnectorCommand = computed(() => {
@@ -157,7 +157,7 @@ const executeNpmxConnectorCommand = computed(() => {
diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index e81a32d804..9929fecde7 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -1,12 +1,12 @@ @@ -2183,7 +2185,7 @@ const isSparklineLayout = computed({ v-if="!chartData.dataset && !activeMetricState.pending" class="min-h-[260px] flex items-center justify-center text-fg-subtle font-mono text-sm" > - {{ $t('package.trends.no_data') }} + {{ $t("package.trends.no_data") }} @@ -2193,7 +2195,7 @@ const isSparklineLayout = computed({ aria-live="polite" class="absolute top-1/2 inset-is-1/2 -translate-x-1/2 -translate-y-1/2 text-xs text-fg-subtle font-mono bg-bg/70 backdrop-blur px-3 py-2 rounded-md border border-border" > - {{ $t('package.trends.loading') }} + {{ $t("package.trends.loading") }} @@ -2234,11 +2236,11 @@ const isSparklineLayout = computed({ } } -[data-pending='true'] .vue-data-ui-zoom { +[data-pending="true"] .vue-data-ui-zoom { opacity: 0.1; } -[data-pending='true'] .vue-data-ui-time-label { +[data-pending="true"] .vue-data-ui-time-label { opacity: 0; } @@ -2247,7 +2249,7 @@ const isSparklineLayout = computed({ top: unset !important; } -[data-minimap-visible='false'] .vue-data-ui-watermark { +[data-minimap-visible="false"] .vue-data-ui-watermark { top: calc(100% - 2rem) !important; } diff --git a/app/components/Package/WeeklyDownloadStats.vue b/app/components/Package/WeeklyDownloadStats.vue index eed8954196..417dfb40ed 100644 --- a/app/components/Package/WeeklyDownloadStats.vue +++ b/app/components/Package/WeeklyDownloadStats.vue @@ -19,7 +19,7 @@ const props = defineProps<{ const router = useRouter() const route = useRoute() -const { settings } = useSettings() +const { localSettings } = useUserLocalSettings() const chartModal = useModal('chart-modal') const hasChartModalTransitioned = shallowRef(false) @@ -185,14 +185,14 @@ watch( const correctedDownloads = computed(() => { let data = weeklyDownloads.value as WeeklyDataPoint[] if (!data.length) return data - if (settings.value.chartFilter.anomaliesFixed) { + if (localSettings.value.chartFilter.anomaliesFixed) { data = applyBlocklistCorrection({ data, packageName: props.packageName, granularity: 'weekly', }) as WeeklyDataPoint[] } - data = applyDataCorrection(data, settings.value.chartFilter) as WeeklyDataPoint[] + data = applyDataCorrection(data, localSettings.value.chartFilter) as WeeklyDataPoint[] return data }) diff --git a/app/components/Settings/AccentColorPicker.vue b/app/components/Settings/AccentColorPicker.vue index af39588052..7fca0f0750 100644 --- a/app/components/Settings/AccentColorPicker.vue +++ b/app/components/Settings/AccentColorPicker.vue @@ -1,11 +1,14 @@ @@ -846,7 +894,7 @@ const showSkeleton = shallowRef(false) target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 rounded-sm underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg focus-visible:decoration-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 transition-colors" - >{{ $t('package.security_downgrade.provenance_link_text') + >{{ $t("package.security_downgrade.provenance_link_text") }}