Browse Source

harmonize user.name and user.email

add settings menu
autosave OFF 10 min

Nostr-Signature: 80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0
main
Silberengel 3 weeks ago
parent
commit
81ed0085ac
  1. 1
      nostr/commit-signatures.jsonl
  2. 2
      src/lib/components/NavBar.svelte
  3. 77
      src/lib/components/SettingsButton.svelte
  4. 472
      src/lib/components/SettingsModal.svelte
  5. 194
      src/lib/services/settings-store.ts
  6. 157
      src/lib/utils/user-profile.ts
  7. 28
      src/routes/+layout.svelte
  8. 45
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  9. 264
      src/routes/repos/[npub]/[repo]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -19,3 +19,4 @@ @@ -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"}

2
src/lib/components/NavBar.svelte

@ -4,6 +4,7 @@ @@ -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 @@ @@ -227,6 +228,7 @@
</div>
</nav>
<div class="auth-section">
<SettingsButton />
<ThemeToggle />
{#if userPubkey}
{@const userNpub = (() => {

77
src/lib/components/SettingsButton.svelte

@ -0,0 +1,77 @@ @@ -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>

472
src/lib/components/SettingsModal.svelte

@ -0,0 +1,472 @@ @@ -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>

194
src/lib/services/settings-store.ts

@ -0,0 +1,194 @@ @@ -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();

157
src/lib/utils/user-profile.ts

@ -0,0 +1,157 @@ @@ -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);
}

28
src/routes/+layout.svelte

@ -11,6 +11,7 @@ @@ -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 @@ @@ -36,17 +37,25 @@
let pendingTransfers = $state<PendingTransfer[]>([]);
let dismissedTransfers = $state<Set<string>>(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 @@ @@ -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() {

45
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -20,6 +20,7 @@ import { join } from 'path'; @@ -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: { @@ -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: { @@ -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: { @@ -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
);

264
src/routes/repos/[npub]/[repo]/+page.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import CodeEditor from '$lib/components/CodeEditor.svelte';
@ -15,9 +15,11 @@ @@ -15,9 +15,11 @@
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { userStore } from '$lib/stores/user-store.js';
import { settingsStore } from '$lib/services/settings-store.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
// Get page data for OpenGraph metadata - use $derived to make it reactive
const pageData = $derived($page.data as {
@ -62,6 +64,9 @@ @@ -62,6 +64,9 @@
let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions'>('discussions');
let showRepoMenu = $state(false);
// Auto-save
let autoSaveInterval: ReturnType<typeof setInterval> | null = null;
// Load maintainers when page data changes (only once per repo, with guard)
let lastRepoKey = $state<string | null>(null);
let maintainersEffectRan = $state(false);
@ -94,6 +99,25 @@ @@ -94,6 +99,25 @@
}
});
// Watch for auto-save setting changes
$effect(() => {
// Check auto-save setting and update interval (async, but don't await)
settingsStore.getSettings().then(settings => {
if (settings.autoSave && !autoSaveInterval) {
// Auto-save was enabled, set it up
setupAutoSave();
} else if (!settings.autoSave && autoSaveInterval) {
// Auto-save was disabled, clear interval
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
}
}).catch(err => {
console.warn('Failed to check auto-save setting:', err);
});
});
// Sync with userStore
$effect(() => {
const currentUser = $userStore;
@ -1432,6 +1456,17 @@ @@ -1432,6 +1456,17 @@
await loadReadme();
await loadForkInfo();
await loadRepoImages();
// Set up auto-save if enabled
setupAutoSave().catch(err => console.warn('Failed to setup auto-save:', err));
});
// Cleanup on destroy
onDestroy(() => {
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
});
async function checkAuth() {
@ -2122,6 +2157,17 @@ @@ -2122,6 +2157,17 @@
let fetchingUserName = $state(false);
async function getUserEmail(): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userEmail && settings.userEmail.trim()) {
cachedUserEmail = settings.userEmail.trim();
return cachedUserEmail;
}
} catch (err) {
console.warn('Failed to get userEmail from settings:', err);
}
// Return cached email if available
if (cachedUserEmail) {
return cachedUserEmail;
@ -2142,54 +2188,21 @@ @@ -2142,54 +2188,21 @@
}
fetchingUserEmail = true;
let nip05Email: string | null = null;
let prefillEmail: string;
try {
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const profileEvents = await client.fetchEvents([
{
kinds: [0], // Kind 0 = profile metadata
authors: [userPubkeyHex],
limit: 1
}
]);
if (profileEvents.length > 0) {
const event = profileEvents[0];
// First check tags (newer format where NIP-05 might be in tags)
const nip05Tag = event.tags.find((tag: string[]) =>
(tag[0] === 'nip05' || tag[0] === 'l') && tag[1]
);
if (nip05Tag && nip05Tag[1]) {
nip05Email = nip05Tag[1];
}
// Also check JSON content (traditional format)
if (!nip05Email) {
try {
const profile = JSON.parse(event.content);
// NIP-05 is stored as 'nip05' in the profile JSON
if (profile.nip05 && typeof profile.nip05 === 'string') {
nip05Email = profile.nip05;
}
} catch {
// Invalid JSON, ignore
}
}
}
// Fetch from kind 0 event (cache or relays)
prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for email:', err);
// Fallback to shortenednpub@gitrepublic.web
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const shortenedNpub = npubFromPubkey.substring(0, 20);
prefillEmail = `${shortenedNpub}@gitrepublic.web`;
} finally {
fetchingUserEmail = false;
}
// Always prompt user for email address (they might want to use a different domain)
// Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub)
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const fallbackEmail = `${npubFromPubkey}@nostr`;
const prefillEmail = nip05Email || fallbackEmail;
// Prompt user for email address
const userEmail = prompt(
'Please enter your email address for git commits.\n\n' +
@ -2203,6 +2216,8 @@ @@ -2203,6 +2216,8 @@
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(userEmail.trim())) {
cachedUserEmail = userEmail.trim();
// Save to settings store
settingsStore.setSetting('userEmail', cachedUserEmail).catch(console.error);
return cachedUserEmail;
} else {
alert('Invalid email format. Using fallback email address.');
@ -2210,11 +2225,22 @@ @@ -2210,11 +2225,22 @@
}
// Use fallback if user cancelled or entered invalid email
cachedUserEmail = fallbackEmail;
cachedUserEmail = prefillEmail;
return cachedUserEmail;
}
async function getUserName(): Promise<string> {
// Check settings store first
try {
const settings = await settingsStore.getSettings();
if (settings.userName && settings.userName.trim()) {
cachedUserName = settings.userName.trim();
return cachedUserName;
}
} catch (err) {
console.warn('Failed to get userName from settings:', err);
}
// Return cached name if available
if (cachedUserName) {
return cachedUserName;
@ -2235,41 +2261,20 @@ @@ -2235,41 +2261,20 @@
}
fetchingUserName = true;
let profileName: string | null = null;
let prefillName: string;
try {
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const profileEvents = await client.fetchEvents([
{
kinds: [0], // Kind 0 = profile metadata
authors: [userPubkeyHex],
limit: 1
}
]);
if (profileEvents.length > 0) {
try {
const profile = JSON.parse(profileEvents[0].content);
// Name is stored as 'name' in the profile JSON
if (profile.name && typeof profile.name === 'string') {
profileName = profile.name;
}
} catch {
// Invalid JSON, ignore
}
}
// Fetch from kind 0 event (cache or relays)
prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS);
} catch (err) {
console.warn('Failed to fetch user profile for name:', err);
// Fallback to shortened npub (20 chars)
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
prefillName = npubFromPubkey.substring(0, 20);
} finally {
fetchingUserName = false;
}
// Always prompt user for name (they might want to use a different name)
// Always use userPubkeyHex to generate npub (userPubkey might be hex instead of npub)
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown');
const fallbackName = npubFromPubkey;
const prefillName = profileName || fallbackName;
// Prompt user for name
const userName = prompt(
'Please enter your name for git commits.\n\n' +
@ -2280,14 +2285,133 @@ @@ -2280,14 +2285,133 @@
if (userName && userName.trim()) {
cachedUserName = userName.trim();
// Save to settings store
settingsStore.setSetting('userName', cachedUserName).catch(console.error);
return cachedUserName;
}
// Use fallback if user cancelled
cachedUserName = fallbackName;
cachedUserName = prefillName;
return cachedUserName;
}
async function setupAutoSave() {
// Clear existing interval if any
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
// Check if auto-save is enabled
try {
const settings = await settingsStore.getSettings();
if (!settings.autoSave) {
return; // Auto-save disabled
}
} catch (err) {
console.warn('Failed to check auto-save setting:', err);
return;
}
// Set up interval to auto-save every 10 minutes
autoSaveInterval = setInterval(async () => {
await autoSaveFile();
}, 10 * 60 * 1000); // 10 minutes
}
async function autoSaveFile() {
// Only auto-save if:
// 1. There are changes
// 2. A file is open
// 3. User is logged in
// 4. User is a maintainer
// 5. Not currently saving
// 6. Not in clone state
if (!hasChanges || !currentFile || !userPubkey || !isMaintainer || saving || needsClone) {
return;
}
// Check auto-save setting again (in case it changed)
try {
const settings = await settingsStore.getSettings();
if (!settings.autoSave) {
// Auto-save was disabled, clear interval
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
return;
}
} catch (err) {
console.warn('Failed to check auto-save setting:', err);
return;
}
// Generate a default commit message
const autoCommitMessage = `Auto-save: ${new Date().toLocaleString()}`;
try {
// Get user email and name from settings
const authorEmail = await getUserEmail();
const authorName = await getUserName();
// Sign commit with NIP-07 (client-side)
let commitSignatureEvent: NostrEvent | null = null;
if (isNIP07Available()) {
try {
const { KIND } = await import('$lib/types/nostr.js');
const timestamp = Math.floor(Date.now() / 1000);
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', autoCommitMessage]
],
content: `Signed commit: ${autoCommitMessage}`
};
commitSignatureEvent = await signEventWithNIP07(eventTemplate);
} catch (err) {
console.warn('Failed to sign commit with NIP-07:', err);
// Continue without signature if signing fails
}
}
const response = await fetch(`/api/repos/${npub}/${repo}/file`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildApiHeaders()
},
body: JSON.stringify({
path: currentFile,
content: editedContent,
commitMessage: autoCommitMessage,
authorName: authorName,
authorEmail: authorEmail,
branch: currentBranch,
userPubkey: userPubkey,
commitSignatureEvent: commitSignatureEvent
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
console.warn('Auto-save failed:', errorData.message || 'Failed to save file');
return;
}
// Reload file to get updated content
await loadFile(currentFile);
// Note: We don't show an alert for auto-save, it's silent
console.log('Auto-saved file:', currentFile);
} catch (err) {
console.warn('Error during auto-save:', err);
// Don't show error to user, it's silent
}
}
async function saveFile() {
if (!currentFile || !commitMessage.trim()) {
alert('Please enter a commit message');

Loading…
Cancel
Save