Browse Source
Nostr-Signature: 862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651amain
17 changed files with 2543 additions and 546 deletions
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
# NIP-A3: Payment Targets (payto://) |
||||
|
||||
GitRepublic supports NIP-A3 (Payment Targets) for displaying payment information in user profiles. This allows users to specify payment targets using the [RFC-8905 (payto:) URI scheme](https://www.rfc-editor.org/rfc/rfc8905.html). |
||||
|
||||
## Overview |
||||
|
||||
NIP-A3 defines `kind:10133` for payment target events. This kind is **replaceable**, meaning users can update their payment targets by publishing a new event with the same kind. |
||||
|
||||
## Event Structure |
||||
|
||||
### Kind 10133: Payment Targets |
||||
|
||||
```json |
||||
{ |
||||
"pubkey": "afc93622eb4d79c0fb75e56e0c14553f7214b0a466abeba14cb38968c6755e6a", |
||||
"kind": 10133, |
||||
"content": "", |
||||
"tags": [ |
||||
["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"], |
||||
["payto", "lightning", "user@wallet.example.com"], |
||||
["payto", "nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"] |
||||
], |
||||
"created_at": 1234567890, |
||||
"id": "...", |
||||
"sig": "..." |
||||
} |
||||
``` |
||||
|
||||
### Tag Format |
||||
|
||||
Payment targets are specified using `payto` tags with the following structure: |
||||
|
||||
```text |
||||
["payto", "<type>", "<authority>", "<optional_extra_1>", "<optional_extra_2>", ...] |
||||
``` |
||||
|
||||
Where: |
||||
- The first element is always the literal string `"payto"` |
||||
- The second element is the payment `type` (e.g., `"bitcoin"`, `"lightning"`) |
||||
- The third element is the `authority` (e.g., address, username) |
||||
- Additional elements are optional and reserved for future RFC-8905 features |
||||
|
||||
## Supported Payment Types |
||||
|
||||
GitRepublic recognizes and displays the following payment target types: |
||||
|
||||
| Payment Target Type | Long Stylization | Short Stylization | Symbol | References | |
||||
| :------------------ | :---------------- | :---------------- | :----- | :--------- | |
||||
| bitcoin | Bitcoin | BTC | ₿ | https://bitcoin.design/ | |
||||
| cashme | Cash App | Cash App | $,£ | https://cash.app/press | |
||||
| ethereum | Ethereum | ETH | Ξ | https://ethereum.org/assets/#brand | |
||||
| lightning | Lightning Network | LBTC | 丰 | https://github.com/shocknet/bitcoin-lightning-logo | |
||||
| monero | Monero | XMR | ɱ | https://www.getmonero.org/press-kit/ | |
||||
| nano | Nano | XNO | Ӿ | https://nano.org/en/currency | |
||||
| revolut | Revolut | Revolut | N/A | https://revolut.me | |
||||
| venmo | Venmo | Venmo | $ | https://venmo.com/pay | |
||||
|
||||
Unrecognized types are still displayed but without special styling. |
||||
|
||||
## Integration with NIP-01 Profiles |
||||
|
||||
GitRepublic merges payment targets from multiple sources: |
||||
|
||||
1. **NIP-01 (kind 0)**: Lightning addresses from `lud16` tags or JSON `lud16` field |
||||
2. **NIP-A3 (kind 10133)**: All payment targets from `payto` tags |
||||
|
||||
The system: |
||||
- Normalizes all addresses to lowercase for deduplication |
||||
- Merges lightning addresses from both sources |
||||
- Displays all payment targets together in the profile |
||||
- Formats each target as a `payto://<type>/<authority>` URI |
||||
|
||||
## Display Format |
||||
|
||||
Payment targets are displayed on user profile pages with: |
||||
- Payment type (e.g., "lightning", "bitcoin") |
||||
- Full `payto://` URI |
||||
- Copy button for easy sharing |
||||
|
||||
Example display: |
||||
``` |
||||
Payments |
||||
├─ lightning payto://lightning/user@wallet.example.com [Copy] |
||||
├─ bitcoin payto://bitcoin/bc1q... [Copy] |
||||
└─ nano payto://nano/nano_1... [Copy] |
||||
``` |
||||
|
||||
## API Access |
||||
|
||||
### GET `/api/users/{npub}/profile` |
||||
|
||||
Returns the full user profile including payment targets: |
||||
|
||||
```json |
||||
{ |
||||
"npub": "npub1...", |
||||
"pubkey": "afc93622...", |
||||
"profile": { |
||||
"name": "Alice", |
||||
"about": "Developer", |
||||
"picture": "https://...", |
||||
"websites": [], |
||||
"nip05": [] |
||||
}, |
||||
"profileEvent": { ... }, |
||||
"paymentTargets": [ |
||||
{ |
||||
"type": "lightning", |
||||
"authority": "user@wallet.example.com", |
||||
"payto": "payto://lightning/user@wallet.example.com" |
||||
}, |
||||
{ |
||||
"type": "bitcoin", |
||||
"authority": "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k", |
||||
"payto": "payto://bitcoin/bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k" |
||||
} |
||||
], |
||||
"paymentEvent": { ... } |
||||
} |
||||
``` |
||||
|
||||
## CLI Access |
||||
|
||||
The GitRepublic CLI also supports fetching payment targets: |
||||
|
||||
```bash |
||||
# Profile fetcher automatically includes payment targets |
||||
gitrep profile fetch npub1... |
||||
``` |
||||
|
||||
The CLI's `profile-fetcher.js` module fetches both kind 0 and kind 10133 events and merges the payment information. |
||||
|
||||
## Creating Payment Target Events |
||||
|
||||
To create a payment target event, publish a kind 10133 event with `payto` tags: |
||||
|
||||
```javascript |
||||
const event = { |
||||
kind: 10133, |
||||
content: "", |
||||
tags: [ |
||||
["payto", "lightning", "user@wallet.example.com"], |
||||
["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"] |
||||
], |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
pubkey: yourPubkey |
||||
}; |
||||
|
||||
// Sign and publish to relays |
||||
``` |
||||
|
||||
## References |
||||
|
||||
- [NIP-A3 Specification](https://github.com/nostr-protocol/nips/pull/XXX) (when published) |
||||
- [RFC-8905: The "payto" URI Scheme](https://www.rfc-editor.org/rfc/rfc8905.html) |
||||
- [NIP-57: Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md) - Related specification for lightning payments |
||||
|
||||
## Notes |
||||
|
||||
- Payment targets are **replaceable** - publish a new kind 10133 event to update |
||||
- GitRepublic checks cache first, then relays for profile and payment events |
||||
- Lightning addresses from NIP-01 (lud16) are automatically merged with kind 10133 |
||||
- All addresses are normalized (lowercase) and deduplicated before display |
||||
@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
|
||||
/** |
||||
* API endpoint for fetching user profile with payment targets |
||||
* Returns full profile event (kind 0) and payment targets (kind 10133) |
||||
*/ |
||||
|
||||
import { json } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; |
||||
import { fetchUserProfile } from '$lib/utils/user-profile.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
|
||||
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
|
||||
export const GET: RequestHandler = async (event) => { |
||||
try { |
||||
const { npub } = event.params; |
||||
if (!npub) { |
||||
return handleValidationError('Missing npub parameter', { operation: 'getUserProfile' }); |
||||
} |
||||
|
||||
// Decode npub to get pubkey
|
||||
let userPubkey: string; |
||||
try { |
||||
const decoded = nip19.decode(npub); |
||||
if (decoded.type !== 'npub') { |
||||
return handleValidationError('Invalid npub format', { operation: 'getUserProfile', npub }); |
||||
} |
||||
userPubkey = decoded.data as string; |
||||
} catch { |
||||
return handleValidationError('Invalid npub format', { operation: 'getUserProfile', npub }); |
||||
} |
||||
|
||||
// Fetch user profile (kind 0) - check cache first
|
||||
const profileEvent = await fetchUserProfile(userPubkey, DEFAULT_NOSTR_RELAYS); |
||||
|
||||
// Extract profile data - prefer tags, fallback to JSON
|
||||
let profileData: any = {}; |
||||
if (profileEvent) { |
||||
try { |
||||
profileData = JSON.parse(profileEvent.content); |
||||
} catch { |
||||
// Invalid JSON, will use tags
|
||||
} |
||||
} |
||||
|
||||
// Extract from tags (new format) - prefer tags over JSON
|
||||
const nameTag = profileEvent?.tags.find(t => t[0] === 'name' || t[0] === 'display_name')?.[1]; |
||||
const aboutTag = profileEvent?.tags.find(t => t[0] === 'about')?.[1]; |
||||
const pictureTag = profileEvent?.tags.find(t => t[0] === 'picture' || t[0] === 'avatar')?.[1]; |
||||
const websiteTags = profileEvent?.tags.filter(t => t[0] === 'website' || t[0] === 'w').map(t => t[1]).filter(Boolean) || []; |
||||
const nip05Tags = profileEvent?.tags.filter(t => t[0] === 'nip05' || t[0] === 'l').map(t => t[1]).filter(Boolean) || []; |
||||
|
||||
// Initialize lightning addresses set for collecting from multiple sources
|
||||
const lightningAddresses = new Set<string>(); |
||||
|
||||
// Extract lightning addresses from NIP-01 (lud16 tag or JSON)
|
||||
if (profileEvent) { |
||||
// From tags (lud16)
|
||||
const lud16Tags = profileEvent.tags.filter(t => t[0] === 'lud16').map(t => t[1]).filter(Boolean); |
||||
lud16Tags.forEach(addr => lightningAddresses.add(addr.toLowerCase())); |
||||
|
||||
// From JSON (lud16 field)
|
||||
if (profileData.lud16 && typeof profileData.lud16 === 'string') { |
||||
lightningAddresses.add(profileData.lud16.toLowerCase()); |
||||
} |
||||
} |
||||
|
||||
// Fetch kind 10133 (payment targets)
|
||||
const paymentEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [10133], |
||||
authors: [userPubkey], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
// Extract lightning addresses from kind 10133
|
||||
if (paymentEvents.length > 0) { |
||||
const paytoTags = paymentEvents[0].tags.filter(t => t[0] === 'payto' && t[1] === 'lightning' && t[2]); |
||||
paytoTags.forEach(tag => { |
||||
if (tag[2]) { |
||||
lightningAddresses.add(tag[2].toLowerCase()); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// Build payment targets array - start with lightning addresses
|
||||
const paymentTargets: Array<{ type: string; authority: string; payto: string }> = Array.from(lightningAddresses).map(authority => ({ |
||||
type: 'lightning', |
||||
authority, |
||||
payto: `payto://lightning/${authority}` |
||||
})); |
||||
|
||||
// Also include other payment types from kind 10133
|
||||
if (paymentEvents.length > 0) { |
||||
const otherPaytoTags = paymentEvents[0].tags.filter(t => t[0] === 'payto' && t[1] && t[1] !== 'lightning' && t[2]); |
||||
otherPaytoTags.forEach(tag => { |
||||
const type = tag[1]?.toLowerCase() || ''; |
||||
const authority = tag[2] || ''; |
||||
if (type && authority) { |
||||
// Check if we already have this (for deduplication)
|
||||
const existing = paymentTargets.find(p => p.type === type && p.authority.toLowerCase() === authority.toLowerCase()); |
||||
if (!existing) { |
||||
paymentTargets.push({ |
||||
type, |
||||
authority, |
||||
payto: `payto://${type}/${authority}` |
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
return json({ |
||||
npub, |
||||
pubkey: userPubkey, |
||||
profile: { |
||||
name: nameTag || profileData.display_name || profileData.name, |
||||
about: aboutTag || profileData.about, |
||||
picture: pictureTag || profileData.picture, |
||||
websites: websiteTags, |
||||
nip05: nip05Tags |
||||
}, |
||||
profileEvent: profileEvent ? { |
||||
id: profileEvent.id, |
||||
pubkey: profileEvent.pubkey, |
||||
created_at: profileEvent.created_at, |
||||
kind: profileEvent.kind, |
||||
tags: profileEvent.tags, |
||||
content: profileEvent.content, |
||||
sig: profileEvent.sig |
||||
} : null, |
||||
paymentTargets, |
||||
paymentEvent: paymentEvents.length > 0 ? { |
||||
id: paymentEvents[0].id, |
||||
pubkey: paymentEvents[0].pubkey, |
||||
created_at: paymentEvents[0].created_at, |
||||
kind: paymentEvents[0].kind, |
||||
tags: paymentEvents[0].tags, |
||||
content: paymentEvents[0].content, |
||||
sig: paymentEvents[0].sig |
||||
} : null |
||||
}); |
||||
} catch (err) { |
||||
return handleApiError(err, { operation: 'getUserProfile' }, 'Failed to get user profile'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
/** |
||||
* Server-side loader for NIP-A3 documentation |
||||
*/ |
||||
|
||||
import { readFile } from 'fs/promises'; |
||||
import { join } from 'path'; |
||||
import type { PageServerLoad } from './$types'; |
||||
import logger from '$lib/services/logger.js'; |
||||
|
||||
export const load: PageServerLoad = async () => { |
||||
try { |
||||
// Read NIP-A3 documentation from docs/NIP-A3.md
|
||||
const filePath = join(process.cwd(), 'docs', 'NIP-A3.md'); |
||||
const content = await readFile(filePath, 'utf-8'); |
||||
return { content }; |
||||
} catch (error) { |
||||
logger.error({ error }, 'Error loading NIP-A3 documentation'); |
||||
return { content: null, error: 'Failed to load NIP-A3 documentation' }; |
||||
} |
||||
}; |
||||
@ -0,0 +1,241 @@
@@ -0,0 +1,241 @@
|
||||
<script lang="ts"> |
||||
import { page } from '$app/stores'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let content = $state(''); |
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
|
||||
onMount(async () => { |
||||
try { |
||||
const docContent = $page.data.content; |
||||
if (docContent) { |
||||
const MarkdownIt = (await import('markdown-it')).default; |
||||
const hljsModule = await import('highlight.js'); |
||||
const hljs = hljsModule.default || hljsModule; |
||||
|
||||
const md = new MarkdownIt({ |
||||
highlight: function (str: string, lang: string): string { |
||||
if (lang && hljs.getLanguage(lang)) { |
||||
try { |
||||
return '<pre class="hljs"><code>' + |
||||
hljs.highlight(str, { language: lang }).value + |
||||
'</code></pre>'; |
||||
} catch (err) { |
||||
// Fallback to escaped HTML if highlighting fails |
||||
// This is expected for unsupported languages |
||||
} |
||||
} |
||||
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; |
||||
} |
||||
}); |
||||
|
||||
let rendered = md.render(docContent); |
||||
|
||||
// Add IDs to headings for anchor links |
||||
rendered = rendered.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => { |
||||
// Extract text content (remove any HTML tags) |
||||
const textContent = text.replace(/<[^>]*>/g, '').trim(); |
||||
// Create slug from text |
||||
const slug = textContent |
||||
.toLowerCase() |
||||
.replace(/[^\w\s-]/g, '') // Remove special characters |
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens |
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single |
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens |
||||
|
||||
return `<h${level} id="${slug}">${text}</h${level}>`; |
||||
}); |
||||
|
||||
content = rendered; |
||||
|
||||
// Handle anchor links after content is rendered |
||||
setTimeout(() => { |
||||
// Handle initial hash in URL |
||||
if (window.location.hash) { |
||||
const id = window.location.hash.substring(1); |
||||
const element = document.getElementById(id); |
||||
if (element) { |
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
||||
} |
||||
} |
||||
|
||||
// Handle clicks on anchor links |
||||
const markdownContent = document.querySelector('.markdown-content'); |
||||
if (markdownContent) { |
||||
markdownContent.addEventListener('click', (e) => { |
||||
const target = e.target as HTMLElement; |
||||
if (target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) { |
||||
const id = target.getAttribute('href')?.substring(1); |
||||
if (id) { |
||||
const element = document.getElementById(id); |
||||
if (element) { |
||||
e.preventDefault(); |
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
||||
// Update URL without scrolling |
||||
window.history.pushState(null, '', `#${id}`); |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
}, 100); |
||||
} else { |
||||
error = $page.data.error || 'Failed to load NIP-A3 documentation'; |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to load documentation'; |
||||
console.error('Error parsing NIP-A3 documentation:', err); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<h1>NIP-A3: Payment Targets</h1> |
||||
<p class="subtitle">Payment targets (payto://) support and documentation</p> |
||||
</header> |
||||
|
||||
<main class="docs-content"> |
||||
{#if loading} |
||||
<div class="loading">Loading documentation...</div> |
||||
{:else if error} |
||||
<div class="error">{error}</div> |
||||
{:else} |
||||
<div class="markdown-content"> |
||||
{@html content} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.subtitle { |
||||
color: var(--text-muted); |
||||
margin: 0; |
||||
} |
||||
|
||||
.docs-content { |
||||
background: var(--card-bg); |
||||
padding: 2rem; |
||||
border-radius: 0.5rem; |
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); |
||||
border: 1px solid var(--border-color); |
||||
} |
||||
|
||||
:global(.markdown-content) { |
||||
line-height: 1.6; |
||||
} |
||||
|
||||
:global(.markdown-content h1) { |
||||
font-size: 2rem; |
||||
margin-top: 2rem; |
||||
margin-bottom: 1rem; |
||||
border-bottom: 2px solid var(--border-color); |
||||
padding-bottom: 0.5rem; |
||||
color: var(--text-primary); |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
:global(.markdown-content h2) { |
||||
font-size: 1.5rem; |
||||
margin-top: 1.5rem; |
||||
margin-bottom: 0.75rem; |
||||
color: var(--text-primary); |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
:global(.markdown-content h3) { |
||||
font-size: 1.25rem; |
||||
margin-top: 1.25rem; |
||||
margin-bottom: 0.5rem; |
||||
color: var(--text-primary); |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
:global(.markdown-content h4) { |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
:global(.markdown-content h5) { |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
:global(.markdown-content h6) { |
||||
scroll-margin-top: 1rem; |
||||
} |
||||
|
||||
/* Smooth scrolling for anchor links */ |
||||
:global(.markdown-content) { |
||||
scroll-behavior: smooth; |
||||
} |
||||
|
||||
:global(.markdown-content code) { |
||||
background: var(--bg-secondary); |
||||
padding: 0.125rem 0.25rem; |
||||
border-radius: 0.25rem; |
||||
font-family: 'IBM Plex Mono', monospace; |
||||
font-size: 0.875em; |
||||
color: var(--text-primary); |
||||
} |
||||
|
||||
:global(.markdown-content pre) { |
||||
background: var(--bg-tertiary); |
||||
color: var(--text-primary); |
||||
padding: 1rem; |
||||
border-radius: 0.5rem; |
||||
overflow-x: auto; |
||||
margin: 1rem 0; |
||||
border: 1px solid var(--border-color); |
||||
} |
||||
|
||||
:global(.markdown-content pre code) { |
||||
background: transparent; |
||||
padding: 0; |
||||
color: inherit; |
||||
} |
||||
|
||||
:global(.markdown-content p) { |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
:global(.markdown-content ul, .markdown-content ol) { |
||||
margin: 1rem 0; |
||||
padding-left: 2rem; |
||||
} |
||||
|
||||
:global(.markdown-content li) { |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
:global(.markdown-content blockquote) { |
||||
border-left: 4px solid var(--accent); |
||||
padding-left: 1rem; |
||||
margin: 1rem 0; |
||||
color: var(--text-secondary); |
||||
} |
||||
|
||||
:global(.markdown-content table) { |
||||
width: 100%; |
||||
border-collapse: collapse; |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
:global(.markdown-content th, .markdown-content td) { |
||||
border: 1px solid var(--border-color); |
||||
padding: 0.5rem; |
||||
text-align: left; |
||||
} |
||||
|
||||
:global(.markdown-content th) { |
||||
background: var(--bg-secondary); |
||||
font-weight: 600; |
||||
color: var(--text-primary); |
||||
} |
||||
|
||||
:global(.markdown-content td) { |
||||
color: var(--text-primary); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,618 @@
@@ -0,0 +1,618 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import { goto } from '$app/navigation'; |
||||
import { settingsStore } from '$lib/services/settings-store.js'; |
||||
import { userStore } from '$lib/stores/user-store.js'; |
||||
import { fetchUserEmail, fetchUserName, fetchUserProfile, extractProfileData, getUserName, getUserEmail } from '$lib/utils/user-profile.js'; |
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; |
||||
|
||||
// Get tab from URL params |
||||
const validTabs = ['general', 'git-setup', 'connections'] as const; |
||||
type TabType = typeof validTabs[number]; |
||||
|
||||
// Get initial tab from URL, default to 'general' |
||||
const getTabFromUrl = () => { |
||||
const tabParam = ($page.params as { tab?: string }).tab; |
||||
if (tabParam && validTabs.includes(tabParam as TabType)) { |
||||
return tabParam as TabType; |
||||
} |
||||
return 'general'; |
||||
}; |
||||
|
||||
let activeTab = $state<TabType>(getTabFromUrl()); |
||||
|
||||
let autoSave = $state(false); |
||||
let userName = $state(''); |
||||
let userEmail = $state(''); |
||||
let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark'); |
||||
let defaultBranch = $state('master'); |
||||
let loading = $state(false); |
||||
let saving = $state(false); |
||||
let loadingPresets = $state(false); |
||||
let settingsLoaded = $state(false); |
||||
|
||||
// Preset values that will be used if user doesn't override |
||||
let presetUserName = $state(''); |
||||
let presetUserEmail = $state(''); |
||||
|
||||
// Update URL when tab changes |
||||
function setActiveTab(tab: TabType) { |
||||
activeTab = tab; |
||||
goto(`/settings/${tab}`, { replaceState: true, noScroll: true }); |
||||
} |
||||
|
||||
async function loadSettings() { |
||||
if (settingsLoaded) return; // Don't reload if already loaded |
||||
loading = true; |
||||
try { |
||||
console.log('[SettingsPage] Loading settings from store...'); |
||||
const settings = await settingsStore.getSettings(); |
||||
console.log('[SettingsPage] Settings loaded:', settings); |
||||
autoSave = settings.autoSave; |
||||
userName = settings.userName; |
||||
userEmail = settings.userEmail; |
||||
theme = settings.theme; |
||||
defaultBranch = settings.defaultBranch; |
||||
settingsLoaded = true; |
||||
} catch (err) { |
||||
console.error('[SettingsPage] Failed to load settings:', err); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function loadPresets() { |
||||
// Get user's pubkey from store |
||||
const currentUser = $userStore; |
||||
if (!currentUser.userPubkeyHex) { |
||||
// User not logged in, no presets available |
||||
presetUserName = ''; |
||||
presetUserEmail = ''; |
||||
return; |
||||
} |
||||
|
||||
loadingPresets = true; |
||||
try { |
||||
// Fetch profile from kind 0 event (cache or relays) |
||||
const profileEvent = await fetchUserProfile(currentUser.userPubkeyHex, DEFAULT_NOSTR_RELAYS); |
||||
const profile = extractProfileData(profileEvent); |
||||
|
||||
// Get preset values using the same fallback logic as the commit functions |
||||
presetUserName = getUserName(profile, currentUser.userPubkeyHex, currentUser.userPubkey || undefined); |
||||
presetUserEmail = getUserEmail(profile, currentUser.userPubkeyHex, currentUser.userPubkey || undefined); |
||||
} catch (err) { |
||||
console.warn('Failed to load presets from profile:', err); |
||||
// Fallback to shortened npub values |
||||
if (currentUser.userPubkey) { |
||||
presetUserName = currentUser.userPubkey.substring(0, 20); |
||||
presetUserEmail = `${currentUser.userPubkey.substring(0, 20)}@gitrepublic.web`; |
||||
} else if (currentUser.userPubkeyHex) { |
||||
const { nip19 } = await import('nostr-tools'); |
||||
const npub = nip19.npubEncode(currentUser.userPubkeyHex); |
||||
presetUserName = npub.substring(0, 20); |
||||
presetUserEmail = `${npub.substring(0, 20)}@gitrepublic.web`; |
||||
} else { |
||||
presetUserName = ''; |
||||
presetUserEmail = ''; |
||||
} |
||||
} finally { |
||||
loadingPresets = false; |
||||
} |
||||
} |
||||
|
||||
async function saveSettings() { |
||||
saving = true; |
||||
try { |
||||
// Save empty string if user wants to use presets, otherwise save the custom value |
||||
await settingsStore.updateSettings({ |
||||
autoSave, |
||||
userName: userName.trim() || '', // Empty string means use preset |
||||
userEmail: userEmail.trim() || '', // Empty string means use preset |
||||
theme, |
||||
defaultBranch: defaultBranch.trim() || 'master' |
||||
}); |
||||
|
||||
// Apply theme immediately |
||||
applyTheme(theme); |
||||
|
||||
// Sync to localStorage for app.html flash prevention |
||||
if (typeof window !== 'undefined') { |
||||
localStorage.setItem('theme', theme); |
||||
// Dispatch event to notify layout of theme change |
||||
window.dispatchEvent(new CustomEvent('themeChanged', { |
||||
detail: { theme } |
||||
})); |
||||
} |
||||
|
||||
// Show success message and optionally navigate back |
||||
alert('Settings saved successfully!'); |
||||
} catch (err) { |
||||
console.error('Failed to save settings:', err); |
||||
alert('Failed to save settings. Please try again.'); |
||||
} finally { |
||||
saving = false; |
||||
} |
||||
} |
||||
|
||||
function applyTheme(newTheme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black') { |
||||
// Remove all theme attributes first |
||||
document.documentElement.removeAttribute('data-theme'); |
||||
document.documentElement.removeAttribute('data-theme-light'); |
||||
document.documentElement.removeAttribute('data-theme-black'); |
||||
|
||||
// Apply the selected theme |
||||
if (newTheme === 'gitrepublic-light') { |
||||
document.documentElement.setAttribute('data-theme', 'light'); |
||||
} else if (newTheme === 'gitrepublic-dark') { |
||||
document.documentElement.setAttribute('data-theme', 'dark'); |
||||
} else if (newTheme === 'gitrepublic-black') { |
||||
document.documentElement.setAttribute('data-theme', 'black'); |
||||
} |
||||
} |
||||
|
||||
function handleThemeChange(newTheme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black') { |
||||
theme = newTheme; |
||||
// Preview theme change immediately (don't save yet) |
||||
applyTheme(newTheme); |
||||
} |
||||
|
||||
function goBack() { |
||||
// Use browser history to go back, fallback to dashboard if no history |
||||
if (typeof window !== 'undefined' && window.history.length > 1) { |
||||
window.history.back(); |
||||
} else { |
||||
goto('/dashboard'); |
||||
} |
||||
} |
||||
|
||||
// Load settings and presets on mount |
||||
onMount(async () => { |
||||
// Redirect to /settings/general if no tab is specified |
||||
const tabParam = ($page.params as { tab?: string }).tab; |
||||
if (!tabParam) { |
||||
goto('/settings/general', { replaceState: true }); |
||||
} |
||||
|
||||
await loadSettings(); |
||||
await loadPresets(); |
||||
}); |
||||
|
||||
// Sync activeTab with URL param when it changes |
||||
$effect(() => { |
||||
const tab = getTabFromUrl(); |
||||
if (tab !== activeTab) { |
||||
activeTab = tab; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<div class="settings-page"> |
||||
<div class="settings-header"> |
||||
<h1>Settings</h1> |
||||
<button class="back-button" onclick={goBack} aria-label="Back"> |
||||
<span>← Back</span> |
||||
</button> |
||||
</div> |
||||
|
||||
{#if loading && !settingsLoaded} |
||||
<div class="loading">Loading settings...</div> |
||||
{:else} |
||||
<!-- Tabs --> |
||||
<div class="tabs"> |
||||
<button |
||||
class="tab-button" |
||||
class:active={activeTab === 'general'} |
||||
onclick={() => setActiveTab('general')} |
||||
> |
||||
General |
||||
</button> |
||||
<button |
||||
class="tab-button" |
||||
class:active={activeTab === 'git-setup'} |
||||
onclick={() => setActiveTab('git-setup')} |
||||
> |
||||
Git Setup |
||||
</button> |
||||
<button |
||||
class="tab-button" |
||||
class:active={activeTab === 'connections'} |
||||
onclick={() => setActiveTab('connections')} |
||||
> |
||||
Connections |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="settings-content"> |
||||
<!-- General Tab --> |
||||
{#if activeTab === 'general'} |
||||
<div class="setting-group"> |
||||
<div class="setting-label"> |
||||
<span class="label-text">Theme</span> |
||||
</div> |
||||
<div class="theme-options"> |
||||
<button |
||||
class="theme-option" |
||||
class:active={theme === 'gitrepublic-light'} |
||||
onclick={() => handleThemeChange('gitrepublic-light')} |
||||
> |
||||
<img src="/icons/sun.svg" alt="Light theme" class="theme-icon" /> |
||||
<span>Light</span> |
||||
</button> |
||||
<button |
||||
class="theme-option" |
||||
class:active={theme === 'gitrepublic-dark'} |
||||
onclick={() => handleThemeChange('gitrepublic-dark')} |
||||
> |
||||
<img src="/icons/palette.svg" alt="Purple theme" class="theme-icon" /> |
||||
<span>Purple</span> |
||||
</button> |
||||
<button |
||||
class="theme-option" |
||||
class:active={theme === 'gitrepublic-black'} |
||||
onclick={() => handleThemeChange('gitrepublic-black')} |
||||
> |
||||
<img src="/icons/moon.svg" alt="Black theme" class="theme-icon" /> |
||||
<span>Black</span> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- Git Setup Tab --> |
||||
{#if activeTab === 'git-setup'} |
||||
<!-- Auto-save Toggle --> |
||||
<div class="setting-group"> |
||||
<label class="setting-label"> |
||||
<span class="label-text">Auto-save</span> |
||||
<div class="toggle-container"> |
||||
<input |
||||
type="checkbox" |
||||
bind:checked={autoSave} |
||||
class="toggle-input" |
||||
id="auto-save-toggle" |
||||
/> |
||||
<label for="auto-save-toggle" class="toggle-label"> |
||||
<span class="toggle-slider"></span> |
||||
</label> |
||||
</div> |
||||
</label> |
||||
<p class="setting-description"> |
||||
When enabled, changes are automatically committed every 10 minutes if there are unsaved changes. |
||||
</p> |
||||
</div> |
||||
|
||||
<!-- User Name --> |
||||
<div class="setting-group"> |
||||
<label class="setting-label" for="user-name"> |
||||
<span class="label-text">Git User Name</span> |
||||
</label> |
||||
<input |
||||
type="text" |
||||
id="user-name" |
||||
bind:value={userName} |
||||
placeholder={presetUserName || 'Enter your git user.name'} |
||||
class="setting-input" |
||||
/> |
||||
{#if presetUserName} |
||||
<p class="setting-hint"> |
||||
{#if userName.trim()} |
||||
Custom value saved. Default would be: {presetUserName} |
||||
{:else} |
||||
Will use: <strong>{presetUserName}</strong> (from your Nostr profile: display_name → name → shortened npub) |
||||
{/if} |
||||
</p> |
||||
{/if} |
||||
<p class="setting-description"> |
||||
Your name as it will appear in git commits. Leave empty to use the preset value from your Nostr profile. |
||||
</p> |
||||
</div> |
||||
|
||||
<!-- User Email --> |
||||
<div class="setting-group"> |
||||
<label class="setting-label" for="user-email"> |
||||
<span class="label-text">Git User Email</span> |
||||
</label> |
||||
<input |
||||
type="email" |
||||
id="user-email" |
||||
bind:value={userEmail} |
||||
placeholder={presetUserEmail || 'Enter your git user.email'} |
||||
class="setting-input" |
||||
/> |
||||
{#if presetUserEmail} |
||||
<p class="setting-hint"> |
||||
{#if userEmail.trim()} |
||||
Custom value saved. Default would be: {presetUserEmail} |
||||
{:else} |
||||
Will use: <strong>{presetUserEmail}</strong> (from your Nostr profile: NIP-05 → shortenednpub@gitrepublic.web) |
||||
{/if} |
||||
</p> |
||||
{/if} |
||||
<p class="setting-description"> |
||||
Your email as it will appear in git commits. Leave empty to use the preset value from your Nostr profile. |
||||
</p> |
||||
</div> |
||||
|
||||
<!-- Default Branch --> |
||||
<div class="setting-group"> |
||||
<label class="setting-label" for="default-branch"> |
||||
<span class="label-text">Default Branch Name</span> |
||||
</label> |
||||
<input |
||||
type="text" |
||||
id="default-branch" |
||||
bind:value={defaultBranch} |
||||
placeholder="master" |
||||
class="setting-input" |
||||
/> |
||||
<p class="setting-description"> |
||||
Default branch name to use when creating new repositories. This will be used as the base branch when creating the first branch in a new repo. |
||||
</p> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- Connections Tab --> |
||||
{#if activeTab === 'connections'} |
||||
<div class="setting-group"> |
||||
<ForwardingConfig |
||||
userPubkeyHex={$userStore.userPubkeyHex} |
||||
showTitle={true} |
||||
compact={false} |
||||
/> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="settings-actions"> |
||||
<button onclick={saveSettings} class="save-button" disabled={saving}> |
||||
{saving ? 'Saving...' : 'Save Settings'} |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.settings-page { |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
min-height: calc(100vh - 4rem); |
||||
} |
||||
|
||||
.settings-header { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
margin-bottom: 2rem; |
||||
padding-bottom: 1rem; |
||||
border-bottom: 1px solid var(--border-color); |
||||
} |
||||
|
||||
.settings-header h1 { |
||||
margin: 0; |
||||
font-size: 2rem; |
||||
color: var(--text-primary); |
||||
} |
||||
|
||||
.back-button { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 0.5rem 1rem; |
||||
background: var(--bg-secondary); |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 0.375rem; |
||||
color: var(--text-primary); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
transition: all 0.2s ease; |
||||
} |
||||
|
||||
.back-button:hover { |
||||
background: var(--bg-tertiary); |
||||
border-color: var(--accent); |
||||
} |
||||
|
||||
|
||||
.tabs { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
border-bottom: 1px solid var(--border-color); |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.tab-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: none; |
||||
border: none; |
||||
border-bottom: 2px solid transparent; |
||||
cursor: pointer; |
||||
font-size: 1rem; |
||||
color: var(--text-secondary); |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.tab-button:hover { |
||||
color: var(--text-primary); |
||||
background: var(--bg-secondary); |
||||
} |
||||
|
||||
.tab-button.active { |
||||
color: var(--accent); |
||||
border-bottom-color: var(--accent); |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.settings-content { |
||||
padding: 1.5rem 0; |
||||
} |
||||
|
||||
.setting-group { |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.setting-group:last-child { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.setting-label { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
margin-bottom: 0.5rem; |
||||
font-weight: 600; |
||||
color: var(--text-primary); |
||||
} |
||||
|
||||
.label-text { |
||||
flex: 1; |
||||
} |
||||
|
||||
.toggle-container { |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.toggle-input { |
||||
display: none; |
||||
} |
||||
|
||||
.toggle-label { |
||||
position: relative; |
||||
width: 44px; |
||||
height: 24px; |
||||
background: var(--bg-tertiary); |
||||
border-radius: 12px; |
||||
cursor: pointer; |
||||
transition: background 0.2s ease; |
||||
} |
||||
|
||||
.toggle-input:checked + .toggle-label { |
||||
background: var(--accent); |
||||
} |
||||
|
||||
.toggle-slider { |
||||
position: absolute; |
||||
top: 2px; |
||||
left: 2px; |
||||
width: 20px; |
||||
height: 20px; |
||||
background: white; |
||||
border-radius: 50%; |
||||
transition: transform 0.2s ease; |
||||
} |
||||
|
||||
.toggle-input:checked + .toggle-label .toggle-slider { |
||||
transform: translateX(20px); |
||||
} |
||||
|
||||
.setting-input { |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
border: 1px solid var(--border-color); |
||||
border-radius: 0.375rem; |
||||
background: var(--bg-primary); |
||||
color: var(--text-primary); |
||||
font-size: 1rem; |
||||
margin-bottom: 0.5rem; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
.setting-input:focus { |
||||
outline: none; |
||||
border-color: var(--accent); |
||||
} |
||||
|
||||
.setting-hint { |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary); |
||||
margin: -0.25rem 0 0.5rem 0; |
||||
} |
||||
|
||||
.setting-description { |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary); |
||||
margin: 0.5rem 0 0 0; |
||||
} |
||||
|
||||
.theme-options { |
||||
display: flex; |
||||
gap: 0.75rem; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.theme-option { |
||||
flex: 1; |
||||
min-width: 120px; |
||||
padding: 1rem; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
border: 2px solid var(--border-color); |
||||
border-radius: 0.375rem; |
||||
background: var(--bg-primary); |
||||
color: var(--text-primary); |
||||
cursor: pointer; |
||||
transition: all 0.2s ease; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.theme-option:hover { |
||||
border-color: var(--accent); |
||||
background: var(--bg-secondary); |
||||
} |
||||
|
||||
.theme-option.active { |
||||
border-color: var(--accent); |
||||
background: var(--bg-tertiary); |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.theme-icon { |
||||
width: 24px; |
||||
height: 24px; |
||||
filter: brightness(0) saturate(100%) invert(1); |
||||
} |
||||
|
||||
:global([data-theme="light"]) .theme-icon { |
||||
filter: brightness(0) saturate(100%); |
||||
} |
||||
|
||||
.loading { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
color: var(--text-secondary); |
||||
} |
||||
|
||||
.settings-actions { |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
gap: 0.75rem; |
||||
padding: 2rem 0; |
||||
border-top: 1px solid var(--border-color); |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.save-button { |
||||
padding: 0.75rem 1.5rem; |
||||
border: 1px solid var(--accent); |
||||
border-radius: 0.375rem; |
||||
font-size: 1rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s ease; |
||||
background: var(--accent); |
||||
color: var(--accent-text, #ffffff); |
||||
} |
||||
|
||||
.save-button:hover:not(:disabled) { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.save-button:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue