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}
+ e.target === e.currentTarget && onClose()}>
+
+
+
+ {#if loading}
+
Loading settings...
+ {:else}
+
+
+
+
+
+ When enabled, changes are automatically committed every 10 minutes if there are unsaved changes.
+
+
+
+
+
+
+ Git User Name
+
+
+ {#if defaultUserName}
+
Default: {defaultUserName}
+ {/if}
+
+ Your name as it will appear in git commits.
+
+
+
+
+
+
+ Git User Email
+
+
+ {#if defaultUserEmail}
+
Default: {defaultUserEmail}
+ {/if}
+
+ Your email as it will appear in git commits.
+
+
+
+
+
+
+ Theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+{/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 @@