208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react';
|
|
import toast from 'react-hot-toast';
|
|
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
|
import type { UserPreferences } from '../types';
|
|
import { COLOR_SCHEMES } from '../types';
|
|
|
|
const LOCAL_API_KEYS_STORAGE = 'kaboot.apiKeys';
|
|
|
|
type LocalApiKeys = Pick<UserPreferences, 'geminiApiKey' | 'openRouterApiKey' | 'openAIApiKey'>;
|
|
|
|
const normalizeApiKey = (value: unknown): string | undefined => {
|
|
if (typeof value !== 'string') return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
};
|
|
|
|
const readLocalApiKeys = (): LocalApiKeys => {
|
|
if (typeof window === 'undefined') return {};
|
|
try {
|
|
const raw = window.localStorage.getItem(LOCAL_API_KEYS_STORAGE);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
return {
|
|
geminiApiKey: normalizeApiKey(parsed.geminiApiKey),
|
|
openRouterApiKey: normalizeApiKey(parsed.openRouterApiKey),
|
|
openAIApiKey: normalizeApiKey(parsed.openAIApiKey),
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const writeLocalApiKeys = (keys: LocalApiKeys) => {
|
|
if (typeof window === 'undefined') return;
|
|
const normalized = {
|
|
geminiApiKey: normalizeApiKey(keys.geminiApiKey),
|
|
openRouterApiKey: normalizeApiKey(keys.openRouterApiKey),
|
|
openAIApiKey: normalizeApiKey(keys.openAIApiKey),
|
|
};
|
|
const hasAny = Boolean(
|
|
normalized.geminiApiKey || normalized.openRouterApiKey || normalized.openAIApiKey
|
|
);
|
|
if (!hasAny) {
|
|
window.localStorage.removeItem(LOCAL_API_KEYS_STORAGE);
|
|
return;
|
|
}
|
|
window.localStorage.setItem(LOCAL_API_KEYS_STORAGE, JSON.stringify(normalized));
|
|
};
|
|
|
|
const mergePreferencesWithLocalKeys = (prefs: UserPreferences, localKeys: LocalApiKeys): UserPreferences => ({
|
|
...prefs,
|
|
geminiApiKey: localKeys.geminiApiKey ?? prefs.geminiApiKey,
|
|
openRouterApiKey: localKeys.openRouterApiKey ?? prefs.openRouterApiKey,
|
|
openAIApiKey: localKeys.openAIApiKey ?? prefs.openAIApiKey,
|
|
});
|
|
|
|
const DEFAULT_PREFERENCES: UserPreferences = {
|
|
colorScheme: 'blue',
|
|
aiProvider: 'gemini',
|
|
};
|
|
|
|
export const applyColorScheme = (schemeId: string) => {
|
|
const scheme = COLOR_SCHEMES.find(s => s.id === schemeId) || COLOR_SCHEMES[0];
|
|
document.documentElement.style.setProperty('--theme-primary', scheme.primary);
|
|
document.documentElement.style.setProperty('--theme-primary-dark', scheme.primaryDark);
|
|
document.documentElement.style.setProperty('--theme-primary-darker', scheme.primaryDarker);
|
|
};
|
|
|
|
interface SubscriptionInfo {
|
|
hasAccess: boolean;
|
|
accessType: 'group' | 'subscription' | 'none';
|
|
generationCount: number | null;
|
|
generationLimit: number | null;
|
|
generationsRemaining: number | null;
|
|
}
|
|
|
|
interface UseUserPreferencesReturn {
|
|
preferences: UserPreferences;
|
|
hasAIAccess: boolean;
|
|
hasEarlyAccess: boolean;
|
|
subscription: SubscriptionInfo | null;
|
|
loading: boolean;
|
|
saving: boolean;
|
|
fetchPreferences: () => Promise<void>;
|
|
savePreferences: (prefs: UserPreferences) => Promise<void>;
|
|
applyColorScheme: (schemeId: string) => void;
|
|
}
|
|
|
|
export const useUserPreferences = (): UseUserPreferencesReturn => {
|
|
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
|
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
|
|
const [hasAIAccess, setHasAIAccess] = useState(false);
|
|
const [hasEarlyAccess, setHasEarlyAccess] = useState(false);
|
|
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const fetchPreferences = useCallback(async () => {
|
|
if (!isAuthenticated) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await authFetch('/api/users/me/preferences');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const serverPrefs: UserPreferences = {
|
|
colorScheme: data.colorScheme || 'blue',
|
|
aiProvider: data.aiProvider || 'gemini',
|
|
geminiApiKey: data.geminiApiKey || undefined,
|
|
geminiModel: data.geminiModel || undefined,
|
|
openRouterApiKey: data.openRouterApiKey || undefined,
|
|
openRouterModel: data.openRouterModel || undefined,
|
|
openAIApiKey: data.openAIApiKey || undefined,
|
|
openAIModel: data.openAIModel || undefined,
|
|
};
|
|
const localKeys = readLocalApiKeys();
|
|
const mergedPrefs = mergePreferencesWithLocalKeys(serverPrefs, localKeys);
|
|
const mergedLocalKeys: LocalApiKeys = {
|
|
geminiApiKey: localKeys.geminiApiKey ?? serverPrefs.geminiApiKey,
|
|
openRouterApiKey: localKeys.openRouterApiKey ?? serverPrefs.openRouterApiKey,
|
|
openAIApiKey: localKeys.openAIApiKey ?? serverPrefs.openAIApiKey,
|
|
};
|
|
writeLocalApiKeys(mergedLocalKeys);
|
|
setPreferences(mergedPrefs);
|
|
setHasAIAccess(data.hasAIAccess || false);
|
|
setHasEarlyAccess(data.hasEarlyAccess || false);
|
|
applyColorScheme(mergedPrefs.colorScheme);
|
|
|
|
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
|
try {
|
|
const subResponse = await authFetch(`${backendUrl}/api/payments/status`);
|
|
if (subResponse.ok) {
|
|
const subData = await subResponse.json();
|
|
setSubscription({
|
|
hasAccess: subData.hasAccess,
|
|
accessType: subData.accessType,
|
|
generationCount: subData.generationCount,
|
|
generationLimit: subData.generationLimit,
|
|
generationsRemaining: subData.generationsRemaining,
|
|
});
|
|
}
|
|
} catch {
|
|
// Payments not configured, ignore
|
|
}
|
|
}
|
|
} catch {
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [authFetch, isAuthenticated]);
|
|
|
|
const savePreferences = useCallback(async (prefs: UserPreferences) => {
|
|
setSaving(true);
|
|
try {
|
|
const nextPrefs: UserPreferences = { ...preferences, ...prefs };
|
|
const localKeys: LocalApiKeys = {
|
|
geminiApiKey: nextPrefs.geminiApiKey,
|
|
openRouterApiKey: nextPrefs.openRouterApiKey,
|
|
openAIApiKey: nextPrefs.openAIApiKey,
|
|
};
|
|
writeLocalApiKeys(localKeys);
|
|
|
|
const serverPrefs = {
|
|
...nextPrefs,
|
|
geminiApiKey: undefined,
|
|
openRouterApiKey: undefined,
|
|
openAIApiKey: undefined,
|
|
};
|
|
|
|
const response = await authFetch('/api/users/me/preferences', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(serverPrefs),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to save preferences');
|
|
}
|
|
|
|
const normalizedLocalKeys = readLocalApiKeys();
|
|
const mergedPrefs = mergePreferencesWithLocalKeys(nextPrefs, normalizedLocalKeys);
|
|
setPreferences(mergedPrefs);
|
|
applyColorScheme(mergedPrefs.colorScheme);
|
|
toast.success('Preferences saved!');
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
toast.error(message);
|
|
throw err;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [authFetch, preferences]);
|
|
|
|
useEffect(() => {
|
|
fetchPreferences();
|
|
}, [fetchPreferences]);
|
|
|
|
return {
|
|
preferences,
|
|
hasAIAccess,
|
|
hasEarlyAccess,
|
|
subscription,
|
|
loading,
|
|
saving,
|
|
fetchPreferences,
|
|
savePreferences,
|
|
applyColorScheme,
|
|
};
|
|
};
|