From 81ed0085acdfc94375453e004efbf77b8d081cb7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 19:28:02 +0100 Subject: [PATCH] harmonize user.name and user.email add settings menu autosave OFF 10 min Nostr-Signature: 80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0 --- nostr/commit-signatures.jsonl | 1 + src/lib/components/NavBar.svelte | 2 + src/lib/components/SettingsButton.svelte | 77 +++ src/lib/components/SettingsModal.svelte | 472 ++++++++++++++++++ src/lib/services/settings-store.ts | 194 +++++++ src/lib/utils/user-profile.ts | 157 ++++++ src/routes/+layout.svelte | 28 +- .../api/repos/[npub]/[repo]/file/+server.ts | 45 +- src/routes/repos/[npub]/[repo]/+page.svelte | 264 +++++++--- 9 files changed, 1155 insertions(+), 85 deletions(-) create mode 100644 src/lib/components/SettingsButton.svelte create mode 100644 src/lib/components/SettingsModal.svelte create mode 100644 src/lib/services/settings-store.ts create mode 100644 src/lib/utils/user-profile.ts diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 049673f..3d9ea3e 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -19,3 +19,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771584611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix login persistence"]],"content":"Signed commit: fix login persistence","id":"e02d4dbaf56fb0498ca6871ae25bd5da1061eeca1d28c88d54ff5f6549982f11","sig":"647fa0385224b33546c55c786b3c2cf3b2cfab5de9f9748ce814e40e8c6819131ebb9e86d7682bffa327e3b690297f17bcfb2f6b2d5fb6b65e1d9474d66659b1"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771587832,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","implemented IndexedDB to organize the persistent event cache\nbackground deletion removal\ncorrected and expanded search and added cancel button\nshow maintainers on the search result cards\nremove code search\nremoved hard-coded theme classes"]],"content":"Signed commit: implemented IndexedDB to organize the persistent event cache\nbackground deletion removal\ncorrected and expanded search and added cancel button\nshow maintainers on the search result cards\nremove code search\nremoved hard-coded theme classes","id":"8080f3cad9abacfc9a5fe08bc26744ff8444d0228ea8a6e8a449c8c2704885d6","sig":"70120c99f5e8a1e9df6d74af756a51641c4998265b9233d5a7d187d9e21302dc6377ae274b07be4d6515af1dabfada43fa9af1a087a34e2879b028ac34e551ca"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771604372,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"9a1ba983e0b0db8cff3675a078a376df5c9ad351c3988ea893f3e8084a65a1e6","sig":"724a326cbd6a33f1ff6a2c37b242c7571e35149281609e9eb1c6a197422a13834d9ac2f5d0719026bc66126bd0022df49adf50aa08af93dd95076f407b0f0456"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771607520,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"2040e0adbed520ee9a21c6a1c7df48fae27021c1d3474b584388cd5ddafc6a49","sig":"893b4881e3876c0f556e3be991e9c6e99c9f5933bc9755e4075c1d0bfea95750b2318f3d3409d689c7e9a862cf053db0e7d3083ee28cf48ffbe794583c3ad783"} diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index a7ca117..f1888c2 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -4,6 +4,7 @@ import { getPublicKeyWithNIP07, isNIP07Available } from '../services/nostr/nip07-signer.js'; import { nip19 } from 'nostr-tools'; import ThemeToggle from './ThemeToggle.svelte'; + import SettingsButton from './SettingsButton.svelte'; import UserBadge from './UserBadge.svelte'; import { onMount } from 'svelte'; import { userStore } from '../stores/user-store.js'; @@ -227,6 +228,7 @@
+ {#if userPubkey} {@const userNpub = (() => { diff --git a/src/lib/components/SettingsButton.svelte b/src/lib/components/SettingsButton.svelte new file mode 100644 index 0000000..c725bc8 --- /dev/null +++ b/src/lib/components/SettingsButton.svelte @@ -0,0 +1,77 @@ + + + + + showSettings = false} /> + + diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte new file mode 100644 index 0000000..405b4fd --- /dev/null +++ b/src/lib/components/SettingsModal.svelte @@ -0,0 +1,472 @@ + + +{#if isOpen} + +{/if} + + diff --git a/src/lib/services/settings-store.ts b/src/lib/services/settings-store.ts new file mode 100644 index 0000000..bda77f5 --- /dev/null +++ b/src/lib/services/settings-store.ts @@ -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 | null = null; + private settingsCache: Settings | null = null; + + constructor() { + this.init(); + } + + /** + * Initialize IndexedDB + */ + private async init(): Promise { + 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 { + 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((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): Promise { + 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((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(key: K): Promise { + const settings = await this.getSettings(); + return settings[key]; + } + + /** + * Set a specific setting + */ + async setSetting(key: K, value: Settings[K]): Promise { + await this.updateSettings({ [key]: value } as Partial); + } + + /** + * Clear all settings (reset to defaults) + */ + async clear(): Promise { + await this.init(); + + if (!this.db) { + return; + } + + try { + const store = this.db.transaction([STORE_SETTINGS], 'readwrite').objectStore(STORE_SETTINGS); + await new Promise((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(); diff --git a/src/lib/utils/user-profile.ts b/src/lib/utils/user-profile.ts new file mode 100644 index 0000000..c24fa46 --- /dev/null +++ b/src/lib/utils/user-profile.ts @@ -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 { + // 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 { + 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 { + const profileEvent = await fetchUserProfile(userPubkeyHex, relays); + const profile = extractProfileData(profileEvent); + return getUserEmail(profile, userPubkeyHex, userPubkey); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d66a532..5e36e3d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,6 +11,7 @@ import { determineUserLevel, decodePubkey } from '$lib/services/nostr/user-level-service.js'; import { userStore } from '$lib/stores/user-store.js'; import { updateActivity } from '$lib/services/activity-tracker.js'; + import { settingsStore } from '$lib/services/settings-store.js'; // Accept children as a snippet prop (Svelte 5) let { children }: { children: Snippet } = $props(); @@ -36,17 +37,25 @@ let pendingTransfers = $state([]); let dismissedTransfers = $state>(new Set()); - onMount(() => { + onMount(async () => { // Only run client-side code if (typeof window === 'undefined') return; - // Check for saved theme preference or default to gitrepublic-dark - const savedTheme = localStorage.getItem('theme') as 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' | null; - if (savedTheme === 'gitrepublic-light' || savedTheme === 'gitrepublic-dark' || savedTheme === 'gitrepublic-black') { - theme = savedTheme; - } else { - // Default to gitrepublic-dark (purple) - theme = 'gitrepublic-dark'; + // Load theme from settings store + try { + const settings = await settingsStore.getSettings(); + theme = settings.theme; + } catch (err) { + console.warn('Failed to load theme from settings, using default:', err); + // Fallback to localStorage for migration + const savedTheme = localStorage.getItem('theme') as 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' | null; + if (savedTheme === 'gitrepublic-light' || savedTheme === 'gitrepublic-dark' || savedTheme === 'gitrepublic-black') { + theme = savedTheme; + // Migrate to settings store + settingsStore.setSetting('theme', theme).catch(console.error); + } else { + theme = 'gitrepublic-dark'; + } } applyTheme(); @@ -185,7 +194,8 @@ } else if (theme === 'gitrepublic-black') { document.documentElement.setAttribute('data-theme', 'black'); } - localStorage.setItem('theme', theme); + // Save to settings store (async, don't await) + settingsStore.setSetting('theme', theme).catch(console.error); } function toggleTheme() { diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index 23efb76..94fb1f7 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -20,6 +20,7 @@ import { join } from 'path'; import { existsSync } from 'fs'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; +import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT @@ -306,8 +307,40 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { } } - if (!path || !commitMessage || !authorName || !authorEmail) { - return error(400, 'Missing required fields: path, commitMessage, authorName, authorEmail'); + if (!path || !commitMessage) { + return error(400, 'Missing required fields: path, commitMessage'); + } + + // Fetch authorName and authorEmail from kind 0 event if not provided + let finalAuthorName = authorName; + let finalAuthorEmail = authorEmail; + + if (!finalAuthorName || !finalAuthorEmail) { + if (!userPubkey) { + return error(400, 'Missing userPubkey. Cannot fetch author information without userPubkey.'); + } + + const userPubkeyHexForProfile = decodeNpubToHex(userPubkey) || userPubkey; + + try { + if (!finalAuthorName) { + finalAuthorName = await fetchUserName(userPubkeyHexForProfile, userPubkey, DEFAULT_NOSTR_RELAYS); + } + if (!finalAuthorEmail) { + finalAuthorEmail = await fetchUserEmail(userPubkeyHexForProfile, userPubkey, DEFAULT_NOSTR_RELAYS); + } + } catch (err) { + logger.warn({ error: err, userPubkey }, 'Failed to fetch user profile for author info, using fallbacks'); + // Use fallbacks if fetch fails + if (!finalAuthorName) { + const npub = userPubkey.startsWith('npub') ? userPubkey : nip19.npubEncode(userPubkeyHexForProfile); + finalAuthorName = npub.substring(0, 20); + } + if (!finalAuthorEmail) { + const npub = userPubkey.startsWith('npub') ? userPubkey : nip19.npubEncode(userPubkeyHexForProfile); + finalAuthorEmail = `${npub.substring(0, 20)}@gitrepublic.web`; + } + } } if (!userPubkey) { @@ -406,8 +439,8 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { repo, path, commitMessage, - authorName, - authorEmail, + finalAuthorName, + finalAuthorEmail, targetBranch, Object.keys(signingOptions).length > 0 ? signingOptions : undefined ); @@ -446,8 +479,8 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { path, content, commitMessage, - authorName, - authorEmail, + finalAuthorName, + finalAuthorEmail, targetBranch, Object.keys(signingOptions).length > 0 ? signingOptions : undefined ); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 9f8e34b..a95274f 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -1,5 +1,5 @@