Skip to content

Commit 3c4eb04

Browse files
committed
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 `<ClientOnly>` 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`
1 parent 37f436c commit 3c4eb04

19 files changed

Lines changed: 388 additions & 65 deletions

app/components/CollapsibleSection.vue

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ const props = withDefaults(defineProps<Props>(), {
1616
headingLevel: 'h2',
1717
})
1818
19-
const appSettings = useSettings()
19+
const { sidebarPreferences } = usePackageSidebarPreferences()
2020
2121
const buttonId = `${props.id}-collapsible-button`
2222
const contentId = `${props.id}-collapsible-content`
2323
2424
const isOpen = shallowRef(true)
2525
2626
onPrehydrate(() => {
27-
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
28-
const collapsed: string[] = settings?.sidebar?.collapsed || []
27+
const sidebar = JSON.parse(localStorage.getItem('npmx-sidebar-preferences') || '{}')
28+
const collapsed: string[] = sidebar?.collapsed || []
2929
for (const id of collapsed) {
3030
if (!document.documentElement.dataset.collapsed?.split(' ').includes(id)) {
3131
document.documentElement.dataset.collapsed = (
@@ -48,17 +48,16 @@ onMounted(() => {
4848
function toggle() {
4949
isOpen.value = !isOpen.value
5050
51-
const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id)
51+
const removed = sidebarPreferences.value.collapsed.filter(c => c !== props.id)
5252
5353
if (isOpen.value) {
54-
appSettings.settings.value.sidebar.collapsed = removed
54+
sidebarPreferences.value.collapsed = removed
5555
} else {
5656
removed.push(props.id)
57-
appSettings.settings.value.sidebar.collapsed = removed
57+
sidebarPreferences.value.collapsed = removed
5858
}
5959
60-
document.documentElement.dataset.collapsed =
61-
appSettings.settings.value.sidebar.collapsed.join(' ')
60+
document.documentElement.dataset.collapsed = sidebarPreferences.value.collapsed.join(' ')
6261
}
6362
6463
const ariaLabel = computed(() => {

app/components/Settings/AccentColorPicker.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<script setup lang="ts">
2-
import { useAccentColor } from '~/composables/useSettings'
2+
import { useAccentColor } from '~/composables/useUserPreferences'
33
44
const { accentColors, selectedAccentColor, setAccentColor } = useAccentColor()
55
66
onPrehydrate(el => {
7-
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
7+
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
88
const defaultId = 'sky'
9-
const id = settings.accentColorId
9+
const id = preferences.accentColorId
1010
if (id) {
1111
const input = el.querySelector<HTMLInputElement>(`input[value="${id}"]`)
1212
if (input) {

app/components/Settings/BgThemePicker.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
const { backgroundThemes, selectedBackgroundTheme, setBackgroundTheme } = useBackgroundTheme()
33
44
onPrehydrate(el => {
5-
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
5+
const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}')
66
const defaultId = 'neutral'
7-
const id = settings.preferredBackgroundTheme
7+
const id = preferences.preferredBackgroundTheme
88
if (id) {
99
const input = el.querySelector<HTMLInputElement>(`input[value="${id}"]`)
1010
if (input) {

app/composables/useInstallCommand.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ export function useInstallCommand(
1212
installVersionOverride?: MaybeRefOrGetter<string | null>,
1313
) {
1414
const selectedPM = useSelectedPackageManager()
15-
const { settings } = useSettings()
15+
const { preferences } = useUserPreferences()
1616

1717
// Check if we should show @types in install command
1818
const showTypesInInstall = computed(() => {
19-
return settings.value.includeTypesInInstall && !!toValue(typesPackageName)
19+
return preferences.value.includeTypesInInstall && !!toValue(typesPackageName)
2020
})
2121

2222
const installCommandParts = computed(() => {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
interface SidebarPreferences {
2+
collapsed: string[]
3+
}
4+
5+
const STORAGE_KEY = 'npmx-sidebar-preferences'
6+
const DEFAULT_SIDEBAR_PREFERENCES: SidebarPreferences = { collapsed: [] }
7+
8+
let sidebarRef: Ref<SidebarPreferences> | null = null
9+
10+
/**
11+
* Composable for managing sidebar section collapsed state.
12+
* This is local-only and uses its own LS key.
13+
*/
14+
export function usePackageSidebarPreferences() {
15+
if (!sidebarRef) {
16+
sidebarRef = useLocalStorage<SidebarPreferences>(STORAGE_KEY, DEFAULT_SIDEBAR_PREFERENCES, {
17+
mergeDefaults: true,
18+
})
19+
}
20+
21+
return {
22+
sidebarPreferences: sidebarRef,
23+
}
24+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants'
2+
import {
3+
DEFAULT_USER_PREFERENCES,
4+
type AccentColorId,
5+
type BackgroundThemeId,
6+
type ColorModePreference,
7+
} from '#shared/schemas/userPreferences'
8+
9+
/**
10+
* Main composable for user preferences.
11+
* Uses `npmx-user-preferences` localStorage key and syncs to the server
12+
* for authenticated users via `useUserPreferencesProvider`.
13+
*/
14+
export function useUserPreferences() {
15+
const provider = useUserPreferencesProvider(DEFAULT_USER_PREFERENCES)
16+
17+
return {
18+
preferences: provider.data,
19+
isAuthenticated: provider.isAuthenticated,
20+
isSyncing: provider.isSyncing,
21+
isSynced: provider.isSynced,
22+
hasError: provider.hasError,
23+
syncError: provider.syncError,
24+
lastSyncedAt: provider.lastSyncedAt,
25+
initSync: provider.initSync,
26+
}
27+
}
28+
29+
export function useRelativeDates() {
30+
const { preferences } = useUserPreferences()
31+
return computed(() => preferences.value.relativeDates)
32+
}
33+
34+
export function useAccentColor() {
35+
const { preferences } = useUserPreferences()
36+
const colorMode = useColorMode()
37+
38+
const accentColors = computed(() => {
39+
const isDark = colorMode.value === 'dark'
40+
const colors = isDark ? ACCENT_COLORS.dark : ACCENT_COLORS.light
41+
42+
return Object.entries(colors).map(([id, value]) => ({
43+
id: id as AccentColorId,
44+
name: id,
45+
value,
46+
}))
47+
})
48+
49+
function setAccentColor(id: AccentColorId | null) {
50+
if (id) {
51+
document.documentElement.style.setProperty('--accent-color', `var(--swatch-${id})`)
52+
} else {
53+
document.documentElement.style.removeProperty('--accent-color')
54+
}
55+
preferences.value.accentColorId = id
56+
}
57+
58+
return {
59+
accentColors,
60+
selectedAccentColor: computed(() => preferences.value.accentColorId),
61+
setAccentColor,
62+
}
63+
}
64+
65+
export function useBackgroundTheme() {
66+
const backgroundThemes = Object.entries(BACKGROUND_THEMES).map(([id, value]) => ({
67+
id: id as BackgroundThemeId,
68+
name: id,
69+
value,
70+
}))
71+
72+
const { preferences } = useUserPreferences()
73+
74+
function setBackgroundTheme(id: BackgroundThemeId | null) {
75+
if (id) {
76+
document.documentElement.dataset.bgTheme = id
77+
} else {
78+
document.documentElement.removeAttribute('data-bg-theme')
79+
}
80+
preferences.value.preferredBackgroundTheme = id
81+
}
82+
83+
return {
84+
backgroundThemes,
85+
selectedBackgroundTheme: computed(() => preferences.value.preferredBackgroundTheme),
86+
setBackgroundTheme,
87+
}
88+
}
89+
90+
/**
91+
* Composable for syncing color mode preference.
92+
* Keeps the user preference in sync with @nuxtjs/color-mode's own LS key (`npmx-color-mode`)
93+
* so that the color-mode module picks up the correct value on page load.
94+
*/
95+
export function useColorModePreference() {
96+
const { preferences } = useUserPreferences()
97+
const colorMode = useColorMode()
98+
99+
/**
100+
* Set color mode preference and sync to both user preferences and the
101+
* `npmx-color-mode` LS key used by @nuxtjs/color-mode.
102+
*/
103+
function setColorMode(mode: ColorModePreference) {
104+
preferences.value.colorModePreference = mode
105+
colorMode.preference = mode
106+
}
107+
108+
/**
109+
* On init, if the user has a stored preference, apply it to @nuxtjs/color-mode.
110+
* This handles the case where preferences were synced from the server.
111+
*/
112+
function applyStoredColorMode() {
113+
const stored = preferences.value.colorModePreference
114+
if (stored) {
115+
colorMode.preference = stored
116+
} else {
117+
// No user preference stored yet — seed it from the current color-mode LS value
118+
const currentPreference = colorMode.preference as ColorModePreference
119+
if (currentPreference && currentPreference !== 'system') {
120+
preferences.value.colorModePreference = currentPreference
121+
}
122+
}
123+
}
124+
125+
return {
126+
colorModePreference: computed(
127+
() => preferences.value.colorModePreference ?? colorMode.preference,
128+
),
129+
setColorMode,
130+
applyStoredColorMode,
131+
}
132+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Abstraction for user preferences storage
3+
* Supports both localStorage (for anonymous users) and API-based storage (for authenticated users)
4+
*
5+
* Design:
6+
* - Anonymous users: localStorage only
7+
* - Authenticated users: localStorage as cache, API as source of truth
8+
* - Changes sync to server with debounce (2s) and on route change/page unload
9+
*
10+
* Module-level singletons are safe here: on the server, useLocalStorage returns
11+
* a ref with defaults (no real localStorage); on the client, there's only one app instance.
12+
*/
13+
14+
import type { RemovableRef } from '@vueuse/core'
15+
import { useLocalStorage } from '@vueuse/core'
16+
import type { UserPreferences } from '#shared/schemas/userPreferences'
17+
18+
const STORAGE_KEY = 'npmx-user-preferences'
19+
20+
export type HydratedUserPreferences = Required<Omit<UserPreferences, 'updatedAt'>> &
21+
Pick<UserPreferences, 'updatedAt'>
22+
23+
let dataRef: RemovableRef<HydratedUserPreferences> | null = null
24+
let syncInitialized = false
25+
26+
/**
27+
* User preferences provider with server sync support.
28+
*/
29+
export function useUserPreferencesProvider(defaultValue: HydratedUserPreferences) {
30+
if (!dataRef) {
31+
dataRef = useLocalStorage<HydratedUserPreferences>(STORAGE_KEY, defaultValue, {
32+
mergeDefaults: true,
33+
})
34+
}
35+
36+
// After the guard above, dataRef is guaranteed to be initialized.
37+
const preferences: RemovableRef<HydratedUserPreferences> = dataRef
38+
39+
const { user } = useAtproto()
40+
41+
const isAuthenticated = computed(() => !!user.value?.did)
42+
const {
43+
status,
44+
lastSyncedAt,
45+
error,
46+
loadFromServer,
47+
scheduleSync,
48+
setupRouteGuard,
49+
setupBeforeUnload,
50+
} = useUserPreferencesSync()
51+
52+
const isSyncing = computed(() => status.value === 'syncing')
53+
const isSynced = computed(() => status.value === 'synced')
54+
const hasError = computed(() => status.value === 'error')
55+
56+
async function initSync(): Promise<void> {
57+
if (syncInitialized || import.meta.server) return
58+
syncInitialized = true
59+
60+
setupRouteGuard(() => preferences.value)
61+
setupBeforeUnload(() => preferences.value)
62+
63+
if (isAuthenticated.value) {
64+
const serverPrefs = await loadFromServer()
65+
if (serverPrefs) {
66+
preferences.value = { ...preferences.value, ...serverPrefs }
67+
}
68+
}
69+
70+
watch(
71+
preferences,
72+
newPrefs => {
73+
if (isAuthenticated.value) {
74+
scheduleSync(newPrefs)
75+
}
76+
},
77+
{ deep: true },
78+
)
79+
80+
watch(isAuthenticated, async newIsAuth => {
81+
if (newIsAuth) {
82+
const serverPrefs = await loadFromServer()
83+
if (serverPrefs) {
84+
preferences.value = { ...defaultValue, ...preferences.value, ...serverPrefs }
85+
}
86+
}
87+
})
88+
}
89+
90+
return {
91+
data: preferences,
92+
isAuthenticated,
93+
isSyncing,
94+
isSynced,
95+
hasError,
96+
syncError: error,
97+
lastSyncedAt,
98+
initSync,
99+
}
100+
}

app/pages/search.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ onMounted(() => {
6565
const rawVisibleResults = computed(() => results.value)
6666
6767
// Settings for platform package filtering
68-
const { settings } = useSettings()
68+
const { preferences } = useUserPreferences()
6969
7070
/**
7171
* Reorder results to put exact package name match at the top,
@@ -78,7 +78,7 @@ const visibleResults = computed(() => {
7878
let objects = raw.objects
7979
8080
// Filter out platform-specific packages if setting is enabled
81-
if (settings.value.hidePlatformPackages) {
81+
if (preferences.value.hidePlatformPackages) {
8282
objects = objects.filter(r => !isPlatformSpecificPackage(r.package.name))
8383
}
8484

0 commit comments

Comments
 (0)