Skip to main content

max / goingson

7.9 KB · 251 lines History Blame Raw
1 /**
2 * GoingsOn - Theme Management
3 * Loads themes from shared TOML files via Tauri commands
4 */
5
6 (function() {
7 'use strict';
8
9 const { invoke } = window.__TAURI__.core;
10
11 // Theme state lives in GoingsOn.state (centralized)
12 GoingsOn.state.set('currentThemeId', 'goingson');
13 GoingsOn.state.set('themeCache', {});
14 GoingsOn.state.set('themeList', []);
15
16 // ============ Theme Functions ============
17
18 /**
19 * Fetch and cache theme list from backend.
20 * @returns {Promise<Array<Object>>} Array of theme metadata objects
21 */
22 async function fetchThemeList() {
23 const cached = GoingsOn.state.themeList;
24 if (cached.length > 0) return cached;
25 try {
26 const list = await invoke('list_themes');
27 GoingsOn.state.set('themeList', list);
28 return list;
29 } catch (e) {
30 console.error('Failed to list themes:', e);
31 GoingsOn.state.set('themeList', []);
32 return [];
33 }
34 }
35
36 /**
37 * Fetch a single theme's resolved intent tokens, using cache if available.
38 * @param {string} themeId - Theme ID to fetch
39 * @returns {Promise<Object|null>} Theme object with `intents`, or null on error
40 */
41 async function fetchTheme(themeId) {
42 const cache = GoingsOn.state.themeCache;
43 if (cache[themeId]) return cache[themeId];
44 try {
45 const theme = await invoke('get_theme', { id: themeId });
46 cache[themeId] = theme;
47 GoingsOn.state.set('themeCache', cache);
48 return theme;
49 } catch (e) {
50 console.error(`Failed to load theme ${themeId}:`, e);
51 return null;
52 }
53 }
54
55 /**
56 * Apply a theme's resolved intent tokens as CSS custom properties on the
57 * document root. Each intent key becomes `--{key}` (e.g. `surface-page` ->
58 * `--surface-page`); styles.css aliases the app's brand vocabulary over them.
59 * @param {Object} intents - Map of intent token name -> color value
60 */
61 function applyColors(intents) {
62 const root = document.documentElement;
63 for (const [token, value] of Object.entries(intents)) {
64 root.style.setProperty(`--${token}`, value);
65 }
66 // Sync the iOS / browser chrome color with the active theme's page
67 // background so the status bar matches.
68 const themeMeta = document.getElementById('meta-theme-color');
69 if (themeMeta && intents['surface-page']) {
70 themeMeta.setAttribute('content', intents['surface-page']);
71 }
72 }
73
74 /**
75 * Load and apply a theme by ID, saving the selection to localStorage.
76 * @param {string} themeId - Theme ID to load
77 */
78 async function loadTheme(themeId) {
79 const theme = await fetchTheme(themeId);
80 if (!theme) {
81 console.warn(`Theme not found: ${themeId}, falling back to goingson`);
82 if (themeId !== 'goingson') {
83 return loadTheme('goingson');
84 }
85 return;
86 }
87
88 applyColors(theme.intents);
89 GoingsOn.state.set('currentThemeId', themeId);
90 localStorage.setItem('goingson-theme', themeId);
91
92 // Update the theme selector if it exists
93 const selector = document.getElementById('theme-selector');
94 if (selector) {
95 selector.value = themeId;
96 }
97 }
98
99 /**
100 * Get the user's system theme preference.
101 * @returns {string} 'dark' or 'light'
102 */
103 function getSystemThemePreference() {
104 if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
105 return 'dark';
106 }
107 return 'light';
108 }
109
110 /**
111 * Load theme from localStorage or use system preference
112 */
113 async function loadThemeFromStorage() {
114 // Pre-fetch the theme list so it's cached for the settings UI
115 await fetchThemeList();
116
117 const savedTheme = localStorage.getItem('goingson-theme');
118 if (savedTheme === 'system') {
119 await applySystemTheme();
120 } else if (savedTheme) {
121 await loadTheme(savedTheme);
122 } else {
123 await applySystemTheme();
124 }
125 }
126
127 /**
128 * Apply theme based on system preference
129 */
130 async function applySystemTheme() {
131 const preference = getSystemThemePreference();
132 if (preference === 'dark') {
133 await loadTheme('catppuccin-mocha');
134 } else {
135 await loadTheme('goingson');
136 }
137 localStorage.setItem('goingson-theme', 'system');
138 }
139
140 /**
141 * Handle theme selector change.
142 * @param {string} value - Selected theme ID or 'system'
143 */
144 async function onThemeChange(value) {
145 if (value === 'system') {
146 await applySystemTheme();
147 } else {
148 await loadTheme(value);
149 }
150 }
151
152 /**
153 * Get themes grouped by type (for settings UI).
154 * @returns {Promise<{light: Object[], dark: Object[], highContrast: Object[]}>}
155 */
156 async function getThemesByType() {
157 const list = await fetchThemeList();
158 const light = [];
159 const dark = [];
160 const highContrast = [];
161 for (const t of list) {
162 if (t.variant === 'high-contrast') {
163 highContrast.push(t);
164 } else if (t.variant === 'light') {
165 light.push(t);
166 } else {
167 dark.push(t);
168 }
169 }
170 return { light, dark, highContrast };
171 }
172
173 /**
174 * Get current theme ID.
175 * @returns {string} Active theme ID
176 */
177 function getCurrentThemeId() {
178 return GoingsOn.state.currentThemeId;
179 }
180
181 // Listen for system theme changes
182 if (window.matchMedia) {
183 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
184 if (localStorage.getItem('goingson-theme') === 'system') {
185 applySystemTheme();
186 }
187 });
188 }
189
190 // ============ Import / Export ============
191
192 /**
193 * Import a custom theme TOML file via native file dialog.
194 */
195 async function importTheme() {
196 try {
197 const { open } = window.__TAURI__.dialog;
198 const path = await open({
199 filters: [{ name: 'Theme', extensions: ['toml'] }],
200 multiple: false,
201 });
202 if (!path) return;
203
204 const meta = await invoke('import_theme', { path });
205 // Clear caches so new theme is discoverable
206 GoingsOn.state.set('themeCache', {});
207 GoingsOn.state.set('themeList', []);
208 GoingsOn.ui.showToast(`Imported "${meta.name}"`);
209 await loadTheme(meta.id);
210 } catch (e) {
211 GoingsOn.ui.showToast('Import failed: ' + (e.message || e), 'error');
212 }
213 }
214
215 /**
216 * Export the current theme TOML file via native save dialog.
217 */
218 async function exportTheme() {
219 const currentId = GoingsOn.state.currentThemeId;
220 if (!currentId || currentId === 'system') return;
221 try {
222 const { save } = window.__TAURI__.dialog;
223 const path = await save({
224 defaultPath: currentId + '.toml',
225 filters: [{ name: 'Theme', extensions: ['toml'] }],
226 });
227 if (!path) return;
228
229 await invoke('export_theme', { id: currentId, path });
230 GoingsOn.ui.showToast('Theme exported');
231 } catch (e) {
232 GoingsOn.ui.showToast('Export failed: ' + (e.message || e), 'error');
233 }
234 }
235
236 // ============ Populate GoingsOn.themes Namespace ============
237
238 GoingsOn.themes = {
239 load: loadTheme,
240 loadFromStorage: loadThemeFromStorage,
241 applySystem: applySystemTheme,
242 onChange: onThemeChange,
243 getByType: getThemesByType,
244 getCurrentId: getCurrentThemeId,
245 getSystemPreference: getSystemThemePreference,
246 importTheme,
247 exportTheme,
248 };
249
250 })();
251