at main 7.1 kB view raw
1// user preferences state management 2import { browser } from '$app/environment'; 3import { API_URL } from '$lib/config'; 4import { auth } from '$lib/auth.svelte'; 5 6export type Theme = 'dark' | 'light' | 'system'; 7 8export interface UiSettings { 9 background_image_url?: string; 10 background_tile?: boolean; 11 use_playing_artwork_as_background?: boolean; 12} 13 14export interface Preferences { 15 accent_color: string | null; 16 auto_advance: boolean; 17 allow_comments: boolean; 18 hidden_tags: string[]; 19 theme: Theme; 20 enable_teal_scrobbling: boolean; 21 teal_needs_reauth: boolean; 22 show_sensitive_artwork: boolean; 23 show_liked_on_profile: boolean; 24 support_url: string | null; 25 ui_settings: UiSettings; 26 auto_download_liked: boolean; 27} 28 29const DEFAULT_PREFERENCES: Preferences = { 30 accent_color: null, 31 auto_advance: true, 32 allow_comments: true, 33 hidden_tags: ['ai'], 34 theme: 'dark', 35 enable_teal_scrobbling: false, 36 teal_needs_reauth: false, 37 show_sensitive_artwork: false, 38 show_liked_on_profile: false, 39 support_url: null, 40 ui_settings: {}, 41 auto_download_liked: false 42}; 43 44class PreferencesManager { 45 data = $state<Preferences | null>(null); 46 loading = $state(false); 47 private initialized = false; 48 49 get loaded(): boolean { 50 return this.data !== null; 51 } 52 53 get hiddenTags(): string[] { 54 return this.data?.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags; 55 } 56 57 get accentColor(): string | null { 58 return this.data?.accent_color ?? null; 59 } 60 61 get autoAdvance(): boolean { 62 return this.data?.auto_advance ?? DEFAULT_PREFERENCES.auto_advance; 63 } 64 65 get allowComments(): boolean { 66 return this.data?.allow_comments ?? DEFAULT_PREFERENCES.allow_comments; 67 } 68 69 get theme(): Theme { 70 return this.data?.theme ?? DEFAULT_PREFERENCES.theme; 71 } 72 73 get enableTealScrobbling(): boolean { 74 return this.data?.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling; 75 } 76 77 get tealNeedsReauth(): boolean { 78 return this.data?.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth; 79 } 80 81 get showSensitiveArtwork(): boolean { 82 return this.data?.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork; 83 } 84 85 get showLikedOnProfile(): boolean { 86 return this.data?.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile; 87 } 88 89 get supportUrl(): string | null { 90 return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url; 91 } 92 93 get uiSettings(): UiSettings { 94 return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings; 95 } 96 97 get autoDownloadLiked(): boolean { 98 return this.data?.auto_download_liked ?? DEFAULT_PREFERENCES.auto_download_liked; 99 } 100 101 setAutoDownloadLiked(enabled: boolean): void { 102 if (browser) { 103 localStorage.setItem('autoDownloadLiked', enabled ? '1' : '0'); 104 } 105 if (this.data) { 106 this.data = { ...this.data, auto_download_liked: enabled }; 107 } 108 } 109 110 setTheme(theme: Theme): void { 111 if (browser) { 112 localStorage.setItem('theme', theme); 113 this.applyTheme(theme); 114 } 115 this.update({ theme }); 116 } 117 118 applyTheme(theme: Theme): void { 119 if (!browser) return; 120 const root = document.documentElement; 121 root.classList.remove('theme-dark', 'theme-light'); 122 123 let effectiveTheme: 'dark' | 'light'; 124 if (theme === 'system') { 125 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 126 } else { 127 effectiveTheme = theme; 128 } 129 root.classList.add(`theme-${effectiveTheme}`); 130 } 131 132 async initialize(): Promise<void> { 133 if (!browser || this.initialized || this.loading) return; 134 this.initialized = true; 135 await this.fetch(); 136 } 137 138 async fetch(): Promise<void> { 139 if (!browser || !auth.isAuthenticated) return; 140 141 this.loading = true; 142 try { 143 // preserve theme from localStorage (theme is client-side only) 144 const storedTheme = localStorage.getItem('theme') as Theme | null; 145 const currentTheme = storedTheme ?? this.data?.theme ?? DEFAULT_PREFERENCES.theme; 146 147 const response = await fetch(`${API_URL}/preferences/`, { 148 credentials: 'include' 149 }); 150 if (response.ok) { 151 const data = await response.json(); 152 // auto_download_liked is stored locally since it's device-specific 153 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 154 this.data = { 155 accent_color: data.accent_color ?? null, 156 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance, 157 allow_comments: data.allow_comments ?? DEFAULT_PREFERENCES.allow_comments, 158 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags, 159 theme: currentTheme, 160 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 161 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 162 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 163 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 164 support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url, 165 ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings, 166 auto_download_liked: storedAutoDownload 167 }; 168 } else { 169 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 170 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme, auto_download_liked: storedAutoDownload }; 171 } 172 // apply theme after fetching 173 if (browser) { 174 this.applyTheme(this.data.theme); 175 } 176 } catch (error) { 177 console.error('failed to fetch preferences:', error); 178 // preserve theme on error too 179 const storedTheme = localStorage.getItem('theme') as Theme | null; 180 this.data = { ...DEFAULT_PREFERENCES, theme: storedTheme ?? DEFAULT_PREFERENCES.theme }; 181 } finally { 182 this.loading = false; 183 } 184 } 185 186 async update(updates: Partial<Preferences>): Promise<void> { 187 if (!browser || !auth.isAuthenticated) return; 188 189 // optimistic update 190 if (this.data) { 191 this.data = { ...this.data, ...updates }; 192 } 193 194 try { 195 await fetch(`${API_URL}/preferences/`, { 196 method: 'POST', 197 headers: { 'Content-Type': 'application/json' }, 198 credentials: 'include', 199 body: JSON.stringify(updates) 200 }); 201 } catch (error) { 202 console.error('failed to save preferences:', error); 203 // revert on error by refetching 204 await this.fetch(); 205 } 206 } 207 208 async updateUiSettings(updates: Partial<UiSettings>): Promise<void> { 209 if (!browser || !auth.isAuthenticated) return; 210 211 // optimistic update - merge with existing 212 if (this.data) { 213 this.data = { 214 ...this.data, 215 ui_settings: { ...this.data.ui_settings, ...updates } 216 }; 217 } 218 219 try { 220 const response = await fetch(`${API_URL}/preferences/`, { 221 method: 'POST', 222 headers: { 'Content-Type': 'application/json' }, 223 credentials: 'include', 224 body: JSON.stringify({ ui_settings: updates }) 225 }); 226 if (!response.ok) { 227 console.error('failed to save ui settings:', response.status); 228 await this.fetch(); 229 } 230 } catch (error) { 231 console.error('failed to save ui settings:', error); 232 await this.fetch(); 233 } 234 } 235 236 clear(): void { 237 this.data = null; 238 this.initialized = false; 239 } 240} 241 242export const preferences = new PreferencesManager();