Browse Source
add settings menu autosave OFF 10 min Nostr-Signature: 80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0main
9 changed files with 1155 additions and 85 deletions
@ -0,0 +1,77 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import SettingsModal from './SettingsModal.svelte'; |
||||||
|
|
||||||
|
let showSettings = $state(false); |
||||||
|
</script> |
||||||
|
|
||||||
|
<button |
||||||
|
class="settings-button" |
||||||
|
onclick={() => showSettings = true} |
||||||
|
title="Settings" |
||||||
|
aria-label="Settings" |
||||||
|
> |
||||||
|
<img src="/icons/settings.svg" alt="Settings" class="settings-icon" /> |
||||||
|
</button> |
||||||
|
|
||||||
|
<SettingsModal isOpen={showSettings} onClose={() => showSettings = false} /> |
||||||
|
|
||||||
|
<style> |
||||||
|
.settings-button { |
||||||
|
cursor: pointer; |
||||||
|
padding: 0.5rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--card-bg); |
||||||
|
color: var(--text-primary); |
||||||
|
transition: all 0.2s ease; |
||||||
|
font-size: 0.875rem; |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: 2.5rem; |
||||||
|
height: 2.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.settings-button:hover { |
||||||
|
background: var(--bg-secondary); |
||||||
|
border-color: var(--accent); |
||||||
|
color: var(--accent); |
||||||
|
} |
||||||
|
|
||||||
|
.settings-button:active { |
||||||
|
transform: scale(0.98); |
||||||
|
} |
||||||
|
|
||||||
|
.settings-icon { |
||||||
|
width: 16px; |
||||||
|
height: 16px; |
||||||
|
display: block; |
||||||
|
filter: brightness(0) saturate(100%) invert(1) !important; /* Default white for dark themes */ |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Light theme: black icon */ |
||||||
|
:global([data-theme="light"]) .settings-icon { |
||||||
|
filter: brightness(0) saturate(100%) !important; /* Black in light theme */ |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Dark themes: white icon */ |
||||||
|
:global([data-theme="dark"]) .settings-icon, |
||||||
|
:global([data-theme="black"]) .settings-icon { |
||||||
|
filter: brightness(0) saturate(100%) invert(1) !important; /* White in dark themes */ |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Hover: white for visibility */ |
||||||
|
.settings-button:hover .settings-icon { |
||||||
|
filter: brightness(0) saturate(100%) invert(1) !important; |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
|
||||||
|
/* Light theme hover: keep black */ |
||||||
|
:global([data-theme="light"]) .settings-button:hover .settings-icon { |
||||||
|
filter: brightness(0) saturate(100%) !important; |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,472 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { settingsStore } from '../services/settings-store.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
isOpen: boolean; |
||||||
|
onClose: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { isOpen, onClose }: Props = $props(); |
||||||
|
|
||||||
|
let autoSave = $state(false); |
||||||
|
let userName = $state(''); |
||||||
|
let userEmail = $state(''); |
||||||
|
let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark'); |
||||||
|
let loading = $state(true); |
||||||
|
let saving = $state(false); |
||||||
|
|
||||||
|
// Get default git user name and email (from git config if available) |
||||||
|
let defaultUserName = $state(''); |
||||||
|
let defaultUserEmail = $state(''); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await loadSettings(); |
||||||
|
await loadGitDefaults(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadSettings() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const settings = await settingsStore.getSettings(); |
||||||
|
autoSave = settings.autoSave; |
||||||
|
userName = settings.userName; |
||||||
|
userEmail = settings.userEmail; |
||||||
|
theme = settings.theme; |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to load settings:', err); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadGitDefaults() { |
||||||
|
// Try to get git config defaults from the server |
||||||
|
// This would require a new API endpoint, but for now we'll just use empty strings |
||||||
|
// The user can manually enter their git config values |
||||||
|
defaultUserName = ''; |
||||||
|
defaultUserEmail = ''; |
||||||
|
} |
||||||
|
|
||||||
|
async function saveSettings() { |
||||||
|
saving = true; |
||||||
|
try { |
||||||
|
await settingsStore.updateSettings({ |
||||||
|
autoSave, |
||||||
|
userName: userName.trim(), |
||||||
|
userEmail: userEmail.trim(), |
||||||
|
theme |
||||||
|
}); |
||||||
|
|
||||||
|
// Apply theme immediately |
||||||
|
applyTheme(theme); |
||||||
|
|
||||||
|
onClose(); |
||||||
|
} 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); |
||||||
|
} |
||||||
|
|
||||||
|
// Watch for modal open/close |
||||||
|
$effect(() => { |
||||||
|
if (isOpen) { |
||||||
|
loadSettings(); |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if isOpen} |
||||||
|
<div class="modal-overlay" onclick={(e) => e.target === e.currentTarget && onClose()}> |
||||||
|
<div class="modal-content"> |
||||||
|
<div class="modal-header"> |
||||||
|
<h2>Settings</h2> |
||||||
|
<button class="close-button" onclick={onClose} aria-label="Close"> |
||||||
|
<img src="/icons/x.svg" alt="Close" class="close-icon" /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading settings...</div> |
||||||
|
{:else} |
||||||
|
<div class="modal-body"> |
||||||
|
<!-- 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={defaultUserName || 'Enter your git user.name'} |
||||||
|
class="setting-input" |
||||||
|
/> |
||||||
|
{#if defaultUserName} |
||||||
|
<p class="setting-hint">Default: {defaultUserName}</p> |
||||||
|
{/if} |
||||||
|
<p class="setting-description"> |
||||||
|
Your name as it will appear in git commits. |
||||||
|
</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={defaultUserEmail || 'Enter your git user.email'} |
||||||
|
class="setting-input" |
||||||
|
/> |
||||||
|
{#if defaultUserEmail} |
||||||
|
<p class="setting-hint">Default: {defaultUserEmail}</p> |
||||||
|
{/if} |
||||||
|
<p class="setting-description"> |
||||||
|
Your email as it will appear in git commits. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Theme Selector --> |
||||||
|
<div class="setting-group"> |
||||||
|
<label class="setting-label"> |
||||||
|
<span class="label-text">Theme</span> |
||||||
|
</label> |
||||||
|
<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> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-actions"> |
||||||
|
<button onclick={onClose} class="cancel-button">Cancel</button> |
||||||
|
<button onclick={saveSettings} class="save-button" disabled={saving}> |
||||||
|
{saving ? 'Saving...' : 'Save'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 10000; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: var(--card-bg); |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 0.5rem; |
||||||
|
max-width: 600px; |
||||||
|
width: 100%; |
||||||
|
max-height: 90vh; |
||||||
|
overflow-y: auto; |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
padding: 1.5rem; |
||||||
|
border-bottom: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header h2 { |
||||||
|
margin: 0; |
||||||
|
font-size: 1.5rem; |
||||||
|
color: var(--text-primary); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: transparent; |
||||||
|
border: none; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0.5rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
border-radius: 0.25rem; |
||||||
|
transition: background 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.close-button:hover { |
||||||
|
background: var(--bg-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.close-icon { |
||||||
|
width: 20px; |
||||||
|
height: 20px; |
||||||
|
filter: brightness(0) saturate(100%) invert(1); |
||||||
|
} |
||||||
|
|
||||||
|
:global([data-theme="light"]) .close-icon { |
||||||
|
filter: brightness(0) saturate(100%); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.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; |
||||||
|
} |
||||||
|
|
||||||
|
.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); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-actions { |
||||||
|
display: flex; |
||||||
|
justify-content: flex-end; |
||||||
|
gap: 0.75rem; |
||||||
|
padding: 1.5rem; |
||||||
|
border-top: 1px solid var(--border-color); |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button, |
||||||
|
.save-button { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
border: 1px solid var(--border-color); |
||||||
|
border-radius: 0.375rem; |
||||||
|
font-size: 1rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button { |
||||||
|
background: var(--bg-secondary); |
||||||
|
color: var(--text-primary); |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-button:hover { |
||||||
|
background: var(--bg-tertiary); |
||||||
|
} |
||||||
|
|
||||||
|
.save-button { |
||||||
|
background: var(--accent); |
||||||
|
color: var(--accent-text, #ffffff); |
||||||
|
border-color: var(--accent); |
||||||
|
} |
||||||
|
|
||||||
|
.save-button:hover:not(:disabled) { |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.save-button:disabled { |
||||||
|
opacity: 0.6; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,194 @@ |
|||||||
|
/** |
||||||
|
* Settings store using IndexedDB for persistent client-side storage |
||||||
|
* Stores: auto-save, user.name, user.email, theme |
||||||
|
*/ |
||||||
|
|
||||||
|
import logger from './logger.js'; |
||||||
|
|
||||||
|
const DB_NAME = 'gitrepublic_settings'; |
||||||
|
const DB_VERSION = 1; |
||||||
|
const STORE_SETTINGS = 'settings'; |
||||||
|
|
||||||
|
interface Settings { |
||||||
|
autoSave: boolean; |
||||||
|
userName: string; |
||||||
|
userEmail: string; |
||||||
|
theme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'; |
||||||
|
} |
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = { |
||||||
|
autoSave: false, |
||||||
|
userName: '', |
||||||
|
userEmail: '', |
||||||
|
theme: 'gitrepublic-dark' |
||||||
|
}; |
||||||
|
|
||||||
|
export class SettingsStore { |
||||||
|
private db: IDBDatabase | null = null; |
||||||
|
private initPromise: Promise<void> | null = null; |
||||||
|
private settingsCache: Settings | null = null; |
||||||
|
|
||||||
|
constructor() { |
||||||
|
this.init(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize IndexedDB |
||||||
|
*/ |
||||||
|
private async init(): Promise<void> { |
||||||
|
if (this.initPromise) { |
||||||
|
return this.initPromise; |
||||||
|
} |
||||||
|
|
||||||
|
if (typeof window === 'undefined' || !window.indexedDB) { |
||||||
|
logger.warn('IndexedDB not available, using in-memory cache only'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.initPromise = new Promise((resolve, reject) => { |
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION); |
||||||
|
|
||||||
|
request.onerror = () => { |
||||||
|
logger.error('Failed to open settings IndexedDB'); |
||||||
|
reject(new Error('Failed to open settings IndexedDB')); |
||||||
|
}; |
||||||
|
|
||||||
|
request.onsuccess = () => { |
||||||
|
this.db = request.result; |
||||||
|
resolve(); |
||||||
|
}; |
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => { |
||||||
|
const db = (event.target as IDBOpenDBRequest).result; |
||||||
|
|
||||||
|
// Settings store - stores all settings as a single object
|
||||||
|
if (!db.objectStoreNames.contains(STORE_SETTINGS)) { |
||||||
|
db.createObjectStore(STORE_SETTINGS, { keyPath: 'id' }); |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
return this.initPromise; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all settings |
||||||
|
*/ |
||||||
|
async getSettings(): Promise<Settings> { |
||||||
|
await this.init(); |
||||||
|
|
||||||
|
// Return cached settings if available
|
||||||
|
if (this.settingsCache) { |
||||||
|
return this.settingsCache; |
||||||
|
} |
||||||
|
|
||||||
|
if (!this.db) { |
||||||
|
return { ...DEFAULT_SETTINGS }; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const store = this.db.transaction([STORE_SETTINGS], 'readonly').objectStore(STORE_SETTINGS); |
||||||
|
const request = store.get('main'); |
||||||
|
|
||||||
|
const result = await new Promise<Settings>((resolve, reject) => { |
||||||
|
request.onsuccess = () => { |
||||||
|
const data = request.result; |
||||||
|
if (data && data.settings) { |
||||||
|
// Merge with defaults to ensure all fields exist
|
||||||
|
const merged = { ...DEFAULT_SETTINGS, ...data.settings }; |
||||||
|
resolve(merged); |
||||||
|
} else { |
||||||
|
resolve({ ...DEFAULT_SETTINGS }); |
||||||
|
} |
||||||
|
}; |
||||||
|
request.onerror = () => reject(request.error); |
||||||
|
}); |
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.settingsCache = result; |
||||||
|
return result; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error }, 'Error reading settings from IndexedDB'); |
||||||
|
return { ...DEFAULT_SETTINGS }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update settings (partial update) |
||||||
|
*/ |
||||||
|
async updateSettings(updates: Partial<Settings>): Promise<void> { |
||||||
|
await this.init(); |
||||||
|
|
||||||
|
if (!this.db) { |
||||||
|
logger.warn('IndexedDB not available, cannot save settings'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Get current settings
|
||||||
|
const current = await this.getSettings(); |
||||||
|
|
||||||
|
// Merge with updates
|
||||||
|
const updated = { ...current, ...updates }; |
||||||
|
|
||||||
|
// Save to IndexedDB
|
||||||
|
const store = this.db.transaction([STORE_SETTINGS], 'readwrite').objectStore(STORE_SETTINGS); |
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const request = store.put({ id: 'main', settings: updated }); |
||||||
|
request.onsuccess = () => resolve(); |
||||||
|
request.onerror = () => reject(request.error); |
||||||
|
}); |
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this.settingsCache = updated; |
||||||
|
|
||||||
|
logger.debug({ updates }, 'Settings updated'); |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error, updates }, 'Error updating settings'); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get a specific setting |
||||||
|
*/ |
||||||
|
async getSetting<K extends keyof Settings>(key: K): Promise<Settings[K]> { |
||||||
|
const settings = await this.getSettings(); |
||||||
|
return settings[key]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set a specific setting |
||||||
|
*/ |
||||||
|
async setSetting<K extends keyof Settings>(key: K, value: Settings[K]): Promise<void> { |
||||||
|
await this.updateSettings({ [key]: value } as Partial<Settings>); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all settings (reset to defaults) |
||||||
|
*/ |
||||||
|
async clear(): Promise<void> { |
||||||
|
await this.init(); |
||||||
|
|
||||||
|
if (!this.db) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const store = this.db.transaction([STORE_SETTINGS], 'readwrite').objectStore(STORE_SETTINGS); |
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const request = store.delete('main'); |
||||||
|
request.onsuccess = () => resolve(); |
||||||
|
request.onerror = () => reject(request.error); |
||||||
|
}); |
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
this.settingsCache = null; |
||||||
|
} catch (error) { |
||||||
|
logger.error({ error }, 'Error clearing settings'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const settingsStore = new SettingsStore(); |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
/** |
||||||
|
* Utility functions for fetching and extracting user profile data from kind 0 events |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from '../services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '../config.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { persistentEventCache } from '../services/nostr/persistent-event-cache.js'; |
||||||
|
import type { NostrEvent } from '../types/nostr.js'; |
||||||
|
import { KIND } from '../types/nostr.js'; |
||||||
|
|
||||||
|
export interface UserProfile { |
||||||
|
displayName?: string; |
||||||
|
name?: string; |
||||||
|
nip05?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch user's kind 0 event from cache or relays |
||||||
|
*/ |
||||||
|
export async function fetchUserProfile( |
||||||
|
userPubkeyHex: string, |
||||||
|
relays: string[] = DEFAULT_NOSTR_RELAYS |
||||||
|
): Promise<NostrEvent | null> { |
||||||
|
// Try cache first
|
||||||
|
try { |
||||||
|
const cachedProfile = await persistentEventCache.getProfile(userPubkeyHex); |
||||||
|
if (cachedProfile) { |
||||||
|
return cachedProfile; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to get profile from cache:', err); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to relays
|
||||||
|
try { |
||||||
|
const client = new NostrClient(relays); |
||||||
|
const events = await client.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [0], // Kind 0 = profile metadata
|
||||||
|
authors: [userPubkeyHex], |
||||||
|
limit: 1 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
// Cache the profile for future use
|
||||||
|
await persistentEventCache.setProfile(userPubkeyHex, events[0]).catch(console.warn); |
||||||
|
return events[0]; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to fetch profile from relays:', err); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract user profile data from kind 0 event |
||||||
|
*/ |
||||||
|
export function extractProfileData(profileEvent: NostrEvent | null): UserProfile { |
||||||
|
if (!profileEvent) { |
||||||
|
return {}; |
||||||
|
} |
||||||
|
|
||||||
|
const profile: UserProfile = {}; |
||||||
|
|
||||||
|
// Try to parse JSON content
|
||||||
|
try { |
||||||
|
const content = JSON.parse(profileEvent.content); |
||||||
|
profile.displayName = content.display_name || content.displayName; |
||||||
|
profile.name = content.name; |
||||||
|
profile.nip05 = content.nip05; |
||||||
|
} catch { |
||||||
|
// Invalid JSON, try tags
|
||||||
|
} |
||||||
|
|
||||||
|
// Check tags for nip05 (newer format)
|
||||||
|
if (!profile.nip05) { |
||||||
|
const nip05Tag = profileEvent.tags.find((tag: string[]) =>
|
||||||
|
(tag[0] === 'nip05' || tag[0] === 'l') && tag[1] |
||||||
|
); |
||||||
|
if (nip05Tag && nip05Tag[1]) { |
||||||
|
profile.nip05 = nip05Tag[1]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return profile; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get user name with fallbacks: display_name -> name -> shortened npub (20 chars) |
||||||
|
*/ |
||||||
|
export function getUserName( |
||||||
|
profile: UserProfile, |
||||||
|
userPubkeyHex: string, |
||||||
|
userPubkey?: string |
||||||
|
): string { |
||||||
|
// Try display_name first
|
||||||
|
if (profile.displayName && profile.displayName.trim()) { |
||||||
|
return profile.displayName.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to name
|
||||||
|
if (profile.name && profile.name.trim()) { |
||||||
|
return profile.name.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to shortened npub (20 chars)
|
||||||
|
const npub = userPubkey || (userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : 'unknown'); |
||||||
|
return npub.substring(0, 20); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get user email with fallbacks: NIP-05 -> shortenednpub@gitrepublic.web |
||||||
|
*/ |
||||||
|
export function getUserEmail( |
||||||
|
profile: UserProfile, |
||||||
|
userPubkeyHex: string, |
||||||
|
userPubkey?: string |
||||||
|
): string { |
||||||
|
// Try NIP-05 first
|
||||||
|
if (profile.nip05 && profile.nip05.trim()) { |
||||||
|
return profile.nip05.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback to shortenednpub@gitrepublic.web
|
||||||
|
const npub = userPubkey || (userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : 'unknown'); |
||||||
|
const shortenedNpub = npub.substring(0, 20); |
||||||
|
return `${shortenedNpub}@gitrepublic.web`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch and get user name with all fallbacks |
||||||
|
*/ |
||||||
|
export async function fetchUserName( |
||||||
|
userPubkeyHex: string, |
||||||
|
userPubkey?: string, |
||||||
|
relays: string[] = DEFAULT_NOSTR_RELAYS |
||||||
|
): Promise<string> { |
||||||
|
const profileEvent = await fetchUserProfile(userPubkeyHex, relays); |
||||||
|
const profile = extractProfileData(profileEvent); |
||||||
|
return getUserName(profile, userPubkeyHex, userPubkey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch and get user email with all fallbacks |
||||||
|
*/ |
||||||
|
export async function fetchUserEmail( |
||||||
|
userPubkeyHex: string, |
||||||
|
userPubkey?: string, |
||||||
|
relays: string[] = DEFAULT_NOSTR_RELAYS |
||||||
|
): Promise<string> { |
||||||
|
const profileEvent = await fetchUserProfile(userPubkeyHex, relays); |
||||||
|
const profile = extractProfileData(profileEvent); |
||||||
|
return getUserEmail(profile, userPubkeyHex, userPubkey); |
||||||
|
} |
||||||
Loading…
Reference in new issue