feat: add settings synchronization feature with server, including UI toggle and localization
This commit is contained in:
@@ -43,3 +43,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# claude code
|
# claude code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# settings sync data
|
||||||
|
/data/
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSettingsStore } from '@/stores/settings-store';
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import { useConfig } from '@/hooks/use-config';
|
||||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export function AdvancedSettings() {
|
export function AdvancedSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
const { debugMode, settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
|
const { settingsSyncEnabled } = useConfig();
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ export function AdvancedSettings() {
|
|||||||
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
{/* Settings Sync */}
|
||||||
|
{settingsSyncEnabled && (
|
||||||
|
<SettingItem label={t('settings_sync.label')} description={t('settings_sync.description')}>
|
||||||
|
<ToggleSwitch checked={!settingsSyncDisabled} onChange={(checked) => updateSetting('settingsSyncDisabled', !checked)} />
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Export Settings */}
|
{/* Export Settings */}
|
||||||
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
<SettingItem label={t('export_settings.label')} description={t('export_settings.description')}>
|
||||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||||
|
|||||||
@@ -746,6 +746,10 @@
|
|||||||
"label": "Debug Mode",
|
"label": "Debug Mode",
|
||||||
"description": "Enable detailed logging for troubleshooting"
|
"description": "Enable detailed logging for troubleshooting"
|
||||||
},
|
},
|
||||||
|
"settings_sync": {
|
||||||
|
"label": "Settings Sync",
|
||||||
|
"description": "Sync your settings across browsers and devices"
|
||||||
|
},
|
||||||
"keyboard_shortcuts": {
|
"keyboard_shortcuts": {
|
||||||
"label": "Keyboard Shortcuts",
|
"label": "Keyboard Shortcuts",
|
||||||
"description": "View available keyboard shortcuts",
|
"description": "View available keyboard shortcuts",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { useThemeStore } from './theme-store';
|
||||||
|
import { useLocaleStore } from './locale-store';
|
||||||
|
|
||||||
|
// Use console directly to avoid circular dependency with lib/debug.ts
|
||||||
|
// (debug.ts imports useSettingsStore for debugMode check)
|
||||||
|
const syncLog = (...args: unknown[]) => console.log('[SETTINGS_SYNC]', ...args);
|
||||||
|
const syncWarn = (...args: unknown[]) => console.warn('[SETTINGS_SYNC]', ...args);
|
||||||
|
const syncError = (...args: unknown[]) => console.error('[SETTINGS_SYNC]', ...args);
|
||||||
|
|
||||||
// Settings sync state (module-level, not persisted)
|
// Settings sync state (module-level, not persisted)
|
||||||
let syncEnabled = false;
|
let syncEnabled = false;
|
||||||
@@ -58,6 +66,7 @@ interface SettingsState {
|
|||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
|
settingsSyncDisabled: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
updateSetting: <K extends keyof SettingsState>(
|
updateSetting: <K extends keyof SettingsState>(
|
||||||
@@ -122,6 +131,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
|
settingsSyncDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>()(
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
@@ -179,6 +189,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
|
settingsSyncDisabled: state.settingsSyncDisabled,
|
||||||
|
// Cross-store settings
|
||||||
|
theme: useThemeStore.getState().theme,
|
||||||
|
locale: useLocaleStore.getState().locale,
|
||||||
};
|
};
|
||||||
return JSON.stringify(settings, null, 2);
|
return JSON.stringify(settings, null, 2);
|
||||||
},
|
},
|
||||||
@@ -204,6 +218,14 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
applyListDensity(get().listDensity);
|
applyListDensity(get().listDensity);
|
||||||
applyAnimations(get().animationsEnabled);
|
applyAnimations(get().animationsEnabled);
|
||||||
|
|
||||||
|
// Apply cross-store settings
|
||||||
|
if (settings.theme) {
|
||||||
|
useThemeStore.getState().setTheme(settings.theme);
|
||||||
|
}
|
||||||
|
if (settings.locale) {
|
||||||
|
useLocaleStore.getState().setLocale(settings.locale);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to import settings:', error);
|
console.error('Failed to import settings:', error);
|
||||||
@@ -247,6 +269,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
syncUsername = username;
|
syncUsername = username;
|
||||||
syncServerUrl = serverUrl;
|
syncServerUrl = serverUrl;
|
||||||
syncEnabled = true;
|
syncEnabled = true;
|
||||||
|
syncLog('Settings sync enabled for', username);
|
||||||
},
|
},
|
||||||
|
|
||||||
disableSync: () => {
|
disableSync: () => {
|
||||||
@@ -257,26 +280,33 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
clearTimeout(syncTimeout);
|
clearTimeout(syncTimeout);
|
||||||
syncTimeout = null;
|
syncTimeout = null;
|
||||||
}
|
}
|
||||||
|
syncLog('Settings sync disabled');
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFromServer: async (username: string, serverUrl: string) => {
|
loadFromServer: async (username: string, serverUrl: string) => {
|
||||||
try {
|
try {
|
||||||
|
syncLog('Loading settings from server for', username);
|
||||||
const res = await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
headers: {
|
headers: {
|
||||||
'x-settings-username': username,
|
'x-settings-username': username,
|
||||||
'x-settings-server': serverUrl,
|
'x-settings-server': serverUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) return false;
|
if (!res.ok) {
|
||||||
|
syncLog('No server settings found (status', res.status + ')');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const { settings } = await res.json();
|
const { settings } = await res.json();
|
||||||
if (settings && typeof settings === 'object') {
|
if (settings && typeof settings === 'object') {
|
||||||
isLoadingFromServer = true;
|
isLoadingFromServer = true;
|
||||||
get().importSettings(JSON.stringify(settings));
|
get().importSettings(JSON.stringify(settings));
|
||||||
isLoadingFromServer = false;
|
isLoadingFromServer = false;
|
||||||
|
syncLog('Settings loaded from server successfully');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
syncError('Failed to load settings from server:', error);
|
||||||
isLoadingFromServer = false;
|
isLoadingFromServer = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -332,24 +362,45 @@ if (typeof window !== 'undefined') {
|
|||||||
applyListDensity(store.listDensity);
|
applyListDensity(store.listDensity);
|
||||||
applyAnimations(store.animationsEnabled);
|
applyAnimations(store.animationsEnabled);
|
||||||
|
|
||||||
// Auto-sync settings to server on any state change
|
// Shared sync function used by all store subscribers
|
||||||
useSettingsStore.subscribe(() => {
|
const triggerSync = () => {
|
||||||
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
||||||
if (syncTimeout) clearTimeout(syncTimeout);
|
if (syncTimeout) clearTimeout(syncTimeout);
|
||||||
syncTimeout = setTimeout(async () => {
|
syncTimeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||||
|
syncLog('Syncing settings to server...');
|
||||||
const res = await fetch('/api/settings', {
|
const res = await fetch('/api/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
||||||
});
|
});
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
|
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||||
syncEnabled = false;
|
syncEnabled = false;
|
||||||
|
} else if (!res.ok) {
|
||||||
|
syncError('Settings sync failed with status', res.status);
|
||||||
|
} else {
|
||||||
|
syncLog('Settings synced to server successfully');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Silently ignore sync failures
|
syncError('Settings sync error:', error);
|
||||||
}
|
}
|
||||||
}, SYNC_DEBOUNCE_MS);
|
}, SYNC_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-sync settings to server on any state change
|
||||||
|
let prevSyncDisabled = useSettingsStore.getState().settingsSyncDisabled;
|
||||||
|
useSettingsStore.subscribe(() => {
|
||||||
|
const currentSyncDisabled = useSettingsStore.getState().settingsSyncDisabled;
|
||||||
|
const syncToggleChanged = currentSyncDisabled !== prevSyncDisabled;
|
||||||
|
prevSyncDisabled = currentSyncDisabled;
|
||||||
|
// Skip sync if disabled, unless the toggle itself just changed
|
||||||
|
if (currentSyncDisabled && !syncToggleChanged) return;
|
||||||
|
triggerSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Also sync when theme or locale changes
|
||||||
|
useThemeStore.subscribe(triggerSync);
|
||||||
|
useLocaleStore.subscribe(triggerSync);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user