Browse Source

update profile page, dashboard, and connections

Nostr-Signature: 862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651a
main
Silberengel 3 weeks ago
parent
commit
7a8aed117c
  1. 11
      README.md
  2. 163
      docs/NIP-A3.md
  3. 77
      docs/tutorial.md
  4. 1
      nostr/commit-signatures.jsonl
  5. 1
      src/app.html
  6. 16
      src/lib/components/SettingsButton.svelte
  7. 198
      src/lib/components/SettingsModal.svelte
  8. 16
      src/lib/services/nostr/nostr-client.ts
  9. 49
      src/lib/services/nostr/persistent-event-cache.ts
  10. 60
      src/routes/+layout.svelte
  11. 151
      src/routes/api/users/[npub]/profile/+server.ts
  12. 195
      src/routes/dashboard/+page.svelte
  13. 20
      src/routes/docs/nip-a3/+page.server.ts
  14. 241
      src/routes/docs/nip-a3/+page.svelte
  15. 4
      src/routes/repos/[npub]/[repo]/+page.svelte
  16. 618
      src/routes/settings/[[tab]]/+page.svelte
  17. 946
      src/routes/users/[npub]/+page.svelte

11
README.md

@ -89,7 +89,10 @@ All three interfaces use the same underlying Nostr-based authentication and repo
- **Tag Management**: Create and view git tags - **Tag Management**: Create and view git tags
- **README Rendering**: Automatic markdown rendering for README files - **README Rendering**: Automatic markdown rendering for README files
- **Search**: Search repositories by name, description, or author - **Search**: Search repositories by name, description, or author
- **User Profiles**: View user repositories and activity - **User Profiles**: View user repositories and activity with full profile event support
- Supports both old JSON format (in content) and new tags format
- Displays payment targets (NIP-A3 kind 10133) with payto:// URIs
- Merges and deduplicates lightning addresses from NIP-01 (lud16) and kind 10133
- **Raw File View**: Direct access to raw file content - **Raw File View**: Direct access to raw file content
- **Download Repository**: Download repositories as ZIP archives - **Download Repository**: Download repositories as ZIP archives
- **OpenGraph Metadata**: Rich social media previews with repository images and banners - **OpenGraph Metadata**: Rich social media previews with repository images and banners
@ -265,6 +268,11 @@ This project uses the following Nostr event kinds. For complete JSON examples an
- **10002**: Relay list metadata (NIP-65, for relay discovery) - **10002**: Relay list metadata (NIP-65, for relay discovery)
- **24**: Public message (NIP-24, for relay write proof) - **24**: Public message (NIP-24, for relay write proof)
- **5**: Event deletion request (NIP-09) - **5**: Event deletion request (NIP-09)
- **10133**: Payment targets (NIP-A3, payto:// URI scheme)
- Replaceable event kind for payment and tip invocations
- Tags: `payto` (type, authority, optional extras)
- Supports lightning, bitcoin, nano, monero, ethereum, and other payment types
- See [docs/NIP-A3.md](./docs/NIP-A3.md) for complete documentation
### Custom Event Kinds ### Custom Event Kinds
@ -519,6 +527,7 @@ src/
- [Architecture FAQ](./docs/ARCHITECTURE_FAQ.md) - Answers to common architecture questions - [Architecture FAQ](./docs/ARCHITECTURE_FAQ.md) - Answers to common architecture questions
- [NIP Compliance](./docs/NIP_COMPLIANCE.md) - Complete event kind reference with JSON examples - [NIP Compliance](./docs/NIP_COMPLIANCE.md) - Complete event kind reference with JSON examples
- [NIP-A3: Payment Targets](./docs/NIP-A3.md) - Payment targets (payto://) support and documentation
- [Security Documentation](./docs/SECURITY.md) - Security features and considerations - [Security Documentation](./docs/SECURITY.md) - Security features and considerations
- [CLI Documentation](./gitrepublic-cli/README.md) - Complete CLI usage guide - [CLI Documentation](./gitrepublic-cli/README.md) - Complete CLI usage guide
- [Enterprise Mode](./k8s/ENTERPRISE_MODE.md) - Kubernetes deployment guide - [Enterprise Mode](./k8s/ENTERPRISE_MODE.md) - Kubernetes deployment guide

163
docs/NIP-A3.md

@ -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

77
docs/tutorial.md

@ -728,6 +728,83 @@ git commit -m "Add user authentication with NIP-07 support
--- ---
## User Profiles and Payment Targets
GitRepublic displays full user profiles with support for payment targets using NIP-A3 (kind 10133).
### Viewing User Profiles
1. Navigate to a user's profile page: `/users/{npub}`
2. View their repositories, profile information, and payment targets
3. Profiles support both old JSON format (in content) and new tags format
### Payment Targets (NIP-A3)
Payment targets allow users to specify how they want to receive payments using the `payto://` URI scheme (RFC-8905).
#### Supported Payment Types
- **Lightning**: Lightning Network addresses (e.g., `user@wallet.example.com`)
- **Bitcoin**: Bitcoin addresses
- **Ethereum**: Ethereum addresses
- **Nano**: Nano addresses
- **Monero**: Monero addresses
- And more (see [NIP-A3 documentation](./NIP-A3.md))
#### How Payment Targets Work
GitRepublic merges payment information 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 addresses (lowercase) for deduplication
- Merges lightning addresses from both sources
- Displays all payment targets with `payto://` URIs
- Provides copy buttons for easy sharing
#### Creating Payment Target Events
To add payment targets to your profile, publish a kind 10133 event:
```json
{
"kind": 10133,
"content": "",
"tags": [
["payto", "lightning", "user@wallet.example.com"],
["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"]
],
"created_at": 1234567890
}
```
#### API Access
Fetch user profiles with payment targets via the API:
```bash
GET /api/users/{npub}/profile
```
Response includes:
- Full profile event (kind 0)
- Payment targets array with `payto://` URIs
- Payment event (kind 10133) if available
#### CLI Access
The GitRepublic CLI automatically fetches payment targets when fetching profiles:
```bash
gitrep profile fetch npub1...
```
See [NIP-A3 documentation](./NIP-A3.md) for complete details.
---
## Advanced Topics ## Advanced Topics
### NIP-34 Specification ### NIP-34 Specification

1
nostr/commit-signatures.jsonl

@ -34,3 +34,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771624450,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and doc updates"]],"content":"Signed commit: bug-fixes and doc updates","id":"d089915a2d9a9d46ba25d2d3c1cb4608a2b658ecc4260f17e73efa4ccc63a28d","sig":"3d447f05a55704d45ed843b7cc5fa16e49f3da0e452b1523392aefbb7a2ae3e79400a763df5705db8e38abc89e9a89480ab2c529890b531b171c4e980520d9b8"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771624450,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and doc updates"]],"content":"Signed commit: bug-fixes and doc updates","id":"d089915a2d9a9d46ba25d2d3c1cb4608a2b658ecc4260f17e73efa4ccc63a28d","sig":"3d447f05a55704d45ed843b7cc5fa16e49f3da0e452b1523392aefbb7a2ae3e79400a763df5705db8e38abc89e9a89480ab2c529890b531b171c4e980520d9b8"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771625218,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"1cc16c438c4b1cc5170a90a7e4b540afa24d0c698538dc332fa4753437b21dfe","sig":"3caddc0d00e29995f4920bd4035ea61b4fd2d17e366bdd18889ede38a5ea960cd9f83a9f524b777b8de7bf7e4cdf59ab55c8fb4e46932655985ba8c6f3d7e7da"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771625218,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"1cc16c438c4b1cc5170a90a7e4b540afa24d0c698538dc332fa4753437b21dfe","sig":"3caddc0d00e29995f4920bd4035ea61b4fd2d17e366bdd18889ede38a5ea960cd9f83a9f524b777b8de7bf7e4cdf59ab55c8fb4e46932655985ba8c6f3d7e7da"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771626015,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f5bde3d9199d8cbacca481959663f1e14c43e143ef2b5686502559408e1c526b","sig":"3ed47cd283746d290d8609cbfdefbcee31a19d8e43e1a6ebf5a2829904000d79b83d3235296af4b5f7b555051214fbf2fa5c7a6d7986dca853112bb4e122a6d5"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771626015,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f5bde3d9199d8cbacca481959663f1e14c43e143ef2b5686502559408e1c526b","sig":"3ed47cd283746d290d8609cbfdefbcee31a19d8e43e1a6ebf5a2829904000d79b83d3235296af4b5f7b555051214fbf2fa5c7a6d7986dca853112bb4e122a6d5"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771627873,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"5726811907af73d3b478f3938cdc6421200040542cb1a586b3497c56a24c33cb","sig":"3833d05ba5a34cad78caacbc8382fcd7a85c60b56dd3b18f9a5c68c890d7a611fa6b885ef02be465f541629b0afaeec0e9d57d3b00db332c5c8ae42fd72fc83d"}

1
src/app.html

@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script> <script>
// Apply theme immediately to prevent flash // Apply theme immediately to prevent flash
// Use localStorage as quick fallback, layout will load proper theme from IndexedDB
(function() { (function() {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'gitrepublic-light') { if (savedTheme === 'gitrepublic-light') {

16
src/lib/components/SettingsButton.svelte

@ -1,20 +1,26 @@
<script lang="ts"> <script lang="ts">
import SettingsModal from './SettingsModal.svelte'; import { goto } from '$app/navigation';
let showSettings = $state(false); interface Props {
initialTab?: 'general' | 'git-setup' | 'connections';
}
let { initialTab = 'general' }: Props = $props();
function openSettings() {
goto(`/settings/${initialTab}`);
}
</script> </script>
<button <button
class="settings-button" class="settings-button"
onclick={() => showSettings = true} onclick={openSettings}
title="Settings" title="Settings"
aria-label="Settings" aria-label="Settings"
> >
<img src="/icons/settings.svg" alt="Settings" class="settings-icon" /> <img src="/icons/settings.svg" alt="Settings" class="settings-icon" />
</button> </button>
<SettingsModal isOpen={showSettings} onClose={() => showSettings = false} />
<style> <style>
.settings-button { .settings-button {
cursor: pointer; cursor: pointer;

198
src/lib/components/SettingsModal.svelte

@ -4,43 +4,46 @@
import { userStore } from '../stores/user-store.js'; import { userStore } from '../stores/user-store.js';
import { fetchUserEmail, fetchUserName, fetchUserProfile, extractProfileData, getUserName, getUserEmail } from '../utils/user-profile.js'; import { fetchUserEmail, fetchUserName, fetchUserProfile, extractProfileData, getUserName, getUserEmail } from '../utils/user-profile.js';
import { DEFAULT_NOSTR_RELAYS } from '../config.js'; import { DEFAULT_NOSTR_RELAYS } from '../config.js';
import ForwardingConfig from './ForwardingConfig.svelte';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
initialTab?: 'general' | 'git-setup' | 'connections';
} }
let { isOpen, onClose }: Props = $props(); let { isOpen, onClose, initialTab = 'general' }: Props = $props();
let activeTab = $state<'general' | 'git-setup' | 'connections'>('general');
let autoSave = $state(false); let autoSave = $state(false);
let userName = $state(''); let userName = $state('');
let userEmail = $state(''); let userEmail = $state('');
let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark'); let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark');
let defaultBranch = $state('master'); let defaultBranch = $state('master');
let loading = $state(true); let loading = $state(false);
let saving = $state(false); let saving = $state(false);
let loadingPresets = $state(false); let loadingPresets = $state(false);
let settingsLoaded = $state(false);
// Preset values that will be used if user doesn't override // Preset values that will be used if user doesn't override
let presetUserName = $state(''); let presetUserName = $state('');
let presetUserEmail = $state(''); let presetUserEmail = $state('');
onMount(async () => {
await loadSettings();
await loadPresets();
});
async function loadSettings() { async function loadSettings() {
if (settingsLoaded) return; // Don't reload if already loaded
loading = true; loading = true;
try { try {
console.log('[SettingsModal] Loading settings from store...');
const settings = await settingsStore.getSettings(); const settings = await settingsStore.getSettings();
console.log('[SettingsModal] Settings loaded:', settings);
autoSave = settings.autoSave; autoSave = settings.autoSave;
userName = settings.userName; userName = settings.userName;
userEmail = settings.userEmail; userEmail = settings.userEmail;
theme = settings.theme; theme = settings.theme;
defaultBranch = settings.defaultBranch; defaultBranch = settings.defaultBranch;
settingsLoaded = true;
} catch (err) { } catch (err) {
console.error('Failed to load settings:', err); console.error('[SettingsModal] Failed to load settings:', err);
} finally { } finally {
loading = false; loading = false;
} }
@ -100,6 +103,15 @@
// Apply theme immediately // Apply theme immediately
applyTheme(theme); 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 }
}));
}
onClose(); onClose();
} catch (err) { } catch (err) {
console.error('Failed to save settings:', err); console.error('Failed to save settings:', err);
@ -131,22 +143,50 @@
applyTheme(newTheme); applyTheme(newTheme);
} }
// Watch for modal open/close // Watch for modal open/close and initialTab changes
$effect(() => { $effect(() => {
console.log('[SettingsModal] $effect triggered, isOpen:', isOpen);
if (isOpen) { if (isOpen) {
loadSettings(); console.log('[SettingsModal] Modal is open, loading settings and presets');
loadPresets(); // Set active tab when modal opens (use initialTab prop if provided)
if (initialTab) {
activeTab = initialTab;
}
// Load settings and presets when modal opens
loadSettings().catch(err => console.error('[SettingsModal] Error loading settings:', err));
loadPresets().catch(err => console.error('[SettingsModal] Error loading presets:', err));
} else {
console.log('[SettingsModal] Modal is closed');
// Reset settings loaded flag when modal closes so it reloads next time
settingsLoaded = false;
}
});
// Also watch for initialTab prop changes when modal is open
$effect(() => {
if (isOpen && initialTab) {
activeTab = initialTab;
} }
}); });
</script> </script>
{#if isOpen} {#if isOpen}
<!-- Settings Modal: isOpen={isOpen} -->
<div <div
class="modal-overlay" class="modal-overlay"
role="button" role="button"
tabindex="0" tabindex="0"
onclick={(e) => e.target === e.currentTarget && onClose()} onclick={(e) => {
onkeydown={(e) => e.key === 'Escape' && onClose()} console.log('[SettingsModal] Overlay clicked, target:', e.target, 'currentTarget:', e.currentTarget, 'isOpen:', isOpen);
if (e.target === e.currentTarget) {
onClose();
}
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
> >
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -156,10 +196,72 @@
</button> </button>
</div> </div>
{#if loading} {#if loading && !settingsLoaded}
<div class="loading">Loading settings...</div> <div class="loading">Loading settings...</div>
{:else} {:else}
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'general'}
onclick={() => activeTab = 'general'}
>
General
</button>
<button
class="tab-button"
class:active={activeTab === 'git-setup'}
onclick={() => activeTab = 'git-setup'}
>
Git Setup
</button>
<button
class="tab-button"
class:active={activeTab === 'connections'}
onclick={() => activeTab = 'connections'}
>
Connections
</button>
</div>
<div class="modal-body"> <div class="modal-body">
<!-- 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 --> <!-- Auto-save Toggle -->
<div class="setting-group"> <div class="setting-group">
<label class="setting-label"> <label class="setting-label">
@ -249,39 +351,18 @@
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. 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> </p>
</div> </div>
{/if}
<!-- Theme Selector --> <!-- Connections Tab -->
{#if activeTab === 'connections'}
<div class="setting-group"> <div class="setting-group">
<div class="setting-label"> <ForwardingConfig
<span class="label-text">Theme</span> userPubkeyHex={$userStore.userPubkeyHex}
</div> showTitle={true}
<div class="theme-options"> compact={false}
<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>
{/if}
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -361,6 +442,35 @@
filter: brightness(0) saturate(100%); filter: brightness(0) saturate(100%);
} }
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border-color);
padding: 0 1.5rem;
}
.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;
}
.modal-body { .modal-body {
padding: 1.5rem; padding: 1.5rem;
} }

16
src/lib/services/nostr/nostr-client.ts

@ -35,6 +35,10 @@ function getDeduplicationKey(event: NostrEvent): string {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
return `${event.kind}:${event.pubkey}:${dTag}`; return `${event.kind}:${event.pubkey}:${dTag}`;
} }
// Special handling for gitrepublic-write-proof kind 24 events - treat as replaceable
if (event.kind === KIND.PUBLIC_MESSAGE && event.content && event.content.includes('gitrepublic-write-proof')) {
return `24:${event.pubkey}:write-proof`;
}
return event.id; return event.id;
} }
@ -850,7 +854,11 @@ export class NostrClient {
ws.onerror = () => { ws.onerror = () => {
conn.messageHandlers.delete(subscriptionId); conn.messageHandlers.delete(subscriptionId);
if (originalOnError) { if (originalOnError) {
originalOnError.call(wsRef, new Event('error')); // Create an Event-like object for Node.js compatibility
const errorEvent = typeof Event !== 'undefined'
? new Event('error')
: ({ type: 'error', target: wsRef } as unknown as Event);
originalOnError.call(wsRef, errorEvent);
} }
if (!resolved) { if (!resolved) {
resolveOnce([]); resolveOnce([]);
@ -861,7 +869,11 @@ export class NostrClient {
ws.onclose = () => { ws.onclose = () => {
conn.messageHandlers.delete(subscriptionId); conn.messageHandlers.delete(subscriptionId);
if (originalOnClose) { if (originalOnClose) {
originalOnClose.call(wsRef, new CloseEvent('close')); // Create a CloseEvent-like object for Node.js compatibility
const closeEvent = typeof CloseEvent !== 'undefined'
? new CloseEvent('close')
: ({ type: 'close', code: 1000, reason: '', wasClean: true } as unknown as CloseEvent);
originalOnClose.call(wsRef, closeEvent);
} }
// If we haven't resolved yet, resolve with what we have // If we haven't resolved yet, resolve with what we have
if (!resolved) { if (!resolved) {

49
src/lib/services/nostr/persistent-event-cache.ts

@ -194,16 +194,59 @@ export class PersistentEventCache {
// Store events (only latest for replaceable kinds) // Store events (only latest for replaceable kinds)
const latestByKey = new Map<string, NostrEvent>(); const latestByKey = new Map<string, NostrEvent>();
const writeProofPubkeys = new Set<string>(); // Track pubkeys with write-proof events
for (const event of events) { for (const event of events) {
const key = REPLACEABLE_KINDS.includes(event.kind) // Special handling for gitrepublic-write-proof kind 24 events - treat as replaceable
? `${event.kind}:${event.pubkey}` let key: string;
: event.id; if (REPLACEABLE_KINDS.includes(event.kind)) {
key = `${event.kind}:${event.pubkey}`;
} else if (event.kind === KIND.PUBLIC_MESSAGE && event.content && event.content.includes('gitrepublic-write-proof')) {
key = `24:${event.pubkey}:write-proof`;
writeProofPubkeys.add(event.pubkey);
} else {
key = event.id;
}
const existing = latestByKey.get(key); const existing = latestByKey.get(key);
if (!existing || event.created_at > existing.created_at) { if (!existing || event.created_at > existing.created_at) {
latestByKey.set(key, event); latestByKey.set(key, event);
} }
} }
// Clean up old write-proof events for pubkeys that have new ones
if (writeProofPubkeys.size > 0) {
for (const pubkey of writeProofPubkeys) {
const key = `24:${pubkey}:write-proof`;
const newestEvent = latestByKey.get(key);
if (newestEvent) {
// Find and delete all older write-proof events for this pubkey
const pubkeyIndex = eventStore.index('pubkey');
const cursor = pubkeyIndex.openCursor(IDBKeyRange.only(pubkey));
await new Promise<void>((resolve) => {
cursor.onsuccess = (e) => {
const c = (e.target as IDBRequest<IDBCursorWithValue>).result;
if (c) {
const cached = c.value as CachedEvent;
const evt = cached.event;
// Delete if it's an old write-proof event (not the newest one)
if (evt.kind === KIND.PUBLIC_MESSAGE &&
evt.content &&
evt.content.includes('gitrepublic-write-proof') &&
evt.id !== newestEvent.id &&
evt.created_at < newestEvent.created_at) {
c.delete();
}
c.continue();
} else {
resolve();
}
};
cursor.onerror = () => resolve();
});
}
}
}
for (const event of latestByKey.values()) { for (const event of latestByKey.values()) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const req = eventStore.put({ id: event.id, event, cachedAt: now }); const req = eventStore.put({ id: event.id, event, cachedAt: now });

60
src/routes/+layout.svelte

@ -17,7 +17,7 @@
let { children }: { children: Snippet } = $props(); let { children }: { children: Snippet } = $props();
// Theme management - default to gitrepublic-dark (purple) // Theme management - default to gitrepublic-dark (purple)
let theme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' = 'gitrepublic-dark'; let theme = $state<'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black'>('gitrepublic-dark');
// User level checking state // User level checking state
let checkingUserLevel = $state(false); let checkingUserLevel = $state(false);
@ -37,27 +37,38 @@
let pendingTransfers = $state<PendingTransfer[]>([]); let pendingTransfers = $state<PendingTransfer[]>([]);
let dismissedTransfers = $state<Set<string>>(new Set()); let dismissedTransfers = $state<Set<string>>(new Set());
onMount(async () => { // Load theme on mount and watch for changes
onMount(() => {
// Only run client-side code // Only run client-side code
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// Load theme from settings store // Load theme from settings store (async)
(async () => {
try { try {
const settings = await settingsStore.getSettings(); const settings = await settingsStore.getSettings();
theme = settings.theme; theme = settings.theme;
themeLoaded = true;
applyTheme(theme);
// Also sync to localStorage for app.html flash prevention
localStorage.setItem('theme', theme);
} catch (err) { } catch (err) {
console.warn('Failed to load theme from settings, using default:', err); console.warn('Failed to load theme from settings, using default:', err);
// Fallback to localStorage for migration // Fallback to localStorage for migration
const savedTheme = localStorage.getItem('theme') as 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' | null; const savedTheme = localStorage.getItem('theme') as 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' | null;
if (savedTheme === 'gitrepublic-light' || savedTheme === 'gitrepublic-dark' || savedTheme === 'gitrepublic-black') { if (savedTheme === 'gitrepublic-light' || savedTheme === 'gitrepublic-dark' || savedTheme === 'gitrepublic-black') {
theme = savedTheme; theme = savedTheme;
themeLoaded = true;
applyTheme(theme);
// Migrate to settings store // Migrate to settings store
settingsStore.setSetting('theme', theme).catch(console.error); settingsStore.setSetting('theme', theme).catch(console.error);
} else { } else {
theme = 'gitrepublic-dark'; theme = 'gitrepublic-dark';
themeLoaded = true;
applyTheme(theme);
localStorage.setItem('theme', theme);
} }
} }
applyTheme(); })();
// Update activity on mount (if user is logged in) // Update activity on mount (if user is logged in)
// Session expiry is handled by user store initialization and NavBar // Session expiry is handled by user store initialization and NavBar
@ -89,14 +100,28 @@
window.addEventListener('pendingTransfers', handlePendingTransfersEvent); window.addEventListener('pendingTransfers', handlePendingTransfersEvent);
// Listen for theme changes from SettingsModal
const handleThemeChanged = (event: Event) => {
const customEvent = event as CustomEvent<{ theme: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black' }>;
if (customEvent.detail?.theme) {
theme = customEvent.detail.theme;
// Sync to localStorage for app.html flash prevention
localStorage.setItem('theme', theme);
// Theme will be applied via $effect
}
};
window.addEventListener('themeChanged', handleThemeChanged);
// Session expiry checking is handled by: // Session expiry checking is handled by:
// 1. User store initialization (checks on load) // 1. User store initialization (checks on load)
// 2. NavBar component (checks on mount and periodically) // 2. NavBar component (checks on mount and periodically)
// 3. Splash page (+page.svelte) (checks on mount) // 3. Splash page (+page.svelte) (checks on mount)
// No need for redundant checks here // No need for redundant checks here
// Return cleanup function
return () => { return () => {
window.removeEventListener('pendingTransfers', handlePendingTransfersEvent); window.removeEventListener('pendingTransfers', handlePendingTransfersEvent);
window.removeEventListener('themeChanged', handleThemeChanged);
}; };
}); });
@ -180,23 +205,38 @@
pendingTransfers = pendingTransfers.filter(t => t.eventId !== eventId); pendingTransfers = pendingTransfers.filter(t => t.eventId !== eventId);
} }
function applyTheme() { function applyTheme(newTheme?: 'gitrepublic-light' | 'gitrepublic-dark' | 'gitrepublic-black') {
const themeToApply = newTheme || theme;
// Only run client-side
if (typeof window === 'undefined') return;
// Remove all theme attributes first // Remove all theme attributes first
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
document.documentElement.removeAttribute('data-theme-light'); document.documentElement.removeAttribute('data-theme-light');
document.documentElement.removeAttribute('data-theme-black'); document.documentElement.removeAttribute('data-theme-black');
// Apply the selected theme // Apply the selected theme
if (theme === 'gitrepublic-light') { if (themeToApply === 'gitrepublic-light') {
document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.setAttribute('data-theme', 'light');
} else if (theme === 'gitrepublic-dark') { } else if (themeToApply === 'gitrepublic-dark') {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'gitrepublic-black') { } else if (themeToApply === 'gitrepublic-black') {
document.documentElement.setAttribute('data-theme', 'black'); document.documentElement.setAttribute('data-theme', 'black');
} }
// Save to settings store (async, don't await) // Save to settings store (async, don't await)
settingsStore.setSetting('theme', theme).catch(console.error); if (newTheme) {
settingsStore.setSetting('theme', themeToApply).catch(console.error);
}
}
// Watch for theme changes and apply them (but only after initial load)
let themeLoaded = $state(false);
$effect(() => {
if (typeof window !== 'undefined' && themeLoaded) {
applyTheme(theme);
} }
});
function toggleTheme() { function toggleTheme() {
// Cycle through themes: gitrepublic-dark -> gitrepublic-light -> gitrepublic-black -> gitrepublic-dark // Cycle through themes: gitrepublic-dark -> gitrepublic-light -> gitrepublic-black -> gitrepublic-dark
@ -207,7 +247,7 @@
} else { } else {
theme = 'gitrepublic-dark'; theme = 'gitrepublic-dark';
} }
applyTheme(); // Theme change will be applied via $effect
} }
// Provide theme context to child components // Provide theme context to child components

151
src/routes/api/users/[npub]/profile/+server.ts

@ -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');
}
};

195
src/routes/dashboard/+page.svelte

@ -4,6 +4,10 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js'; import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js';
import { userStore } from '$lib/stores/user-store.js'; import { userStore } from '$lib/stores/user-store.js';
import { fetchUserProfile } from '$lib/utils/user-profile.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { goto } from '$app/navigation';
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -21,6 +25,9 @@
let issues = $state<ExternalIssue[]>([]); let issues = $state<ExternalIssue[]>([]);
let pullRequests = $state<ExternalPullRequest[]>([]); let pullRequests = $state<ExternalPullRequest[]>([]);
let activeTab = $state<'issues' | 'prs' | 'all'>('all'); let activeTab = $state<'issues' | 'prs' | 'all'>('all');
let profileEvent = $state<NostrEvent | null>(null);
let profileData = $state<any>(null);
let profileTags = $state<Array<{ name: string; value: string }>>([]);
const PLATFORM_NAMES: Record<string, string> = { const PLATFORM_NAMES: Record<string, string> = {
github: 'GitHub', github: 'GitHub',
@ -35,7 +42,10 @@
onMount(async () => { onMount(async () => {
await loadUserPubkey(); await loadUserPubkey();
if (userPubkeyHex) { if (userPubkeyHex) {
await loadDashboard(); await Promise.all([
loadDashboard(),
loadProfile()
]);
} else { } else {
loading = false; loading = false;
error = 'Please connect your NIP-07 extension to view the dashboard'; error = 'Please connect your NIP-07 extension to view the dashboard';
@ -101,6 +111,34 @@
} }
} }
async function loadProfile() {
if (!userPubkeyHex) return;
try {
// Fetch user profile (kind 0) - check cache first
profileEvent = await fetchUserProfile(userPubkeyHex, DEFAULT_NOSTR_RELAYS);
if (profileEvent) {
// Try to parse JSON content
try {
profileData = JSON.parse(profileEvent.content);
} catch {
profileData = null;
}
// Store all tags
profileTags = profileEvent.tags
.filter(t => t.length > 0 && t[0])
.map(t => ({
name: t[0],
value: t.slice(1).join(', ')
}));
}
} catch (err) {
console.warn('Failed to load profile:', err);
}
}
function getPlatformName(platform: string): string { function getPlatformName(platform: string): string {
return PLATFORM_NAMES[platform] || platform; return PLATFORM_NAMES[platform] || platform;
} }
@ -188,7 +226,16 @@
<h2>No items found</h2> <h2>No items found</h2>
<p>Configure git platform forwarding in your messaging preferences to see issues and pull requests here.</p> <p>Configure git platform forwarding in your messaging preferences to see issues and pull requests here.</p>
{#if userPubkeyHex} {#if userPubkeyHex}
<p>Go to your <a href="/users/{nip19.npubEncode(userPubkeyHex)}">profile</a> to configure platforms.</p> <p>
<button
type="button"
onclick={() => goto('/settings/connections')}
class="settings-link-button"
>
Open Settings → Connections
</button>
to configure platforms.
</p>
{/if} {/if}
</div> </div>
{:else} {:else}
@ -296,6 +343,51 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Full Profile Event Display -->
{#if profileEvent && !loading}
<section class="profile-event-section">
<h2>Your Profile Event (Kind 0)</h2>
<!-- Profile Data from JSON (if available) -->
{#if profileData}
<div class="profile-data-section">
<h3>Profile Data (JSON)</h3>
<pre class="profile-json"><code>{JSON.stringify(profileData, null, 2)}</code></pre>
</div>
{/if}
<!-- Profile Tags -->
{#if profileTags.length > 0}
<div class="profile-tags-section">
<h3>Profile Tags</h3>
<div class="tags-list">
{#each profileTags as tag}
<div class="tag-item">
<span class="tag-name">{tag.name}</span>
<span class="tag-value">{tag.value}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Full Event JSON -->
<div class="profile-event-json">
<h3>Full Event JSON</h3>
<pre class="event-json"><code>{JSON.stringify({
id: profileEvent.id,
pubkey: profileEvent.pubkey,
created_at: profileEvent.created_at,
kind: profileEvent.kind,
tags: profileEvent.tags,
content: profileEvent.content,
sig: profileEvent.sig
}, null, 2)}</code></pre>
</div>
</section>
{/if}
</div> </div>
<div class="item-actions"> <div class="item-actions">
@ -624,12 +716,103 @@
color: var(--text-primary); color: var(--text-primary);
} }
.empty-state a { .settings-link-button {
color: var(--accent); background: var(--accent);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
text-decoration: none; text-decoration: none;
display: inline-block;
} }
.empty-state a:hover { .settings-link-button:hover {
text-decoration: underline; background: var(--accent-dark);
}
.profile-event-section {
margin-top: 3rem;
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.profile-event-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.profile-event-section h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.profile-data-section,
.profile-tags-section,
.profile-event-json {
margin-bottom: 2rem;
}
.profile-data-section:last-child,
.profile-tags-section:last-child,
.profile-event-json:last-child {
margin-bottom: 0;
}
.profile-json,
.event-json {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1rem;
overflow-x: auto;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-secondary);
}
.profile-json code,
.event-json code {
color: var(--text-primary);
white-space: pre;
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.tag-name {
font-weight: 600;
color: var(--text-primary);
min-width: 120px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9rem;
}
.tag-value {
flex: 1;
color: var(--text-secondary);
word-break: break-all;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9rem;
} }
</style> </style>

20
src/routes/docs/nip-a3/+page.server.ts

@ -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' };
}
};

241
src/routes/docs/nip-a3/+page.svelte

@ -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>

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

@ -5,7 +5,6 @@
import CodeEditor from '$lib/components/CodeEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte';
import PRDetail from '$lib/components/PRDetail.svelte'; import PRDetail from '$lib/components/PRDetail.svelte';
import UserBadge from '$lib/components/UserBadge.svelte'; import UserBadge from '$lib/components/UserBadge.svelte';
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import EventCopyButton from '$lib/components/EventCopyButton.svelte'; import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
@ -3528,9 +3527,6 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if pageData.repoOwnerPubkey && userPubkey === pageData.repoOwnerPubkey}
<ForwardingConfig userPubkeyHex={pageData.repoOwnerPubkey} />
{/if}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">

618
src/routes/settings/[[tab]]/+page.svelte

@ -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>

946
src/routes/users/[npub]/+page.svelte

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save