mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
2246 lines
73 KiB
Svelte
2246 lines
73 KiB
Svelte
<script lang="ts">
|
|
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile } from '$lib/stores/app';
|
|
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
|
|
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
|
import { listen } from '@tauri-apps/api/event';
|
|
import { getVersion } from '@tauri-apps/api/app';
|
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
|
import type { ImportResult, BackupEntry } from '$lib/types';
|
|
|
|
const isMobile = /android|ios/i.test(navigator.userAgent);
|
|
|
|
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates';
|
|
let activeTab = $state<Tab>('styling');
|
|
|
|
// Updates state
|
|
let updateChecking = $state(false);
|
|
let updateAvailable = $state<{ version: string; body?: string; date?: string } | null>(null);
|
|
let updateDownloading = $state(false);
|
|
let updateProgress = $state(0);
|
|
let updateMessage = $state<{ type: 'success' | 'error' | 'info'; text: string } | null>(null);
|
|
let updateObj = $state<any>(null);
|
|
let appVersion = $state('...');
|
|
|
|
async function loadAppVersion() {
|
|
try { appVersion = await getVersion(); } catch { appVersion = '0.0.0'; }
|
|
}
|
|
loadAppVersion();
|
|
|
|
// Switch to requested tab if set externally (e.g. from update badge)
|
|
$effect(() => {
|
|
const tab = $settingsTab;
|
|
if (tab) {
|
|
activeTab = tab as Tab;
|
|
$settingsTab = null;
|
|
}
|
|
});
|
|
|
|
// Pre-populate from global store if update was already detected at startup
|
|
$effect(() => {
|
|
const global = $globalUpdateAvailable;
|
|
if (global && !updateAvailable) {
|
|
updateAvailable = { version: global.version, body: global.body };
|
|
}
|
|
});
|
|
|
|
// Pre-populate updater object from global store so Download & Install works without manual check
|
|
$effect(() => {
|
|
const obj = $globalUpdateObj;
|
|
if (obj && !updateObj) {
|
|
updateObj = obj;
|
|
}
|
|
});
|
|
|
|
async function handleCheckUpdate() {
|
|
updateChecking = true;
|
|
updateMessage = null;
|
|
updateAvailable = null;
|
|
try {
|
|
if (isMobile) {
|
|
await checkForUpdateMobile();
|
|
const global = $globalUpdateAvailable;
|
|
if (global) {
|
|
updateAvailable = { version: global.version, body: global.body };
|
|
updateMessage = { type: 'info', text: `Version ${global.version} is available!` };
|
|
} else {
|
|
updateMessage = { type: 'success', text: 'You are on the latest version.' };
|
|
}
|
|
} else {
|
|
const { check: checkUpdate } = await import('@tauri-apps/plugin-updater');
|
|
const update = await checkUpdate();
|
|
if (update) {
|
|
updateObj = update;
|
|
$globalUpdateObj = update;
|
|
updateAvailable = { version: update.version, body: update.body, date: update.date };
|
|
globalUpdateAvailable.set({ version: update.version, body: update.body });
|
|
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
|
|
} else {
|
|
updateMessage = { type: 'success', text: 'You are on the latest version.' };
|
|
}
|
|
}
|
|
} catch (e) {
|
|
updateMessage = { type: 'error', text: `Failed to check: ${e}` };
|
|
} finally {
|
|
updateChecking = false;
|
|
}
|
|
}
|
|
|
|
async function handleDownloadAndInstall() {
|
|
if (!updateObj) return;
|
|
updateDownloading = true;
|
|
updateProgress = 0;
|
|
updateMessage = { type: 'info', text: 'Downloading update...' };
|
|
try {
|
|
let totalBytes = 0;
|
|
let downloadedBytes = 0;
|
|
await updateObj.downloadAndInstall((event: any) => {
|
|
if (event.event === 'Started' && event.data.contentLength) {
|
|
totalBytes = event.data.contentLength;
|
|
} else if (event.event === 'Progress') {
|
|
downloadedBytes += event.data.chunkLength;
|
|
if (totalBytes > 0) {
|
|
updateProgress = Math.round((downloadedBytes / totalBytes) * 100);
|
|
}
|
|
} else if (event.event === 'Finished') {
|
|
updateProgress = 100;
|
|
}
|
|
});
|
|
updateMessage = { type: 'success', text: 'Update installed! Restart the app to apply.' };
|
|
} catch (e) {
|
|
updateMessage = { type: 'error', text: `Update failed: ${e}` };
|
|
} finally {
|
|
updateDownloading = false;
|
|
}
|
|
}
|
|
|
|
// Backup state
|
|
let backups = $state<BackupEntry[]>([]);
|
|
let backupLoading = $state(false);
|
|
let backupMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
let restoreConfirm = $state<BackupEntry | null>(null);
|
|
|
|
async function loadBackups() {
|
|
try {
|
|
backups = await listBackups();
|
|
} catch (e) {
|
|
console.error('Failed to load backups:', e);
|
|
}
|
|
}
|
|
|
|
async function handleBackupNow() {
|
|
backupLoading = true;
|
|
backupMessage = null;
|
|
const unlisten = await listen<{ success: boolean; entry?: BackupEntry; error?: string }>('backup-done', async (event) => {
|
|
const data = event.payload;
|
|
if (data.success) {
|
|
backupMessage = { type: 'success', text: 'Backup created successfully' };
|
|
await loadBackups();
|
|
if ($appConfig) {
|
|
$appConfig = { ...$appConfig, last_backup_time: new Date().toISOString() };
|
|
}
|
|
} else {
|
|
backupMessage = { type: 'error', text: `Backup failed: ${data.error}` };
|
|
}
|
|
backupLoading = false;
|
|
unlisten();
|
|
});
|
|
try {
|
|
await createBackup();
|
|
} catch (e) {
|
|
backupMessage = { type: 'error', text: `Backup failed: ${e}` };
|
|
backupLoading = false;
|
|
unlisten();
|
|
}
|
|
}
|
|
|
|
async function handleRestore(entry: BackupEntry) {
|
|
restoreConfirm = null;
|
|
backupLoading = true;
|
|
backupMessage = null;
|
|
const unlisten = await listen<{ success: boolean; error?: string }>('restore-done', (event) => {
|
|
const data = event.payload;
|
|
if (data.success) {
|
|
backupMessage = { type: 'success', text: 'Backup restored. Restart the app to see changes.' };
|
|
} else {
|
|
backupMessage = { type: 'error', text: `Restore failed: ${data.error}` };
|
|
}
|
|
backupLoading = false;
|
|
unlisten();
|
|
});
|
|
try {
|
|
await restoreBackup(entry.path);
|
|
} catch (e) {
|
|
backupMessage = { type: 'error', text: `Restore failed: ${e}` };
|
|
backupLoading = false;
|
|
unlisten();
|
|
}
|
|
}
|
|
|
|
async function handleDeleteBackup(entry: BackupEntry) {
|
|
try {
|
|
await deleteBackup(entry.path);
|
|
backups = backups.filter(b => b.path !== entry.path);
|
|
} catch (e) {
|
|
console.error('Failed to delete backup:', e);
|
|
}
|
|
}
|
|
|
|
async function handleSelectBackupFolder() {
|
|
const selected = await openDialog({ directory: true, multiple: false, title: 'Select backup folder' });
|
|
if (selected && $appConfig) {
|
|
await setBackupSettings($appConfig.backup_enabled, $appConfig.backup_frequency, $appConfig.backup_max_count, selected as string, $appConfig.backup_include_attachments);
|
|
$appConfig = { ...$appConfig, backup_location: selected as string };
|
|
await loadBackups();
|
|
}
|
|
}
|
|
|
|
async function saveBackupSettings() {
|
|
if (!$appConfig) return;
|
|
await setBackupSettings($appConfig.backup_enabled, $appConfig.backup_frequency, $appConfig.backup_max_count, $appConfig.backup_location, $appConfig.backup_include_attachments);
|
|
}
|
|
|
|
function formatBackupSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function formatBackupDate(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
// AI state
|
|
let aiProvider = $state<string | null>($appConfig?.ai_provider ?? null);
|
|
let aiApiKey = $derived.by(() => {
|
|
if (aiProvider === 'openai') return _openaiKey;
|
|
if (aiProvider === 'ollama') return '';
|
|
return _anthropicKey;
|
|
});
|
|
let _anthropicKey = $state($appConfig?.ai_api_key ?? '');
|
|
let _openaiKey = $state($appConfig?.openai_api_key ?? '');
|
|
let _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434');
|
|
let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-6');
|
|
let aiWritingStyle = $state($appConfig?.ai_writing_style ?? '');
|
|
let aiShowKey = $state(false);
|
|
let aiTestLoading = $state(false);
|
|
let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
const anthropicModels = [
|
|
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', desc: 'Balanced' },
|
|
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6', desc: 'Most capable' },
|
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', desc: 'Fast' },
|
|
];
|
|
const openaiModels = [
|
|
{ value: 'gpt-5.2', label: 'GPT-5.2', desc: 'Most capable' },
|
|
{ value: 'gpt-5.1', label: 'GPT-5.1', desc: 'Balanced' },
|
|
{ value: 'gpt-5-mini', label: 'GPT-5 Mini', desc: 'Fast' },
|
|
{ value: 'gpt-4.1', label: 'GPT-4.1', desc: 'Previous gen' },
|
|
];
|
|
let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels);
|
|
|
|
async function saveAiSettings() {
|
|
const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null;
|
|
await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || null, baseUrl);
|
|
if ($appConfig) {
|
|
$appConfig = {
|
|
...$appConfig,
|
|
ai_provider: aiProvider,
|
|
ai_api_key: _anthropicKey || null,
|
|
openai_api_key: _openaiKey || null,
|
|
ollama_base_url: _ollamaBaseUrl || null,
|
|
ai_model: aiModel,
|
|
ai_writing_style: aiWritingStyle || null,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleTestAi() {
|
|
aiTestLoading = true;
|
|
aiTestMessage = null;
|
|
const unlisten = await listen<{ success: boolean; message?: string; error?: string }>('ai-test-result', (event) => {
|
|
const data = event.payload;
|
|
if (data.success) {
|
|
aiTestMessage = { type: 'success', text: 'Connection successful!' };
|
|
} else {
|
|
aiTestMessage = { type: 'error', text: data.error ?? 'Connection failed' };
|
|
}
|
|
aiTestLoading = false;
|
|
unlisten();
|
|
});
|
|
try {
|
|
await testAiConnection();
|
|
} catch (e) {
|
|
aiTestMessage = { type: 'error', text: String(e) };
|
|
aiTestLoading = false;
|
|
unlisten();
|
|
}
|
|
}
|
|
|
|
const accentPresets = [
|
|
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
|
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
|
{ name: 'Emerald', light: '#059669', dark: '#5bab8a' },
|
|
{ name: 'Amber', light: '#d97706', dark: '#c9a04e' },
|
|
{ name: 'Purple', light: '#7c3aed', dark: '#9a82c4' },
|
|
{ name: 'Cyan', light: '#0891b2', dark: '#5ba8b5' },
|
|
{ name: 'Orange', light: '#ea580c', dark: '#c98560' },
|
|
{ name: 'Teal', light: '#0d9488', dark: '#5fada5' },
|
|
];
|
|
|
|
const fontSizePresets = [
|
|
{ label: 'Small', size: 13 },
|
|
{ label: 'Default', size: 14 },
|
|
{ label: 'Medium', size: 15 },
|
|
{ label: 'Large', size: 16 },
|
|
{ label: 'Extra Large', size: 18 },
|
|
];
|
|
|
|
const fontFamilyPresets = [
|
|
{ name: 'System', value: 'system', stack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
|
|
{ name: 'Inter', value: 'inter', stack: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' },
|
|
{ name: 'Georgia', value: 'georgia', stack: 'Georgia, "Times New Roman", serif' },
|
|
{ name: 'Merriweather', value: 'merriweather', stack: '"Merriweather", Georgia, serif' },
|
|
{ name: 'Lora', value: 'lora', stack: '"Lora", Georgia, serif' },
|
|
{ name: 'Open Sans', value: 'opensans', stack: '"Open Sans", -apple-system, sans-serif' },
|
|
{ name: 'Literata', value: 'literata', stack: '"Literata", Georgia, serif' },
|
|
{ name: 'Mono', value: 'mono', stack: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace' },
|
|
];
|
|
|
|
const lineHeightPresets = [
|
|
{ label: 'Tight', value: 1.4 },
|
|
{ label: 'Default', value: 1.6 },
|
|
{ label: 'Relaxed', value: 1.8 },
|
|
{ label: 'Loose', value: 2.0 },
|
|
];
|
|
|
|
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
|
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
|
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
|
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
|
|
|
// General settings
|
|
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
|
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
|
let gpuAcceleration = $state($appConfig?.gpu_acceleration ?? true);
|
|
let autostart = $state($appConfig?.autostart ?? false);
|
|
|
|
// Editor settings
|
|
let pdfPreview = $state($appConfig?.pdf_preview ?? false);
|
|
let pdfHeight = $state($appConfig?.pdf_height ?? 600);
|
|
let titleMode = $state($appConfig?.title_mode ?? 'input');
|
|
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
|
|
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
|
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
|
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
|
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
|
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
|
|
|
|
const pdfHeightPresets = [
|
|
{ label: 'Small', value: 300 },
|
|
{ label: 'Medium', value: 450 },
|
|
{ label: 'Default', value: 600 },
|
|
{ label: 'Large', value: 800 },
|
|
{ label: 'Full', value: 1000 },
|
|
];
|
|
|
|
// Import state
|
|
let importLoading = $state(false);
|
|
let importResult = $state<ImportResult | null>(null);
|
|
let importError = $state<string | null>(null);
|
|
async function runObsidianImport() {
|
|
importLoading = true;
|
|
importResult = null;
|
|
importError = null;
|
|
|
|
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => {
|
|
const data = event.payload;
|
|
if (data.success) {
|
|
importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 };
|
|
} else {
|
|
importError = data.error ?? 'Import failed';
|
|
}
|
|
importLoading = false;
|
|
unlistenDone();
|
|
});
|
|
|
|
try {
|
|
await importObsidian();
|
|
} catch (e) {
|
|
importError = String(e);
|
|
importLoading = false;
|
|
unlistenDone();
|
|
}
|
|
}
|
|
|
|
function saveGeneralSettings() {
|
|
if ($appConfig) {
|
|
$appConfig.compact_notes = compactNotes;
|
|
$appConfig.time_format = timeFormat;
|
|
$appConfig.gpu_acceleration = gpuAcceleration;
|
|
$appConfig.autostart = autostart;
|
|
$appConfig.pdf_preview = pdfPreview;
|
|
$appConfig.pdf_height = pdfHeight;
|
|
$appConfig.title_mode = titleMode;
|
|
$appConfig.hide_title_in_body = hideTitleInBody;
|
|
$appConfig.show_line_numbers = showLineNumbers;
|
|
$appConfig.default_view_mode = defaultViewMode;
|
|
$appConfig.show_tray_icon = showTrayIcon;
|
|
$appConfig.close_to_tray = closeToTray;
|
|
$appConfig.enable_wiki_links = enableWikiLinks;
|
|
}
|
|
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
|
|
.catch((e) => console.error('Failed to save general settings:', e));
|
|
}
|
|
|
|
function selectAccent(preset: typeof accentPresets[0]) {
|
|
activeAccent = preset.name;
|
|
applyAccent(preset);
|
|
saveConfig(preset.name);
|
|
}
|
|
|
|
function applyAccent(preset: typeof accentPresets[0]) {
|
|
const root = document.documentElement;
|
|
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
const color = isDark ? preset.dark : preset.light;
|
|
|
|
root.style.setProperty('--accent', color);
|
|
root.style.setProperty('--text-accent', color);
|
|
|
|
root.style.setProperty('--accent-hover', color);
|
|
if (isDark) {
|
|
root.style.setProperty('--accent-light', `color-mix(in srgb, ${color} 10%, transparent)`);
|
|
} else {
|
|
root.style.setProperty('--accent-light', `color-mix(in srgb, ${color} 8%, transparent)`);
|
|
}
|
|
}
|
|
|
|
function saveConfig(accentName: string) {
|
|
if ($appConfig) {
|
|
$appConfig.accent_color = accentName;
|
|
}
|
|
setAccentColor(accentName).catch((e) => console.error('Failed to save accent:', e));
|
|
}
|
|
|
|
function selectFontSize(size: number) {
|
|
activeFontSize = size;
|
|
applyFontSize(size);
|
|
if ($appConfig) $appConfig.font_size = size;
|
|
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
|
}
|
|
|
|
function applyFontSize(size: number) {
|
|
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
|
}
|
|
|
|
function selectFontFamily(preset: typeof fontFamilyPresets[0]) {
|
|
activeFontFamily = preset.value;
|
|
applyFontFamily(preset.stack);
|
|
if ($appConfig) $appConfig.font_family = preset.value;
|
|
setFontFamily(preset.value).catch((e) => console.error('Failed to save font family:', e));
|
|
}
|
|
|
|
function applyFontFamily(stack: string) {
|
|
document.documentElement.style.setProperty('--editor-font-family', stack);
|
|
}
|
|
|
|
function selectLineHeight(value: number) {
|
|
activeLineHeight = value;
|
|
applyLineHeight(value);
|
|
if ($appConfig) $appConfig.line_height = value;
|
|
setLineHeight(value).catch((e) => console.error('Failed to save line height:', e));
|
|
}
|
|
|
|
function applyLineHeight(value: number) {
|
|
document.documentElement.style.setProperty('--editor-line-height', String(value));
|
|
}
|
|
|
|
function close() {
|
|
// Ensure AI settings are saved when closing (in case input wasn't blurred)
|
|
// Only save if the key differs from what's already stored
|
|
if (aiProvider && aiApiKey && aiApiKey !== ($appConfig?.ai_api_key ?? '')) {
|
|
saveAiSettings();
|
|
}
|
|
$showSettings = false;
|
|
}
|
|
|
|
// Apply saved settings on mount
|
|
$effect(() => {
|
|
const savedAccent = $appConfig?.accent_color;
|
|
if (savedAccent) {
|
|
const preset = accentPresets.find(p => p.name === savedAccent);
|
|
if (preset) {
|
|
activeAccent = savedAccent;
|
|
applyAccent(preset);
|
|
}
|
|
}
|
|
const savedSize = $appConfig?.font_size;
|
|
if (savedSize) {
|
|
activeFontSize = savedSize;
|
|
applyFontSize(savedSize);
|
|
}
|
|
const savedFamily = $appConfig?.font_family;
|
|
if (savedFamily) {
|
|
const preset = fontFamilyPresets.find(p => p.value === savedFamily);
|
|
if (preset) {
|
|
activeFontFamily = savedFamily;
|
|
applyFontFamily(preset.stack);
|
|
}
|
|
}
|
|
const savedLineHeight = $appConfig?.line_height;
|
|
if (savedLineHeight) {
|
|
activeLineHeight = savedLineHeight;
|
|
applyLineHeight(savedLineHeight);
|
|
}
|
|
// Sync general settings
|
|
if ($appConfig) {
|
|
compactNotes = $appConfig.compact_notes ?? false;
|
|
timeFormat = $appConfig.time_format ?? 'relative';
|
|
gpuAcceleration = $appConfig.gpu_acceleration ?? true;
|
|
autostart = $appConfig.autostart ?? false;
|
|
pdfPreview = $appConfig.pdf_preview ?? false;
|
|
pdfHeight = $appConfig.pdf_height ?? 600;
|
|
titleMode = $appConfig.title_mode ?? 'input';
|
|
}
|
|
});
|
|
|
|
// Re-apply when theme changes
|
|
$effect(() => {
|
|
const _ = $theme;
|
|
const preset = accentPresets.find(p => p.name === activeAccent);
|
|
if (preset) {
|
|
applyAccent(preset);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if $showSettings}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
|
|
<div class="settings-header">
|
|
<h2>Settings</h2>
|
|
<button class="close-btn" onclick={close}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="settings-content">
|
|
<nav class="settings-tabs">
|
|
<button class="tab-btn" class:active={activeTab === 'general'} onclick={() => activeTab = 'general'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/><circle cx="12" cy="12" r="3"/>
|
|
</svg>
|
|
General
|
|
</button>
|
|
<button class="tab-btn" class:active={activeTab === 'editor'} onclick={() => activeTab = 'editor'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21.174 6.812a1 1 0 00-3.986-3.987L3.842 16.174a2 2 0 00-.5.83l-1.321 4.352a.5.5 0 00.623.622l4.353-1.32a2 2 0 00.83-.497z"/><path d="M15 5l4 4"/>
|
|
</svg>
|
|
Editor
|
|
</button>
|
|
<button class="tab-btn" class:active={activeTab === 'styling'} onclick={() => activeTab = 'styling'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 2v4"/><path d="m6.8 14-3.5 2"/><path d="m20.7 16-3.5-2"/><path d="M6.8 10 3.3 8"/><path d="m20.7 8-3.5 2"/><path d="m9 22 3-8 3 8"/><path d="M8 22h8"/><circle cx="12" cy="12" r="2"/>
|
|
</svg>
|
|
Styling
|
|
</button>
|
|
{#if !isMobile}
|
|
<button class="tab-btn" class:active={activeTab === 'import'} onclick={() => activeTab = 'import'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M8 5H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V7a2 2 0 00-2-2h-4"/>
|
|
</svg>
|
|
Import
|
|
</button>
|
|
{/if}
|
|
{#if !isMobile}
|
|
<button class="tab-btn" class:active={activeTab === 'backup'} onclick={() => { activeTab = 'backup'; loadBackups(); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 009 9 9.75 9.75 0 006.74-2.74L21 16"/><path d="M16 16h5v5"/>
|
|
</svg>
|
|
Backup
|
|
</button>
|
|
{/if}
|
|
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
|
</svg>
|
|
AI
|
|
</button>
|
|
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
|
|
</svg>
|
|
Updates
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="settings-body">
|
|
{#if activeTab === 'general'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Notes List</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Compact mode</span>
|
|
<span class="setting-desc">Show notes in a denser layout without preview</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={compactNotes} onclick={() => { compactNotes = !compactNotes; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Time Format</h3>
|
|
<div class="setting-options">
|
|
<button class="option-btn" class:active={timeFormat === 'relative'} onclick={() => { timeFormat = 'relative'; saveGeneralSettings(); }}>Relative</button>
|
|
<button class="option-btn" class:active={timeFormat === '12h'} onclick={() => { timeFormat = '12h'; saveGeneralSettings(); }}>12-hour</button>
|
|
<button class="option-btn" class:active={timeFormat === '24h'} onclick={() => { timeFormat = '24h'; saveGeneralSettings(); }}>24-hour</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isMobile}
|
|
<div class="settings-section">
|
|
<h3>Vault</h3>
|
|
<p class="setting-desc" style="margin-bottom: 12px; color: var(--text-tertiary); font-size: 13px;">Current: <strong style="color: var(--text-primary);">{$appConfig?.active_vault?.split('/').pop() ?? 'Unknown'}</strong></p>
|
|
<button class="import-btn" onclick={() => { $showSettings = false; $vaultReady = false; }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
|
</svg>
|
|
Switch Vault
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !isMobile}
|
|
<div class="settings-section">
|
|
<h3>Performance</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">GPU Acceleration</span>
|
|
<span class="setting-desc">Use hardware acceleration for rendering (requires restart)</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={gpuAcceleration} onclick={() => { gpuAcceleration = !gpuAcceleration; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>System</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Start at system startup</span>
|
|
<span class="setting-desc">Launch HelixNotes when your computer starts</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={autostart} onclick={() => { autostart = !autostart; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Show in system tray</span>
|
|
<span class="setting-desc">Show an icon in the notification area (requires restart)</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
{#if showTrayIcon}
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Close to tray</span>
|
|
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if activeTab === 'editor'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Title</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Hide title in note body</span>
|
|
<span class="setting-desc">Hide the first heading when it matches the note title</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={hideTitleInBody} onclick={() => { hideTitleInBody = !hideTitleInBody; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
{#if !isMobile}
|
|
<label class="setting-toggle" style="margin-top: 12px;">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Show line numbers</span>
|
|
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
{/if}
|
|
<label class="setting-toggle" style="margin-top: 12px;">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Open notes in View Mode</span>
|
|
<span class="setting-desc">Notes open as read-only by default. Click the eye icon to switch to editing.</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={defaultViewMode} onclick={() => { defaultViewMode = !defaultViewMode; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Wiki Links & Graph</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Enable wiki links</span>
|
|
<span class="setting-desc">Link notes with [[Note Title]] syntax and visualize connections in a graph view</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={enableWikiLinks} onclick={() => { enableWikiLinks = !enableWikiLinks; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
|
|
{#if !isMobile}
|
|
<div class="settings-section">
|
|
<h3>PDF Preview</h3>
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Inline PDF preview</span>
|
|
<span class="setting-desc">Render embedded PDF files as inline previews inside notes</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={pdfPreview} onclick={() => { pdfPreview = !pdfPreview; saveGeneralSettings(); }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
</div>
|
|
|
|
{#if pdfPreview}
|
|
<div class="settings-section">
|
|
<h3>PDF Height</h3>
|
|
<div class="font-size-options">
|
|
{#each pdfHeightPresets as preset}
|
|
<button
|
|
class="font-size-btn"
|
|
class:active={pdfHeight === preset.value}
|
|
onclick={() => { pdfHeight = preset.value; saveGeneralSettings(); }}
|
|
>
|
|
<span class="font-size-label">{preset.label}</span>
|
|
<span class="font-size-value">{preset.value}px</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<span class="setting-hint">Default height for PDF previews in notes</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{:else if activeTab === 'styling'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Theme</h3>
|
|
<div class="theme-toggle">
|
|
<button class="theme-btn" class:active={$theme === 'light'} onclick={() => { $theme = 'light'; setTheme('light'); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="5" />
|
|
<line x1="12" y1="1" x2="12" y2="3" />
|
|
<line x1="12" y1="21" x2="12" y2="23" />
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
<line x1="1" y1="12" x2="3" y2="12" />
|
|
<line x1="21" y1="12" x2="23" y2="12" />
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
</svg>
|
|
Light
|
|
</button>
|
|
<button class="theme-btn" class:active={$theme === 'dark'} onclick={() => { $theme = 'dark'; setTheme('dark'); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
|
</svg>
|
|
Dark
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Accent Color</h3>
|
|
<div class="accent-grid">
|
|
{#each accentPresets as preset}
|
|
<button
|
|
class="accent-swatch"
|
|
class:active={activeAccent === preset.name}
|
|
style="--swatch-color: {$theme === 'dark' ? preset.dark : preset.light}"
|
|
onclick={() => selectAccent(preset)}
|
|
title={preset.name}
|
|
>
|
|
<span class="swatch-circle"></span>
|
|
<span class="swatch-label">{preset.name}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Font Size</h3>
|
|
<div class="font-size-options">
|
|
{#each fontSizePresets as preset}
|
|
<button
|
|
class="font-size-btn"
|
|
class:active={activeFontSize === preset.size}
|
|
onclick={() => selectFontSize(preset.size)}
|
|
>
|
|
<span class="font-size-label">{preset.label}</span>
|
|
<span class="font-size-value">{preset.size}px</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Line Height</h3>
|
|
<div class="font-size-options">
|
|
{#each lineHeightPresets as preset}
|
|
<button
|
|
class="font-size-btn"
|
|
class:active={activeLineHeight === preset.value}
|
|
onclick={() => selectLineHeight(preset.value)}
|
|
>
|
|
<span class="font-size-label">{preset.label}</span>
|
|
<span class="font-size-value">{preset.value}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Font</h3>
|
|
<div class="font-family-options">
|
|
{#each fontFamilyPresets as preset}
|
|
<button
|
|
class="font-family-btn"
|
|
class:active={activeFontFamily === preset.value}
|
|
onclick={() => selectFontFamily(preset)}
|
|
>
|
|
<span class="font-family-preview" style="font-family: {preset.stack}">Aa</span>
|
|
<span class="font-family-name">{preset.name}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if activeTab === 'backup'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Automatic Backup</h3>
|
|
|
|
<label class="setting-toggle">
|
|
<span>Enable automatic backup</span>
|
|
<button class="toggle-switch" class:on={$appConfig?.backup_enabled} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_enabled: !$appConfig.backup_enabled }; saveBackupSettings(); } }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
|
|
<div class="backup-setting-group">
|
|
<span class="backup-setting-label">Backup frequency</span>
|
|
<div class="setting-options">
|
|
{#each [{ value: '1h', label: '1 hour' }, { value: '6h', label: '6 hours' }, { value: '12h', label: '12 hours' }, { value: '24h', label: '24 hours' }] as opt}
|
|
<button class="option-btn" class:active={($appConfig?.backup_frequency ?? '24h') === opt.value} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_frequency: opt.value }; saveBackupSettings(); } }}>{opt.label}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="backup-setting-group">
|
|
<span class="backup-setting-label">Maximum backups</span>
|
|
<div class="setting-options">
|
|
{#each [5, 10, 20, 50] as count}
|
|
<button class="option-btn" class:active={($appConfig?.backup_max_count ?? 10) === count} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_max_count: count }; saveBackupSettings(); } }}>{count}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<label class="setting-toggle">
|
|
<span class="setting-label">
|
|
<span class="setting-name">Include attachments</span>
|
|
<span class="setting-desc">Include images and files in backups (increases size significantly)</span>
|
|
</span>
|
|
<button class="toggle-switch" class:on={$appConfig?.backup_include_attachments} onclick={() => { if ($appConfig) { $appConfig = { ...$appConfig, backup_include_attachments: !$appConfig.backup_include_attachments }; saveBackupSettings(); } }}>
|
|
<span class="toggle-knob"></span>
|
|
</button>
|
|
</label>
|
|
|
|
<div class="backup-actions">
|
|
{#if !isMobile}
|
|
<button class="backup-link-btn" onclick={handleSelectBackupFolder}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
|
</svg>
|
|
{$appConfig?.backup_location ? 'Change backup folder' : 'Select backup folder'}
|
|
</button>
|
|
{#if $appConfig?.backup_location}
|
|
<p class="backup-path">{$appConfig.backup_location}</p>
|
|
{/if}
|
|
{/if}
|
|
<button class="backup-link-btn" onclick={handleBackupNow} disabled={backupLoading}>
|
|
{#if backupLoading}
|
|
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
|
Backing up...
|
|
{:else}
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z" />
|
|
<polyline points="17 21 17 13 7 13 7 21" />
|
|
<polyline points="7 3 7 8 15 8" />
|
|
</svg>
|
|
Backup now
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<p class="backup-last-time">
|
|
{#if $appConfig?.last_backup_time}
|
|
Last backup: {formatBackupDate($appConfig.last_backup_time)}
|
|
{:else}
|
|
No backups yet
|
|
{/if}
|
|
</p>
|
|
|
|
{#if backupMessage}
|
|
<div class="import-result {backupMessage.type}">
|
|
{#if backupMessage.type === 'success'}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
|
{/if}
|
|
<span>{backupMessage.text}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Restore Backup</h3>
|
|
{#if backups.length === 0}
|
|
<p class="backup-empty">No backups found</p>
|
|
{:else}
|
|
<div class="backup-list">
|
|
{#each backups as entry}
|
|
<div class="backup-item">
|
|
<div class="backup-info">
|
|
<span class="backup-date">{formatBackupDate(entry.created)}</span>
|
|
<span class="backup-size">{formatBackupSize(entry.size)}</span>
|
|
</div>
|
|
<div class="backup-item-actions">
|
|
<button class="backup-action-btn" title="Restore" onclick={() => restoreConfirm = entry}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="1 4 1 10 7 10" />
|
|
<path d="M3.51 15a9 9 0 102.13-9.36L1 10" />
|
|
</svg>
|
|
</button>
|
|
<button class="backup-action-btn danger" title="Delete" onclick={() => handleDeleteBackup(entry)}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if restoreConfirm}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="restore-confirm-overlay" onclick={() => restoreConfirm = null}>
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="restore-confirm" onclick={(e) => e.stopPropagation()}>
|
|
<h4>Restore Backup?</h4>
|
|
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
|
<div class="restore-confirm-actions">
|
|
<button class="restore-cancel" onclick={() => restoreConfirm = null}>Cancel</button>
|
|
<button class="restore-confirm-btn" onclick={() => handleRestore(restoreConfirm!)}>Restore</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{:else if activeTab === 'import'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Import from Obsidian</h3>
|
|
<p class="import-desc">Convert Obsidian wiki-links (<code>![[image.png]]</code>, <code>[[note]]</code>) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.</p>
|
|
<div class="import-warn">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
|
<span>This modifies files in place. Make a backup before running!</span>
|
|
</div>
|
|
<button class="import-btn" onclick={runObsidianImport} disabled={importLoading}>
|
|
{#if importLoading}
|
|
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
|
Importing...
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
<polyline points="7 10 12 15 17 10" />
|
|
<line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
Import Obsidian Vault
|
|
{/if}
|
|
</button>
|
|
|
|
{#if importResult}
|
|
<div class="import-result success">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
|
<span>Converted {importResult.links_converted} links across {importResult.files_converted} files</span>
|
|
</div>
|
|
{/if}
|
|
{#if importError}
|
|
<div class="import-result error">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
|
<span>{importError}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{:else if activeTab === 'ai'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Provider</h3>
|
|
<div class="setting-options">
|
|
<button class="option-btn" class:active={!aiProvider} onclick={() => { if (!aiProvider) return; aiProvider = null; aiTestMessage = null; saveAiSettings(); }}>Disabled</button>
|
|
<button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button>
|
|
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
|
|
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if aiProvider}
|
|
{#if aiProvider === 'ollama'}
|
|
<p class="setting-hint" style="color: var(--text-success, #4ade80); margin-top: -4px; margin-bottom: 12px;">Your data stays on your device. No text is sent to any external server.</p>
|
|
{:else}
|
|
<p class="setting-hint" style="color: var(--text-warning, #f59e0b); margin-top: -4px; margin-bottom: 12px;">Your selected text and note content will be sent to {aiProvider === 'openai' ? 'OpenAI' : 'Anthropic'} servers for processing.</p>
|
|
{/if}
|
|
|
|
{#if aiProvider === 'ollama'}
|
|
<div class="settings-section">
|
|
<h3>Server URL</h3>
|
|
<input
|
|
type="text"
|
|
class="ai-key-input"
|
|
placeholder="http://localhost:11434"
|
|
value={_ollamaBaseUrl}
|
|
oninput={(e) => { _ollamaBaseUrl = (e.target as HTMLInputElement).value; }}
|
|
onblur={saveAiSettings}
|
|
/>
|
|
<p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p>
|
|
</div>
|
|
{:else}
|
|
<div class="settings-section">
|
|
<h3>API Key</h3>
|
|
<div class="ai-key-row">
|
|
<input
|
|
type={aiShowKey ? 'text' : 'password'}
|
|
class="ai-key-input"
|
|
placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
|
|
value={aiApiKey}
|
|
oninput={(e) => { const v = (e.target as HTMLInputElement).value; if (aiProvider === 'openai') _openaiKey = v; else _anthropicKey = v; }}
|
|
onblur={saveAiSettings}
|
|
/>
|
|
<button class="ai-key-toggle" onclick={() => aiShowKey = !aiShowKey} title={aiShowKey ? 'Hide' : 'Show'}>
|
|
{#if aiShowKey}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{#if aiProvider === 'openai'}
|
|
<p class="setting-hint">Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="ai-link">platform.openai.com</a></p>
|
|
{:else}
|
|
<p class="setting-hint">Get your API key from <a href="https://console.anthropic.com/settings/keys" target="_blank" class="ai-link">console.anthropic.com</a></p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="settings-section">
|
|
<h3>Model</h3>
|
|
{#if aiProvider === 'ollama'}
|
|
<input
|
|
type="text"
|
|
class="ai-key-input"
|
|
placeholder="gemma3:4b"
|
|
value={aiModel}
|
|
oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }}
|
|
onblur={saveAiSettings}
|
|
/>
|
|
<p class="setting-hint">Enter the model name as shown by <code>ollama list</code></p>
|
|
{:else}
|
|
<div class="ai-model-options">
|
|
{#each aiModels as m}
|
|
<button
|
|
class="ai-model-btn"
|
|
class:active={aiModel === m.value}
|
|
onclick={() => { aiModel = m.value; saveAiSettings(); }}
|
|
>
|
|
<span class="ai-model-name">{m.label}</span>
|
|
<span class="ai-model-desc">{m.desc}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Writing Style</h3>
|
|
<textarea
|
|
class="ai-style-input"
|
|
placeholder="e.g. Professional and concise. Use active voice. Avoid jargon."
|
|
value={aiWritingStyle}
|
|
oninput={(e) => { aiWritingStyle = (e.target as HTMLTextAreaElement).value; }}
|
|
onblur={saveAiSettings}
|
|
rows="3"
|
|
></textarea>
|
|
<p class="setting-hint">Describe your preferred tone, style, or personality. This will be applied to all AI actions.</p>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Connection</h3>
|
|
<button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || (aiProvider !== 'ollama' && !aiApiKey)}>
|
|
{#if aiTestLoading}
|
|
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
|
Testing...
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
|
Test Connection
|
|
{/if}
|
|
</button>
|
|
{#if aiTestMessage}
|
|
<div class="import-result {aiTestMessage.type}">
|
|
{#if aiTestMessage.type === 'success'}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
|
{/if}
|
|
<span>{aiTestMessage.text}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Usage</h3>
|
|
{#if isMobile}
|
|
<p class="import-desc">Use the AI button in the toolbar or header to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
|
|
{:else}
|
|
<p class="import-desc">Select text in the editor and right-click to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{:else if activeTab === 'updates'}
|
|
<div class="tab-content">
|
|
<div class="settings-section">
|
|
<h3>Current Version</h3>
|
|
<p class="update-version">HelixNotes <strong>v{appVersion}</strong></p>
|
|
</div>
|
|
|
|
<div class="settings-section">
|
|
<h3>Check for Updates</h3>
|
|
<button class="import-btn" onclick={handleCheckUpdate} disabled={updateChecking || updateDownloading}>
|
|
{#if updateChecking}
|
|
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
|
Checking...
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/>
|
|
</svg>
|
|
Check for Updates
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if updateAvailable}
|
|
<div class="settings-section">
|
|
<h3>Update Available</h3>
|
|
<div class="update-info">
|
|
<p class="update-new-version">Version <strong>{updateAvailable.version}</strong></p>
|
|
{#if updateAvailable.date}
|
|
<p class="update-date">{new Date(updateAvailable.date).toLocaleDateString()}</p>
|
|
{/if}
|
|
{#if updateAvailable.body}
|
|
<div class="update-notes">{updateAvailable.body}</div>
|
|
{/if}
|
|
</div>
|
|
{#if $installType === 'appimage' || $installType === 'windows' || $installType === 'macos'}
|
|
<button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={updateDownloading}>
|
|
{#if updateDownloading}
|
|
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
|
|
{updateProgress > 0 ? `Downloading ${updateProgress}%` : 'Downloading...'}
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
Download & Install
|
|
{/if}
|
|
</button>
|
|
{#if updateDownloading && updateProgress > 0}
|
|
<div class="update-progress-bar">
|
|
<div class="update-progress-fill" style="width: {updateProgress}%"></div>
|
|
</div>
|
|
{/if}
|
|
{:else if $installType === 'deb'}
|
|
<div class="update-apt-info">
|
|
<p>Update via your package manager:</p>
|
|
<code>sudo apt update && sudo apt upgrade helix-notes</code>
|
|
</div>
|
|
{:else if $installType === 'aur'}
|
|
<div class="update-apt-info">
|
|
<p>Update via your AUR helper:</p>
|
|
<code>yay -Syu helixnotes</code>
|
|
</div>
|
|
{:else if $installType === 'android'}
|
|
<button class="update-install-btn" onclick={() => { openUrl('https://helixnotes.com/#download').catch(() => {}); }}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
Download from Website
|
|
</button>
|
|
{:else}
|
|
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>
|
|
Download from Codeberg
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if updateMessage}
|
|
<div class="import-result {updateMessage.type}">
|
|
{#if updateMessage.type === 'success'}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
|
{:else if updateMessage.type === 'error'}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
|
{:else}
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
|
|
{/if}
|
|
<span>{updateMessage.text}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.settings-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.35);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.settings-panel {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-lg);
|
|
width: 620px;
|
|
height: 80vh;
|
|
max-height: 700px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.settings-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 20px 24px 16px;
|
|
border-bottom: 1px solid var(--border-light);
|
|
}
|
|
|
|
.settings-header h2 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.close-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.settings-content {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.settings-tabs {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding: 16px 8px;
|
|
border-right: 1px solid var(--border-light);
|
|
min-width: 150px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.tab-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 14px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
background: none;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
text-align: left;
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.settings-body {
|
|
flex: 1;
|
|
padding: 20px 24px 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.tab-content {
|
|
animation: fadeIn 0.15s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.settings-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.settings-section:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.settings-section h3 {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.setting-hint {
|
|
font-size: 13px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.setting-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 10px 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.setting-toggle + .setting-toggle {
|
|
border-top: 1px solid var(--border-light);
|
|
}
|
|
|
|
.setting-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.setting-name {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.setting-desc {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 38px;
|
|
height: 22px;
|
|
border-radius: 11px;
|
|
border: none;
|
|
background: var(--border-color);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background 0.2s;
|
|
padding: 0;
|
|
}
|
|
|
|
.toggle-switch.on {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.toggle-knob {
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
transition: transform 0.2s;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.toggle-switch.on .toggle-knob {
|
|
transform: translateX(16px);
|
|
}
|
|
|
|
.setting-options {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.option-btn {
|
|
flex: 1;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
text-align: center;
|
|
}
|
|
|
|
.option-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.option-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.theme-toggle {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.theme-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 10px 16px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.theme-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.theme-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.accent-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.accent-swatch {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 12px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.accent-swatch:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--swatch-color);
|
|
}
|
|
|
|
.accent-swatch.active {
|
|
border-color: var(--swatch-color);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.swatch-circle {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 50%;
|
|
background: var(--swatch-color);
|
|
}
|
|
|
|
.swatch-label {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.accent-swatch.active .swatch-label {
|
|
color: var(--text-primary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.font-size-options {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.font-size-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 10px 4px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.font-size-btn:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--text-tertiary);
|
|
}
|
|
|
|
.font-size-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.font-size-label {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.font-size-btn.active .font-size-label {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.font-size-value {
|
|
font-size: 10px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.font-family-options {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.font-family-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 12px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.font-family-btn:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--text-tertiary);
|
|
}
|
|
|
|
.font-family-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.font-family-preview {
|
|
font-size: 20px;
|
|
color: var(--text-primary);
|
|
line-height: 1;
|
|
}
|
|
|
|
.font-family-btn.active .font-family-preview {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.font-family-name {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.font-family-btn.active .font-family-name {
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Import tab */
|
|
.import-desc {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.import-desc code {
|
|
background: var(--bg-secondary);
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.import-warn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--danger);
|
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.import-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 18px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.import-btn:hover:not(:disabled) {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.import-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.spinner-icon {
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.import-result {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
padding: 10px 14px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.import-result.success {
|
|
background: color-mix(in srgb, #059669 10%, transparent);
|
|
color: #059669;
|
|
}
|
|
|
|
.import-result.error {
|
|
background: color-mix(in srgb, #e11d48 10%, transparent);
|
|
color: #e11d48;
|
|
}
|
|
|
|
/* Backup tab */
|
|
.backup-setting-group {
|
|
margin: 12px 0;
|
|
}
|
|
|
|
.backup-setting-label {
|
|
display: block;
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.backup-actions {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.backup-link-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.backup-link-btn:hover:not(:disabled) {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.backup-link-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.backup-path {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
padding-left: 20px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.backup-last-time {
|
|
margin-top: 12px;
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.backup-empty {
|
|
font-size: 13px;
|
|
color: var(--text-tertiary);
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.backup-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.backup-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 10px;
|
|
border-radius: 6px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.backup-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.backup-date {
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.backup-size {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.backup-item-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.backup-action-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.backup-action-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.backup-action-btn.danger:hover {
|
|
color: var(--danger);
|
|
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
|
}
|
|
|
|
.restore-confirm-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 3000;
|
|
}
|
|
|
|
.restore-confirm {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
box-shadow: var(--shadow-lg);
|
|
padding: 24px;
|
|
max-width: 380px;
|
|
}
|
|
|
|
.restore-confirm h4 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.restore-confirm p {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.restore-confirm-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.restore-cancel {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 6px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.restore-cancel:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.restore-confirm-btn {
|
|
background: var(--danger);
|
|
border: none;
|
|
color: white;
|
|
padding: 6px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.restore-confirm-btn:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* AI tab */
|
|
.ai-key-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.ai-key-input {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-family: monospace;
|
|
outline: none;
|
|
}
|
|
|
|
.ai-key-input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.ai-key-input::placeholder {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.ai-style-input {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-family: inherit;
|
|
outline: none;
|
|
resize: vertical;
|
|
min-height: 60px;
|
|
}
|
|
|
|
.ai-style-input:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.ai-style-input::placeholder {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.ai-key-toggle {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
cursor: pointer;
|
|
color: var(--text-tertiary);
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.ai-key-toggle:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.ai-link {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.ai-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.ai-model-options {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.ai-model-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 12px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.ai-model-btn:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--text-tertiary);
|
|
}
|
|
|
|
.ai-model-btn.active {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.ai-model-name {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.ai-model-btn.active .ai-model-name {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.ai-model-desc {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
/* Updates tab */
|
|
.update-version {
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.update-info {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.update-new-version {
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.update-date {
|
|
font-size: 12px;
|
|
color: var(--text-tertiary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.update-notes {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.update-install-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 18px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: var(--accent);
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.update-install-btn:hover:not(:disabled) {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
.update-install-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.update-apt-info {
|
|
margin-top: 8px;
|
|
}
|
|
.update-apt-info p {
|
|
margin: 0 0 6px 0;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.update-apt-info code {
|
|
display: block;
|
|
padding: 8px 12px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
user-select: all;
|
|
}
|
|
|
|
.update-progress-bar {
|
|
margin-top: 10px;
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: var(--bg-secondary);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.update-progress-fill {
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
background: var(--accent);
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.import-result.info {
|
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
/* ═══ Mobile ═══ */
|
|
|
|
.settings-overlay.mobile {
|
|
align-items: stretch;
|
|
justify-content: stretch;
|
|
}
|
|
|
|
.settings-panel.mobile {
|
|
width: 100%;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
border-radius: 0;
|
|
border: none;
|
|
}
|
|
|
|
.settings-panel.mobile .settings-content {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.settings-panel.mobile .settings-tabs {
|
|
flex-direction: row;
|
|
min-width: 0;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border-light);
|
|
padding: 8px 8px 0;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
gap: 0;
|
|
}
|
|
|
|
.settings-panel.mobile .settings-tabs::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.settings-panel.mobile .tab-btn {
|
|
flex-shrink: 0;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 8px 14px 10px;
|
|
font-size: 11px;
|
|
border-radius: 8px 8px 0 0;
|
|
}
|
|
|
|
.settings-panel.mobile .tab-btn.active {
|
|
border-bottom: 2px solid var(--accent);
|
|
border-radius: 8px 8px 0 0;
|
|
}
|
|
|
|
.settings-panel.mobile .settings-header {
|
|
padding-top: calc(env(safe-area-inset-top, 12px) + 12px);
|
|
}
|
|
|
|
.settings-panel.mobile .settings-body {
|
|
padding: 16px;
|
|
}
|
|
|
|
.settings-panel.mobile .setting-name {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.settings-panel.mobile .setting-desc {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.settings-panel.mobile .toggle-switch {
|
|
width: 44px;
|
|
height: 26px;
|
|
}
|
|
|
|
.settings-panel.mobile .toggle-knob {
|
|
width: 22px;
|
|
height: 22px;
|
|
}
|
|
|
|
.settings-panel.mobile .toggle-switch.on .toggle-knob {
|
|
transform: translateX(18px);
|
|
}
|
|
|
|
.settings-panel.mobile .option-btn {
|
|
padding: 12px 10px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.settings-panel.mobile .theme-btn {
|
|
padding: 12px 10px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.settings-panel.mobile .accent-grid {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 10px;
|
|
}
|
|
|
|
.settings-panel.mobile .accent-swatch {
|
|
padding: 14px 8px;
|
|
}
|
|
|
|
.settings-panel.mobile .font-size-options {
|
|
gap: 8px;
|
|
}
|
|
|
|
.settings-panel.mobile .font-size-btn {
|
|
padding: 12px 6px;
|
|
}
|
|
|
|
</style>
|