feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
+105
-172
@@ -1,197 +1,130 @@
|
||||
import { schema } from '#triplit/schema';
|
||||
import { type Entity } from '@triplit/client';
|
||||
import { computed, watch, ref, type Ref } from 'vue';
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
type UserSettings = Entity<typeof schema, 'settings'>;
|
||||
type UserSettings = typeof schema.settings.$inferSelect;
|
||||
type Appearance = UserSettings['appearance'];
|
||||
type SystemAssistants = UserSettings['systemAssistants'];
|
||||
|
||||
interface UserSettingsState {
|
||||
accent: Ref<string>;
|
||||
neutral: Ref<string>;
|
||||
hinting: Ref<string>;
|
||||
colorSchemePreference: Ref<'light' | 'dark' | 'system'>;
|
||||
colorSchemeClass: Ref<'light' | 'dark' | undefined>;
|
||||
remoteSettings: Ref<UserSettings | null>;
|
||||
initPromise: Promise<void> | null;
|
||||
}
|
||||
const defaultAppearance: Appearance = {
|
||||
colorScheme: 'system',
|
||||
accent: 'violet',
|
||||
neutral: 'zinc',
|
||||
hinting: 0,
|
||||
fontSize: 'md',
|
||||
};
|
||||
|
||||
export const useUserSettings = () => {
|
||||
const nuxtApp = useNuxtApp();
|
||||
const triplit = useTriplitClient();
|
||||
const { user, loggedIn } = useAuth();
|
||||
export const useUserSettings = async () => {
|
||||
const settings = useState<UserSettings | null>('settings_state', () => null);
|
||||
const loaded = useState('settings_loaded', () => false);
|
||||
const syncing = useState('settings_syncing', () => false);
|
||||
|
||||
if (!nuxtApp._userSettingsState) {
|
||||
nuxtApp._userSettingsState = {
|
||||
accent: ref('violet'),
|
||||
neutral: ref('zinc'),
|
||||
hinting: ref('0'),
|
||||
colorSchemePreference: ref<'light' | 'dark' | 'system'>('system'),
|
||||
colorSchemeClass: ref<undefined | 'light' | 'dark'>(undefined),
|
||||
remoteSettings: ref<UserSettings | null>(null),
|
||||
initPromise: null,
|
||||
} as UserSettingsState;
|
||||
}
|
||||
const localAppearance = useState<Appearance | null>('settings_local_appearance', () => null);
|
||||
const localSystemAssistants = useState<SystemAssistants | null>('settings_local_sa', () => null);
|
||||
|
||||
const state = nuxtApp._userSettingsState as UserSettingsState;
|
||||
|
||||
const init = (): Promise<void> => {
|
||||
if (state.initPromise) return state.initPromise;
|
||||
|
||||
state.initPromise = (async () => {
|
||||
const { results } = await useQuery('settings', triplit, triplit.query('settings'));
|
||||
|
||||
watch(results, (val) => {
|
||||
if (val && val.length > 0) {
|
||||
state.remoteSettings.value = val[0] as UserSettings;
|
||||
} else {
|
||||
state.remoteSettings.value = null;
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
watch(state.remoteSettings, (newSettings) => {
|
||||
if (!newSettings?.appearance) return;
|
||||
const { appearance } = newSettings;
|
||||
|
||||
if (appearance.colorScheme) {
|
||||
state.colorSchemePreference.value = appearance.colorScheme as 'light' | 'dark' | 'system';
|
||||
}
|
||||
if (appearance.accent) {
|
||||
state.accent.value = appearance.accent;
|
||||
}
|
||||
if (appearance.neutral) {
|
||||
state.neutral.value = appearance.neutral;
|
||||
}
|
||||
if (appearance.hinting !== undefined) {
|
||||
state.hinting.value = String(appearance.hinting);
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
if (import.meta.client) {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const update = () => {
|
||||
if (state.colorSchemePreference.value === 'system') {
|
||||
state.colorSchemeClass.value = mediaQuery.matches ? 'dark' : 'light';
|
||||
} else {
|
||||
state.colorSchemeClass.value = state.colorSchemePreference.value as 'dark' | 'light';
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener('change', update);
|
||||
watch(state.colorSchemePreference, update, { immediate: true });
|
||||
} else {
|
||||
watch(state.colorSchemePreference, (pref) => {
|
||||
state.colorSchemeClass.value = pref === 'system' ? 'dark' : (pref as 'dark' | 'light');
|
||||
}, { immediate: true });
|
||||
const { refresh } = await useFetch<UserSettings>('/api/settings', {
|
||||
key: 'settings_request',
|
||||
immediate: !loaded.value,
|
||||
onRequest() {
|
||||
loaded.value = true;
|
||||
},
|
||||
onResponse({ response }) {
|
||||
if (response.ok && response._data) {
|
||||
settings.value = response._data;
|
||||
localAppearance.value = { ...defaultAppearance, ...response._data.appearance };
|
||||
localSystemAssistants.value = response._data.systemAssistants;
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
return state.initPromise;
|
||||
const applyOptimisticUpdates = (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
|
||||
if (updates.appearance && localAppearance.value) {
|
||||
localAppearance.value = { ...localAppearance.value, ...updates.appearance };
|
||||
}
|
||||
if (updates.systemAssistants && localSystemAssistants.value) {
|
||||
localSystemAssistants.value = { ...localSystemAssistants.value, ...updates.systemAssistants };
|
||||
}
|
||||
if (updates.appearance && settings.value) {
|
||||
settings.value = {
|
||||
...settings.value,
|
||||
appearance: { ...settings.value.appearance, ...updates.appearance }
|
||||
};
|
||||
}
|
||||
if (updates.systemAssistants && settings.value) {
|
||||
settings.value = {
|
||||
...settings.value,
|
||||
systemAssistants: { ...settings.value.systemAssistants, ...updates.systemAssistants }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = async (updates: { appearance?: Partial<Appearance>; systemAssistants?: Partial<SystemAssistants> }) => {
|
||||
const original = settings.value;
|
||||
const originalAppearance = localAppearance.value;
|
||||
const originalSA = localSystemAssistants.value;
|
||||
|
||||
applyOptimisticUpdates(updates);
|
||||
|
||||
try {
|
||||
syncing.value = true;
|
||||
await $fetch(`/api/settings`, {
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
});
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
settings.value = original;
|
||||
localAppearance.value = originalAppearance;
|
||||
localSystemAssistants.value = originalSA;
|
||||
throw error;
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveAppearance = computed(() => {
|
||||
return localAppearance.value || defaultAppearance;
|
||||
});
|
||||
|
||||
const effectiveSystemAssistants = computed(() => {
|
||||
return localSystemAssistants.value || {};
|
||||
});
|
||||
|
||||
const colorSchemeValue = computed(() => {
|
||||
if (state.colorSchemePreference.value === 'system') {
|
||||
if (effectiveAppearance.value.colorScheme === 'system') {
|
||||
if (import.meta.client) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
return state.colorSchemePreference.value as 'dark' | 'light';
|
||||
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
|
||||
});
|
||||
|
||||
const settings = computed(() => {
|
||||
const remote = state.remoteSettings.value;
|
||||
return {
|
||||
appearance: {
|
||||
colorScheme: remote?.appearance?.colorScheme ?? state.colorSchemePreference.value,
|
||||
accent: remote?.appearance?.accent ?? state.accent.value,
|
||||
neutral: remote?.appearance?.neutral ?? state.neutral.value,
|
||||
hinting: remote?.appearance?.hinting ?? Number(state.hinting.value),
|
||||
fontSize: remote?.appearance?.fontSize ?? 'medium',
|
||||
},
|
||||
systemAssistants: {
|
||||
rename: {
|
||||
enabled: remote?.systemAssistants?.rename?.enabled ?? false,
|
||||
prompt: remote?.systemAssistants?.rename?.prompt ?? null,
|
||||
modelId: remote?.systemAssistants?.rename?.modelId ?? null,
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const updateSettings = async (updates: {
|
||||
appearance?: {
|
||||
colorScheme?: 'light' | 'dark' | 'system';
|
||||
accent?: string;
|
||||
neutral?: string;
|
||||
hinting?: number;
|
||||
fontSize?: string;
|
||||
};
|
||||
systemAssistants?: {
|
||||
rename?: {
|
||||
enabled?: boolean;
|
||||
prompt?: string | null;
|
||||
modelId?: string | null;
|
||||
};
|
||||
};
|
||||
}) => {
|
||||
if (updates.appearance) {
|
||||
if (updates.appearance.colorScheme) state.colorSchemePreference.value = updates.appearance.colorScheme;
|
||||
if (updates.appearance.accent) state.accent.value = updates.appearance.accent;
|
||||
if (updates.appearance.neutral) state.neutral.value = updates.appearance.neutral;
|
||||
if (updates.appearance.hinting !== undefined) state.hinting.value = String(updates.appearance.hinting);
|
||||
}
|
||||
|
||||
if (!loggedIn.value || !user.value?.id) return;
|
||||
|
||||
const current = state.remoteSettings.value;
|
||||
if (!current) {
|
||||
await triplit.insert('settings', {
|
||||
userId: user.value.id,
|
||||
appearance: {
|
||||
colorScheme: state.colorSchemePreference.value,
|
||||
accent: state.accent.value,
|
||||
neutral: state.neutral.value,
|
||||
hinting: Number(state.hinting.value),
|
||||
...(updates.appearance || {})
|
||||
},
|
||||
systemAssistants: {
|
||||
rename: {
|
||||
enabled: updates.systemAssistants?.rename?.enabled ?? true,
|
||||
prompt: updates.systemAssistants?.rename?.prompt ?? null,
|
||||
modelId: updates.systemAssistants?.rename?.modelId ?? null,
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await triplit.update('settings', current.id, (s) => {
|
||||
if (updates.appearance) {
|
||||
s.appearance = {
|
||||
...(s.appearance || {}),
|
||||
...updates.appearance
|
||||
};
|
||||
}
|
||||
if (updates.systemAssistants) {
|
||||
// @ts-expect-error
|
||||
s.systemAssistants = {
|
||||
...(s.systemAssistants || {}),
|
||||
...updates.systemAssistants
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
const effectiveSettings = computed(() => ({
|
||||
appearance: effectiveAppearance.value,
|
||||
systemAssistants: effectiveSystemAssistants.value,
|
||||
}));
|
||||
|
||||
return {
|
||||
init,
|
||||
settings,
|
||||
remoteSettings: state.remoteSettings,
|
||||
settings: effectiveSettings,
|
||||
rawSettings: readonly(settings),
|
||||
updateSettings,
|
||||
accent: state.accent,
|
||||
neutral: state.neutral,
|
||||
hinting: state.hinting,
|
||||
syncing: readonly(syncing),
|
||||
loaded: readonly(loaded),
|
||||
refresh,
|
||||
accent: computed(() => effectiveAppearance.value.accent ?? 'violet'),
|
||||
neutral: computed(() => effectiveAppearance.value.neutral ?? 'zinc'),
|
||||
hinting: computed(() => effectiveAppearance.value.hinting ?? 0),
|
||||
colorScheme: {
|
||||
preference: state.colorSchemePreference,
|
||||
preference: computed(() => effectiveAppearance.value.colorScheme ?? 'system'),
|
||||
value: colorSchemeValue,
|
||||
class: state.colorSchemeClass
|
||||
class: computed(() => {
|
||||
if (effectiveAppearance.value.colorScheme === 'system') {
|
||||
if (import.meta.client) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
return effectiveAppearance.value.colorScheme as 'dark' | 'light';
|
||||
}),
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user