You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1107 lines
39 KiB

<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { settingsStore } from '$lib/services/settings-store.js';
import { userStore } from '$lib/stores/user-store.js';
import { fetchUserEmail, fetchUserName, fetchUserProfile, extractProfileData, getUserName, getUserEmail } from '$lib/utils/user-profile.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
// Get tab from URL params
const validTabs = ['general', 'git-setup', 'connections'] as const;
type TabType = typeof validTabs[number];
// Get initial tab from URL, default to 'general'
const getTabFromUrl = () => {
const tabParam = ($page.params as { tab?: string }).tab;
if (tabParam && validTabs.includes(tabParam as TabType)) {
return tabParam as TabType;
}
return 'general';
};
let activeTab = $state<TabType>(getTabFromUrl());
let autoSave = $state(false);
let userName = $state('');
let userEmail = $state('');
let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark');
let defaultBranch = $state('master');
let loading = $state(false);
let saving = $state(false);
let loadingPresets = $state(false);
let settingsLoaded = $state(false);
let configStatus = $state<any>(null);
let loadingConfig = $state(false);
let expandedSections = $state<Set<string>>(new Set(['github', 'git']));
// Preset values that will be used if user doesn't override
let presetUserName = $state('');
let presetUserEmail = $state('');
// Update URL when tab changes
function setActiveTab(tab: TabType) {
activeTab = tab;
goto(`/settings/${tab}`, { replaceState: true, noScroll: true });
}
async function loadSettings() {
if (settingsLoaded) return; // Don't reload if already loaded
loading = true;
try {
console.log('[SettingsPage] Loading settings from store...');
const settings = await settingsStore.getSettings();
console.log('[SettingsPage] Settings loaded:', settings);
autoSave = settings.autoSave;
userName = settings.userName;
userEmail = settings.userEmail;
theme = settings.theme;
defaultBranch = settings.defaultBranch;
settingsLoaded = true;
} catch (err) {
console.error('[SettingsPage] Failed to load settings:', err);
} finally {
loading = false;
}
}
async function loadPresets() {
// Get user's pubkey from store
const currentUser = $userStore;
if (!currentUser.userPubkeyHex) {
// User not logged in, no presets available
presetUserName = '';
presetUserEmail = '';
return;
}
loadingPresets = true;
try {
// Fetch profile from kind 0 event (cache or relays)
const profileEvent = await fetchUserProfile(currentUser.userPubkeyHex, DEFAULT_NOSTR_RELAYS);
const profile = extractProfileData(profileEvent);
// Get preset values using the same fallback logic as the commit functions
presetUserName = getUserName(profile, currentUser.userPubkeyHex, currentUser.userPubkey || undefined);
presetUserEmail = getUserEmail(profile, currentUser.userPubkeyHex, currentUser.userPubkey || undefined);
} catch (err) {
console.warn('Failed to load presets from profile:', err);
// Fallback to shortened npub values
if (currentUser.userPubkey) {
presetUserName = currentUser.userPubkey.substring(0, 20);
presetUserEmail = `${currentUser.userPubkey.substring(0, 20)}@gitrepublic.web`;
} else if (currentUser.userPubkeyHex) {
const { nip19 } = await import('nostr-tools');
const npub = nip19.npubEncode(currentUser.userPubkeyHex);
presetUserName = npub.substring(0, 20);
presetUserEmail = `${npub.substring(0, 20)}@gitrepublic.web`;
} else {
presetUserName = '';
presetUserEmail = '';
}
} finally {
loadingPresets = false;
}
}
async function saveSettings() {
saving = true;
try {
// Save empty string if user wants to use presets, otherwise save the custom value
await settingsStore.updateSettings({
autoSave,
userName: userName.trim() || '', // Empty string means use preset
userEmail: userEmail.trim() || '', // Empty string means use preset
theme,
defaultBranch: defaultBranch.trim() || 'master'
});
// Apply theme immediately
applyTheme(theme);
// Sync to localStorage for app.html flash prevention
if (typeof window !== 'undefined') {
localStorage.setItem('theme', theme);
// Dispatch event to notify layout of theme change
window.dispatchEvent(new CustomEvent('themeChanged', {
detail: { theme }
}));
}
// Show success message and optionally navigate back
alert('Settings saved successfully!');
} catch (err) {
console.error('Failed to save settings:', err);
alert('Failed to save settings. Please try again.');
} finally {
saving = false;
}
}
function applyTheme(newTheme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black') {
// Remove all theme attributes first
document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-theme-light');
document.documentElement.removeAttribute('data-theme-black');
// Apply the selected theme
if (newTheme === 'gitrepublic-light') {
document.documentElement.setAttribute('data-theme', 'light');
} else if (newTheme === 'gitrepublic-dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (newTheme === 'gitrepublic-black') {
document.documentElement.setAttribute('data-theme', 'black');
}
}
function handleThemeChange(newTheme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black') {
theme = newTheme;
// Preview theme change immediately (don't save yet)
applyTheme(newTheme);
}
function goBack() {
// Use browser history to go back, fallback to dashboard if no history
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back();
} else {
goto('/dashboard');
}
}
async function loadConfigStatus() {
loadingConfig = true;
try {
const response = await fetch('/api/config');
if (response.ok) {
configStatus = await response.json();
}
} catch (err) {
console.error('Failed to load config status:', err);
} finally {
loadingConfig = false;
}
}
// Load settings and presets on mount
onMount(async () => {
// Redirect to /settings/general if no tab is specified
const tabParam = ($page.params as { tab?: string }).tab;
if (!tabParam) {
goto('/settings/general', { replaceState: true });
}
await loadSettings();
await loadPresets();
await loadConfigStatus();
});
// Sync activeTab with URL param when it changes
$effect(() => {
const tab = getTabFromUrl();
if (tab !== activeTab) {
activeTab = tab;
}
});
</script>
<div class="settings-page">
<div class="settings-header">
<h1>Settings</h1>
<button class="back-button" onclick={goBack} aria-label="Back">
<span>← Back</span>
</button>
</div>
{#if loading && !settingsLoaded}
<div class="loading">Loading settings...</div>
{:else}
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'general'}
onclick={() => setActiveTab('general')}
>
General
</button>
<button
class="tab-button"
class:active={activeTab === 'git-setup'}
onclick={() => setActiveTab('git-setup')}
>
Git Setup
</button>
<button
class="tab-button"
class:active={activeTab === 'connections'}
onclick={() => setActiveTab('connections')}
>
Connections
</button>
</div>
<div class="settings-content">
<!-- General Tab -->
{#if activeTab === 'general'}
<div class="setting-group">
<div class="setting-label">
<span class="label-text">Theme</span>
</div>
<div class="theme-options">
<button
class="theme-option"
class:active={theme === 'gitrepublic-light'}
onclick={() => handleThemeChange('gitrepublic-light')}
>
<img src="/icons/sun.svg" alt="Light theme" class="theme-icon" />
<span>Light</span>
</button>
<button
class="theme-option"
class:active={theme === 'gitrepublic-dark'}
onclick={() => handleThemeChange('gitrepublic-dark')}
>
<img src="/icons/palette.svg" alt="Purple theme" class="theme-icon" />
<span>Purple</span>
</button>
<button
class="theme-option"
class:active={theme === 'gitrepublic-black'}
onclick={() => handleThemeChange('gitrepublic-black')}
>
<img src="/icons/moon.svg" alt="Black theme" class="theme-icon" />
<span>Black</span>
</button>
</div>
</div>
{/if}
<!-- Git Setup Tab -->
{#if activeTab === 'git-setup'}
<!-- Auto-save Toggle -->
<div class="setting-group">
<label class="setting-label">
<span class="label-text">Auto-save</span>
<div class="toggle-container">
<input
type="checkbox"
bind:checked={autoSave}
class="toggle-input"
id="auto-save-toggle"
/>
<label for="auto-save-toggle" class="toggle-label">
<span class="toggle-slider"></span>
</label>
</div>
</label>
<p class="setting-description">
When enabled, changes are automatically committed every 10 minutes if there are unsaved changes.
</p>
</div>
<!-- User Name -->
<div class="setting-group">
<label class="setting-label" for="user-name">
<span class="label-text">Git User Name</span>
</label>
<input
type="text"
id="user-name"
bind:value={userName}
placeholder={presetUserName || 'Enter your git user.name'}
class="setting-input"
/>
{#if presetUserName}
<p class="setting-hint">
{#if userName.trim()}
Custom value saved. Default would be: {presetUserName}
{:else}
Will use: <strong>{presetUserName}</strong> (from your Nostr profile: display_name → name → shortened npub)
{/if}
</p>
{/if}
<p class="setting-description">
Your name as it will appear in git commits. Leave empty to use the preset value from your Nostr profile.
</p>
</div>
<!-- User Email -->
<div class="setting-group">
<label class="setting-label" for="user-email">
<span class="label-text">Git User Email</span>
</label>
<input
type="email"
id="user-email"
bind:value={userEmail}
placeholder={presetUserEmail || 'Enter your git user.email'}
class="setting-input"
/>
{#if presetUserEmail}
<p class="setting-hint">
{#if userEmail.trim()}
Custom value saved. Default would be: {presetUserEmail}
{:else}
Will use: <strong>{presetUserEmail}</strong> (from your Nostr profile: NIP-05 → shortenednpub@gitrepublic.web)
{/if}
</p>
{/if}
<p class="setting-description">
Your email as it will appear in git commits. Leave empty to use the preset value from your Nostr profile.
</p>
</div>
<!-- Default Branch -->
<div class="setting-group">
<label class="setting-label" for="default-branch">
<span class="label-text">Default Branch Name</span>
</label>
<input
type="text"
id="default-branch"
bind:value={defaultBranch}
placeholder="master"
class="setting-input"
/>
<p class="setting-description">
Default branch name to use when creating new repositories. This will be used as the base branch when creating the first branch in a new repo.
</p>
</div>
{/if}
<!-- Connections Tab -->
{#if activeTab === 'connections'}
<!-- Server Configuration Status -->
<div class="setting-group">
<h3 class="setting-section-title">Server Configuration</h3>
<p class="setting-description">
Environment variables and server settings. Configure these in your environment or process manager.
</p>
{#if loadingConfig}
<p class="setting-description">Loading configuration status...</p>
{:else if configStatus}
<!-- GitHub Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('github')) {
expandedSections.delete('github');
} else {
expandedSections.add('github');
}
expandedSections = expandedSections; // Trigger reactivity
}}
>
<span class="section-title">GitHub Integration</span>
<span class="section-toggle">{expandedSections.has('github') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('github')}
<div class="config-status">
<div class="config-item">
<span class="config-label">GITHUB_TOKEN:</span>
<span class="config-value" class:configured={configStatus.github.tokenConfigured}>
{configStatus.github.tokenConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-docs">
<p><strong>Purpose:</strong> GitHub Personal Access Token for API authentication</p>
<p><strong>Why needed:</strong> Without a token, you're limited to 60 requests/hour per IP. With a token, you get 5,000 requests/hour.</p>
<p><strong>How to set:</strong> <code>export GITHUB_TOKEN=your_token_here</code></p>
<p><strong>How to create:</strong> GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) with <code>public_repo</code> scope</p>
</div>
</div>
{/if}
</div>
<!-- Git Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('git')) {
expandedSections.delete('git');
} else {
expandedSections.add('git');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Git Configuration</span>
<span class="section-toggle">{expandedSections.has('git') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('git')}
<div class="config-status">
<div class="config-item">
<span class="config-label">GIT_REPO_ROOT:</span>
<span class="config-value">{configStatus.git.repoRoot}</span>
</div>
<div class="config-item">
<span class="config-label">GIT_DOMAIN:</span>
<span class="config-value">{configStatus.git.domain}</span>
</div>
<div class="config-item">
<span class="config-label">DEFAULT_BRANCH:</span>
<span class="config-value">{configStatus.git.defaultBranch}</span>
</div>
<div class="config-item">
<span class="config-label">GIT_OPERATION_TIMEOUT_MS:</span>
<span class="config-value">{configStatus.git.operationTimeoutMs}ms ({Math.round(configStatus.git.operationTimeoutMs / 1000 / 60)} min)</span>
</div>
<div class="config-item">
<span class="config-label">GIT_CLONE_TIMEOUT_MS:</span>
<span class="config-value">{configStatus.git.cloneTimeoutMs}ms ({Math.round(configStatus.git.cloneTimeoutMs / 1000 / 60)} min)</span>
</div>
<div class="config-item">
<span class="config-label">ALLOW_FORCE_PUSH:</span>
<span class="config-value">{configStatus.git.allowForcePush ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-docs">
<p><strong>GIT_REPO_ROOT:</strong> Directory where repositories are stored (default: <code>/repos</code>)</p>
<p><strong>GIT_DOMAIN:</strong> Domain for git clone URLs (default: <code>localhost:6543</code>)</p>
<p><strong>DEFAULT_BRANCH:</strong> Default branch name for new repositories (default: <code>master</code>)</p>
<p><strong>GIT_OPERATION_TIMEOUT_MS:</strong> Timeout for git operations in milliseconds (default: 300000 = 5 minutes)</p>
<p><strong>GIT_CLONE_TIMEOUT_MS:</strong> Timeout for git clone operations (default: 300000 = 5 minutes)</p>
<p><strong>ALLOW_FORCE_PUSH:</strong> Allow force push operations (default: <code>false</code>)</p>
</div>
</div>
{/if}
</div>
<!-- Nostr Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('nostr')) {
expandedSections.delete('nostr');
} else {
expandedSections.add('nostr');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Nostr Configuration</span>
<span class="section-toggle">{expandedSections.has('nostr') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('nostr')}
<div class="config-status">
<div class="config-item">
<span class="config-label">NOSTR_RELAYS:</span>
<span class="config-value">{configStatus.nostr.relays.length} relay(s) configured</span>
</div>
<div class="config-item">
<span class="config-label">NOSTR_SEARCH_RELAYS:</span>
<span class="config-value">{configStatus.nostr.searchRelays.length > 0 ? configStatus.nostr.searchRelays.length + ' relay(s)' : 'Using defaults'}</span>
</div>
<div class="config-item">
<span class="config-label">NIP98_AUTH_WINDOW_SECONDS:</span>
<span class="config-value">{configStatus.nostr.nip98AuthWindowSeconds}s</span>
</div>
<div class="config-docs">
<p><strong>NOSTR_RELAYS:</strong> Comma-separated list of Nostr relays for publishing/fetching (default: <code>wss://theforest.nostr1.com,wss://nostr.land</code>)</p>
<p><strong>NOSTR_SEARCH_RELAYS:</strong> Comma-separated list of relays for searching (uses extended default list if not set)</p>
<p><strong>NIP98_AUTH_WINDOW_SECONDS:</strong> Authentication window for NIP-98 HTTP auth (default: 60 seconds)</p>
</div>
</div>
{/if}
</div>
<!-- Tor Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('tor')) {
expandedSections.delete('tor');
} else {
expandedSections.add('tor');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Tor Support</span>
<span class="section-toggle">{expandedSections.has('tor') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('tor')}
<div class="config-status">
<div class="config-item">
<span class="config-label">TOR_SOCKS_PROXY:</span>
<span class="config-value">{configStatus.tor.enabled ? configStatus.tor.socksProxy : 'Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">TOR_HOSTNAME_FILE:</span>
<span class="config-value">{configStatus.tor.hostnameFile || 'Not set'}</span>
</div>
<div class="config-item">
<span class="config-label">TOR_ONION_ADDRESS:</span>
<span class="config-value">{configStatus.tor.onionAddress || 'Not set'}</span>
</div>
<div class="config-docs">
<p><strong>TOR_SOCKS_PROXY:</strong> Tor SOCKS proxy address (format: <code>host:port</code>, default: <code>127.0.0.1:9050</code>, set to empty to disable)</p>
<p><strong>TOR_HOSTNAME_FILE:</strong> Path to file containing Tor hidden service hostname</p>
<p><strong>TOR_ONION_ADDRESS:</strong> Tor .onion address for the service</p>
</div>
</div>
{/if}
</div>
<!-- Security Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('security')) {
expandedSections.delete('security');
} else {
expandedSections.add('security');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Security Settings</span>
<span class="section-toggle">{expandedSections.has('security') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('security')}
<div class="config-status">
<div class="config-item">
<span class="config-label">ADMIN_PUBKEYS:</span>
<span class="config-value" class:configured={configStatus.security.adminPubkeysConfigured}>
{configStatus.security.adminPubkeysConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOGGING_ENABLED:</span>
<span class="config-value">{configStatus.security.auditLoggingEnabled ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOG_FILE:</span>
<span class="config-value">{configStatus.security.auditLogFile || 'Default location'}</span>
</div>
<div class="config-item">
<span class="config-label">AUDIT_LOG_RETENTION_DAYS:</span>
<span class="config-value">{configStatus.security.auditLogRetentionDays} days</span>
</div>
<div class="config-item">
<span class="config-label">RATE_LIMIT_ENABLED:</span>
<span class="config-value">{configStatus.security.rateLimitEnabled ? '✓ Enabled' : '✗ Disabled'}</span>
</div>
<div class="config-item">
<span class="config-label">RATE_LIMIT_WINDOW_MS:</span>
<span class="config-value">{configStatus.security.rateLimitWindowMs}ms ({Math.round(configStatus.security.rateLimitWindowMs / 1000)}s)</span>
</div>
<div class="config-docs">
<p><strong>ADMIN_PUBKEYS:</strong> Comma-separated list of admin pubkeys (hex format) with elevated privileges</p>
<p><strong>AUDIT_LOGGING_ENABLED:</strong> Enable audit logging (default: <code>true</code>, set to <code>false</code> to disable)</p>
<p><strong>AUDIT_LOG_FILE:</strong> Path to audit log file (uses default if not set)</p>
<p><strong>AUDIT_LOG_RETENTION_DAYS:</strong> Number of days to retain audit logs (default: 90)</p>
<p><strong>RATE_LIMIT_ENABLED:</strong> Enable rate limiting (default: <code>true</code>, set to <code>false</code> to disable)</p>
<p><strong>RATE_LIMIT_WINDOW_MS:</strong> Rate limit window in milliseconds (default: 60000 = 1 minute)</p>
</div>
</div>
{/if}
</div>
<!-- Resource Limits -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('resources')) {
expandedSections.delete('resources');
} else {
expandedSections.add('resources');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Resource Limits</span>
<span class="section-toggle">{expandedSections.has('resources') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('resources')}
<div class="config-status">
<div class="config-item">
<span class="config-label">MAX_REPOS_PER_USER:</span>
<span class="config-value">{configStatus.resources.maxReposPerUser} repositories</span>
</div>
<div class="config-item">
<span class="config-label">MAX_DISK_QUOTA_PER_USER:</span>
<span class="config-value">{Math.round(configStatus.resources.maxDiskQuotaPerUser / 1024 / 1024 / 1024)} GB ({configStatus.resources.maxDiskQuotaPerUser} bytes)</span>
</div>
<div class="config-docs">
<p><strong>MAX_REPOS_PER_USER:</strong> Maximum number of repositories per user (default: 100)</p>
<p><strong>MAX_DISK_QUOTA_PER_USER:</strong> Maximum disk quota per user in bytes (default: 10737418240 = 10 GB)</p>
</div>
</div>
{/if}
</div>
<!-- Messaging Configuration -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('messaging')) {
expandedSections.delete('messaging');
} else {
expandedSections.add('messaging');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Messaging Configuration</span>
<span class="section-toggle">{expandedSections.has('messaging') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('messaging')}
<div class="config-status">
<div class="config-item">
<span class="config-label">MESSAGING_PREFS_ENCRYPTION_KEY:</span>
<span class="config-value" class:configured={configStatus.messaging.encryptionKeyConfigured}>
{configStatus.messaging.encryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">MESSAGING_SALT_ENCRYPTION_KEY:</span>
<span class="config-value" class:configured={configStatus.messaging.saltEncryptionKeyConfigured}>
{configStatus.messaging.saltEncryptionKeyConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-item">
<span class="config-label">MESSAGING_LOOKUP_SECRET:</span>
<span class="config-value" class:configured={configStatus.messaging.lookupSecretConfigured}>
{configStatus.messaging.lookupSecretConfigured ? '✓ Configured' : '✗ Not configured'}
</span>
</div>
<div class="config-docs">
<p><strong>MESSAGING_PREFS_ENCRYPTION_KEY:</strong> Encryption key for messaging preferences</p>
<p><strong>MESSAGING_SALT_ENCRYPTION_KEY:</strong> Encryption key for salt values</p>
<p><strong>MESSAGING_LOOKUP_SECRET:</strong> Secret for message lookup operations</p>
</div>
</div>
{/if}
</div>
<!-- Enterprise Mode -->
<div class="config-section">
<button
class="config-section-header"
onclick={() => {
if (expandedSections.has('enterprise')) {
expandedSections.delete('enterprise');
} else {
expandedSections.add('enterprise');
}
expandedSections = expandedSections;
}}
>
<span class="section-title">Enterprise Mode</span>
<span class="section-toggle">{expandedSections.has('enterprise') ? '▼' : '▶'}</span>
</button>
{#if expandedSections.has('enterprise')}
<div class="config-status">
<div class="config-item">
<span class="config-label">ENTERPRISE_MODE:</span>
<span class="config-value">{configStatus.enterprise.enabled ? '✓ Enabled' : '✗ Disabled (Lightweight mode)'}</span>
</div>
<div class="config-docs">
<p><strong>ENTERPRISE_MODE:</strong> Enable enterprise mode for Kubernetes container-per-tenant architecture (default: <code>false</code>, set to <code>true</code> to enable)</p>
</div>
</div>
{/if}
</div>
{:else}
<p class="setting-description">Failed to load configuration status.</p>
{/if}
</div>
<div class="setting-group">
<ForwardingConfig
userPubkeyHex={$userStore.userPubkeyHex}
showTitle={true}
compact={false}
/>
</div>
{/if}
</div>
<div class="settings-actions">
<button onclick={saveSettings} class="save-button" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
{/if}
</div>
<style>
.settings-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
min-height: calc(100vh - 4rem);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.settings-header h1 {
margin: 0;
font-size: 2rem;
color: var(--text-primary);
}
.back-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-primary);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.back-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent);
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.tab-button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
.settings-content {
padding: 1.5rem 0;
}
.setting-group {
margin-bottom: 2rem;
}
.setting-group:last-child {
margin-bottom: 0;
}
.setting-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-primary);
}
.label-text {
flex: 1;
}
.toggle-container {
display: flex;
align-items: center;
}
.toggle-input {
display: none;
}
.toggle-label {
position: relative;
width: 44px;
height: 24px;
background: var(--bg-tertiary);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle-input:checked + .toggle-label {
background: var(--accent);
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle-input:checked + .toggle-label .toggle-slider {
transform: translateX(20px);
}
.setting-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 1rem;
margin-bottom: 0.5rem;
box-sizing: border-box;
}
.setting-input:focus {
outline: none;
border-color: var(--accent);
}
.setting-hint {
font-size: 0.875rem;
color: var(--text-secondary);
margin: -0.25rem 0 0.5rem 0;
}
.setting-description {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0.5rem 0 0 0;
}
.theme-options {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.theme-options {
flex-direction: column;
}
.theme-option {
width: 100%;
min-width: auto;
}
}
.theme-option {
flex: 1;
min-width: 120px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
border: 2px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.theme-option:hover {
border-color: var(--accent);
background: var(--bg-secondary);
}
.theme-option.active {
border-color: var(--accent);
background: var(--bg-tertiary);
font-weight: 600;
}
.theme-icon {
width: 24px;
height: 24px;
filter: brightness(0) saturate(100%) invert(1);
}
:global([data-theme="light"]) .theme-icon {
filter: brightness(0) saturate(100%);
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 2rem 0;
border-top: 1px solid var(--border-color);
margin-top: 2rem;
}
.save-button {
padding: 0.75rem 1.5rem;
border: 1px solid var(--accent);
border-radius: 0.375rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
background: var(--accent);
color: var(--accent-text, #ffffff);
}
.save-button:hover:not(:disabled) {
opacity: 0.9;
}
.save-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.setting-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.config-status {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-weight: 500;
color: var(--text-primary);
}
.config-value {
color: var(--text-secondary);
font-family: monospace;
font-size: 0.875rem;
}
.config-value.configured {
color: var(--success-color, #10b981);
font-weight: 600;
}
.config-section {
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.config-section-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: none;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
transition: background 0.2s ease;
}
.config-section-header:hover {
background: var(--bg-tertiary);
}
.section-title {
flex: 1;
text-align: left;
}
.section-toggle {
font-size: 0.875rem;
color: var(--text-secondary);
margin-left: 1rem;
}
.config-docs {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.config-docs p {
margin: 0.5rem 0;
}
.config-docs p:first-child {
margin-top: 0;
}
.config-docs p:last-child {
margin-bottom: 0;
}
.config-docs code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.config-docs strong {
color: var(--text-primary);
font-weight: 600;
}
</style>