Browse Source
Nostr-Signature: 862b888e52bf4fc3e53c80afd9f301b22ce674366f48d006bca520479394c0f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc c2e895f67ff5a68e87dcdc54a0312e169f4729a05a62f1ffbe92afd6e57b7d232b36ef4291c07969e531cdc4f22f5ac32723a2aecc57a0b613b945217ecc651amain
17 changed files with 2543 additions and 546 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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