Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/components/Header/AuthModal.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,40 @@
const handleInput = shallowRef('')
const route = useRoute()
const { user, logout } = useAtproto()
const { settings } = useSettings()
const colorMode = useColorMode()
const { setLocale, locales, locale } = useI18n()

Check failure on line 10 in app/components/Header/AuthModal.client.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Cannot redeclare block-scoped variable 'locale'.

//TODO need to check them all and idk if this is the spot for it
watch(
user,
async loggedInUser => {
if (!loggedInUser) return

const remote = await $fetch('/api/auth/settings')
if (!remote) return

Object.assign(settings.value, remote)

// Sync theme with colorMode
if (remote.theme) {
colorMode.preference = remote.theme
}

// Sync locale if it's valid and different from current
if (
remote.selectedLocale &&
remote.selectedLocale !== locale.value &&
locales.value.map(l => l.code).includes(remote.selectedLocale)

Check failure on line 32 in app/components/Header/AuthModal.client.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Argument of type 'string' is not assignable to parameter of type '"ar-EG" | "az-AZ" | "cs-CZ" | "de-DE" | "en-GB" | "en-US" | "es-419" | "es-ES" | "fr-FR" | "hi-IN" | "hu-HU" | "id-ID" | "it-IT" | "ja-JP" | "mr-IN" | "ne-NP" | "no-NO" | "pl-PL" | ... 5 more ... | "zh-TW"'.
) {
setLocale(remote.selectedLocale)

Check failure on line 34 in app/components/Header/AuthModal.client.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Argument of type 'string' is not assignable to parameter of type '"ar-EG" | "az-AZ" | "cs-CZ" | "de-DE" | "en-GB" | "en-US" | "es-419" | "es-ES" | "fr-FR" | "hi-IN" | "hu-HU" | "id-ID" | "it-IT" | "ja-JP" | "mr-IN" | "ne-NP" | "no-NO" | "pl-PL" | ... 5 more ... | "zh-TW"'.
}
},
{ immediate: true },
)

// https://atproto.com supports 4 locales as of 2026-02-07
const { locale } = useI18n()

Check failure on line 41 in app/components/Header/AuthModal.client.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Cannot redeclare block-scoped variable 'locale'.
const currentLang = locale.value.split('-')[0] ?? 'en'
const localeSubPath = ['ko', 'pt', 'ja'].includes(currentLang) ? currentLang : ''
const atprotoLink = `https://atproto.com/${localeSubPath}`
Expand Down
36 changes: 10 additions & 26 deletions app/composables/useSettings.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,11 @@
import type { RemovableRef } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { ACCENT_COLORS } from '#shared/utils/constants'
import type { LocaleObject } from '@nuxtjs/i18n'
import { BACKGROUND_THEMES } from '#shared/utils/constants'

type BackgroundThemeId = keyof typeof BACKGROUND_THEMES

type AccentColorId = keyof typeof ACCENT_COLORS.light

/**
* 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
/** User-selected locale */
selectedLocale: LocaleObject['code'] | null
sidebar: {
collapsed: string[]
}
}
import type { AccentColorId, BackgroundThemeId, AppSettings } from '#shared/schemas/app-settings'

const DEFAULT_SETTINGS: AppSettings = {
theme: 'system',
relativeDates: false,
includeTypesInInstall: true,
accentColorId: null,
Expand All @@ -41,6 +17,14 @@ const DEFAULT_SETTINGS: AppSettings = {
},
}

export const syncSettings = async (settings: AppSettings) => {
// DO some error handling
await $fetch('/api/auth/settings', {
method: 'POST',
body: settings,
})
}

const STORAGE_KEY = 'npmx-settings'

// Shared settings instance (singleton per app)
Expand Down
10 changes: 9 additions & 1 deletion app/pages/settings.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
const router = useRouter()

const { settings } = useSettings()
const { locale, locales, setLocale: setNuxti18nLocale } = useI18n()
const colorMode = useColorMode()
const { currentLocaleStatus, isSourceLocale } = useI18nStatus()
const { user } = useAtproto()

// Escape to go back (but not when focused on form elements or modal is open)
onKeyStroke(
Expand Down Expand Up @@ -33,6 +35,12 @@ defineOgImageComponent('Default', {
primaryColor: '#60a5fa',
})

watch(settings.value, async () => {
if (!user.value) return

await syncSettings(settings.value)
})

const setLocale: typeof setNuxti18nLocale = locale => {
settings.value.selectedLocale = locale
return setNuxti18nLocale(locale)
Expand Down Expand Up @@ -78,14 +86,14 @@ const setLocale: typeof setNuxti18nLocale = locale => {
</label>
<select
id="theme-select"
:value="colorMode.preference"
class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg cursor-pointer duration-200 transition-colors hover:border-fg-subtle"
@change="
colorMode.preference = ($event.target as HTMLSelectElement).value as
| 'light'
| 'dark'
| 'system'
"
v-model="settings.theme"
>
<option value="system">
{{ $t('settings.theme_system') }}
Expand Down
10 changes: 10 additions & 0 deletions server/api/auth/settings.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { AppSettings } from '#shared/schemas/app-settings'

export default defineEventHandler(async event => {
// Current thinking is reads can just be a normal event handler and writes use the oauth
const serverSession = await useServerSession(event)

const storage = useStorage('atproto:generic')
const storageKey = `settings:${serverSession.data.public.did}`
return storage.getItem<AppSettings>(storageKey)
})
18 changes: 18 additions & 0 deletions server/api/auth/settings.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as v from 'valibot'
import { AppSettingsSchema } from '#shared/schemas/app-settings'
import type { AppSettings } from '#shared/schemas/app-settings'

export default eventHandlerWithOAuthSession(async (event, oauthSession) => {
// TODO: prob find a better spot. Can't be event handler cause i need oauth on session.delete
if (oauthSession == undefined) {
return createError({
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
const body = v.parse(AppSettingsSchema, await readBody(event))

const storage = useStorage('atproto:generic')
const storageKey = `settings:${oauthSession.did}`
storage.setItem<AppSettings>(storageKey, body)
})
18 changes: 12 additions & 6 deletions server/utils/atproto/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ export function getOauthClientMetadata() {
}) as OAuthClientMetadataInput
}

type EventHandlerWithOAuthSession<T extends EventHandlerRequest, D> = (
event: H3Event<T>,
session: OAuthSession | undefined,
serverSession: SessionManager,
) => Promise<D>

async function getOAuthSession(
event: H3Event,
): Promise<{ oauthSession: OAuthSession | undefined; serverSession: SessionManager }> {
Expand Down Expand Up @@ -100,11 +94,23 @@ export async function throwOnMissingOAuthScope(oAuthSession: OAuthSession, requi
}
}

type EventHandlerWithOAuthSession<T extends EventHandlerRequest, D> = (
event: H3Event<T>,
session: OAuthSession | undefined,
serverSession: SessionManager,
) => Promise<D>

/**
* Handler with a valid OAuth session that is ready to be used
* @param handler
* @returns
*/
export function eventHandlerWithOAuthSession<T extends EventHandlerRequest, D>(
handler: EventHandlerWithOAuthSession<T, D>,
) {
return defineEventHandler(async event => {
const { oauthSession, serverSession } = await getOAuthSession(event)

return await handler(event, oauthSession, serverSession)
})
}
28 changes: 28 additions & 0 deletions shared/schemas/app-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as v from 'valibot'
import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants'

type AccentColorKey = keyof typeof ACCENT_COLORS.light
type BackgroundThemeKey = keyof typeof BACKGROUND_THEMES

const accentColorIds = Object.keys(ACCENT_COLORS.light) as [AccentColorKey, ...AccentColorKey[]]
const backgroundThemeIds = Object.keys(BACKGROUND_THEMES) as [
BackgroundThemeKey,
...BackgroundThemeKey[],
]

export const AppSettingsSchema = v.object({
theme: v.picklist(['light', 'dark', 'system']),
relativeDates: v.boolean(),
includeTypesInInstall: v.boolean(),
accentColorId: v.nullable(v.picklist(accentColorIds)),
preferredBackgroundTheme: v.nullable(v.picklist(backgroundThemeIds)),
hidePlatformPackages: v.boolean(),
selectedLocale: v.nullable(v.string()),
sidebar: v.object({
collapsed: v.array(v.string()),
}),
})

export type AppSettings = v.InferOutput<typeof AppSettingsSchema>
export type AccentColorId = AppSettings['accentColorId']
export type BackgroundThemeId = AppSettings['preferredBackgroundTheme']
Loading