26 changed files with 2880 additions and 82 deletions
@ -0,0 +1,189 @@ |
|||||||
|
/** |
||||||
|
* Test text contrast ratios for WCAG AA compliance |
||||||
|
* WCAG AA requires: |
||||||
|
* - 4.5:1 for normal text |
||||||
|
* - 3:1 for large text (18pt+ or 14pt+ bold) and UI components |
||||||
|
*/ |
||||||
|
|
||||||
|
// Color definitions from tailwind.config.js
|
||||||
|
const colors = { |
||||||
|
light: { |
||||||
|
bg: '#f1f5f9', |
||||||
|
surface: '#f8fafc', |
||||||
|
post: '#ffffff', |
||||||
|
border: '#cbd5e1', |
||||||
|
text: '#475569', |
||||||
|
'text-light': '#64748b', |
||||||
|
accent: '#94a3b8', |
||||||
|
highlight: '#e2e8f0' |
||||||
|
}, |
||||||
|
dark: { |
||||||
|
bg: '#0f172a', |
||||||
|
surface: '#1e293b', |
||||||
|
post: '#334155', |
||||||
|
border: '#475569', |
||||||
|
text: '#cbd5e1', |
||||||
|
'text-light': '#94a3b8', |
||||||
|
accent: '#64748b', |
||||||
|
highlight: '#475569' |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert hex to RGB |
||||||
|
*/ |
||||||
|
function hexToRgb(hex) { |
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); |
||||||
|
return result ? { |
||||||
|
r: parseInt(result[1], 16), |
||||||
|
g: parseInt(result[2], 16), |
||||||
|
b: parseInt(result[3], 16) |
||||||
|
} : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate relative luminance |
||||||
|
*/ |
||||||
|
function getLuminance(rgb) { |
||||||
|
const [r, g, b] = [rgb.r / 255, rgb.g / 255, rgb.b / 255].map(val => { |
||||||
|
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4); |
||||||
|
}); |
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate contrast ratio |
||||||
|
*/ |
||||||
|
function getContrastRatio(color1, color2) { |
||||||
|
const rgb1 = hexToRgb(color1); |
||||||
|
const rgb2 = hexToRgb(color2); |
||||||
|
|
||||||
|
if (!rgb1 || !rgb2) return 0; |
||||||
|
|
||||||
|
const lum1 = getLuminance(rgb1); |
||||||
|
const lum2 = getLuminance(rgb2); |
||||||
|
|
||||||
|
const lighter = Math.max(lum1, lum2); |
||||||
|
const darker = Math.min(lum1, lum2); |
||||||
|
|
||||||
|
return (lighter + 0.05) / (darker + 0.05); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if contrast meets WCAG AA standards |
||||||
|
*/ |
||||||
|
function checkContrast(ratio, isLargeText = false) { |
||||||
|
if (isLargeText) { |
||||||
|
return ratio >= 3.0; // Large text or UI components
|
||||||
|
} |
||||||
|
return ratio >= 4.5; // Normal text
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Format ratio with pass/fail indicator |
||||||
|
*/ |
||||||
|
function formatResult(ratio, isLargeText = false) { |
||||||
|
const passes = checkContrast(ratio, isLargeText); |
||||||
|
const standard = isLargeText ? '3.0:1' : '4.5:1'; |
||||||
|
const status = passes ? '✓ PASS' : '✗ FAIL'; |
||||||
|
return `${ratio.toFixed(2)}:1 (${status} - requires ${standard})`; |
||||||
|
} |
||||||
|
|
||||||
|
console.log('='.repeat(80)); |
||||||
|
console.log('WCAG AA CONTRAST RATIO TEST'); |
||||||
|
console.log('='.repeat(80)); |
||||||
|
console.log(''); |
||||||
|
|
||||||
|
// Test Light Mode
|
||||||
|
console.log('LIGHT MODE:'); |
||||||
|
console.log('-'.repeat(80)); |
||||||
|
|
||||||
|
const lightTests = [ |
||||||
|
{ name: 'Text on background', fg: colors.light.text, bg: colors.light.bg }, |
||||||
|
{ name: 'Text-light on background', fg: colors.light['text-light'], bg: colors.light.bg }, |
||||||
|
{ name: 'Text on post', fg: colors.light.text, bg: colors.light.post }, |
||||||
|
{ name: 'Text-light on post', fg: colors.light['text-light'], bg: colors.light.post }, |
||||||
|
{ name: 'Text on surface', fg: colors.light.text, bg: colors.light.surface }, |
||||||
|
{ name: 'Text-light on surface', fg: colors.light['text-light'], bg: colors.light.surface }, |
||||||
|
{ name: 'Text on highlight', fg: colors.light.text, bg: colors.light.highlight }, |
||||||
|
{ name: 'Text-light on highlight', fg: colors.light['text-light'], bg: colors.light.highlight }, |
||||||
|
{ name: 'Accent on background', fg: colors.light.accent, bg: colors.light.bg }, |
||||||
|
{ name: 'Accent on post', fg: colors.light.accent, bg: colors.light.post }, |
||||||
|
{ name: 'Text on accent (buttons)', fg: '#ffffff', bg: colors.light.accent }, |
||||||
|
]; |
||||||
|
|
||||||
|
lightTests.forEach(test => { |
||||||
|
const ratio = getContrastRatio(test.fg, test.bg); |
||||||
|
const normalResult = formatResult(ratio, false); |
||||||
|
const largeResult = formatResult(ratio, true); |
||||||
|
console.log(`${test.name.padEnd(35)} Normal: ${normalResult}`); |
||||||
|
if (!checkContrast(ratio, false) && checkContrast(ratio, true)) { |
||||||
|
console.log(`${' '.repeat(35)} Large: ${largeResult}`); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(''); |
||||||
|
|
||||||
|
// Test Dark Mode
|
||||||
|
console.log('DARK MODE:'); |
||||||
|
console.log('-'.repeat(80)); |
||||||
|
|
||||||
|
const darkTests = [ |
||||||
|
{ name: 'Text on background', fg: colors.dark.text, bg: colors.dark.bg }, |
||||||
|
{ name: 'Text-light on background', fg: colors.dark['text-light'], bg: colors.dark.bg }, |
||||||
|
{ name: 'Text on post', fg: colors.dark.text, bg: colors.dark.post }, |
||||||
|
{ name: 'Text-light on post', fg: colors.dark['text-light'], bg: colors.dark.post }, |
||||||
|
{ name: 'Text on surface', fg: colors.dark.text, bg: colors.dark.surface }, |
||||||
|
{ name: 'Text-light on surface', fg: colors.dark['text-light'], bg: colors.dark.surface }, |
||||||
|
{ name: 'Text on highlight', fg: colors.dark.text, bg: colors.dark.highlight }, |
||||||
|
{ name: 'Text-light on highlight', fg: colors.dark['text-light'], bg: colors.dark.highlight }, |
||||||
|
{ name: 'Accent on background', fg: colors.dark.accent, bg: colors.dark.bg }, |
||||||
|
{ name: 'Accent on post', fg: colors.dark.accent, bg: colors.dark.post }, |
||||||
|
{ name: 'Text on accent (buttons)', fg: '#ffffff', bg: colors.dark.accent }, |
||||||
|
]; |
||||||
|
|
||||||
|
darkTests.forEach(test => { |
||||||
|
const ratio = getContrastRatio(test.fg, test.bg); |
||||||
|
const normalResult = formatResult(ratio, false); |
||||||
|
const largeResult = formatResult(ratio, true); |
||||||
|
console.log(`${test.name.padEnd(35)} Normal: ${normalResult}`); |
||||||
|
if (!checkContrast(ratio, false) && checkContrast(ratio, true)) { |
||||||
|
console.log(`${' '.repeat(35)} Large: ${largeResult}`); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(''); |
||||||
|
console.log('='.repeat(80)); |
||||||
|
|
||||||
|
// Summary
|
||||||
|
let lightFailures = 0; |
||||||
|
let darkFailures = 0; |
||||||
|
|
||||||
|
lightTests.forEach(test => { |
||||||
|
const ratio = getContrastRatio(test.fg, test.bg); |
||||||
|
if (!checkContrast(ratio, false)) lightFailures++; |
||||||
|
}); |
||||||
|
|
||||||
|
darkTests.forEach(test => { |
||||||
|
const ratio = getContrastRatio(test.fg, test.bg); |
||||||
|
if (!checkContrast(ratio, false)) darkFailures++; |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`SUMMARY:`); |
||||||
|
console.log(`Light mode failures: ${lightFailures}`); |
||||||
|
console.log(`Dark mode failures: ${darkFailures}`); |
||||||
|
console.log(''); |
||||||
|
|
||||||
|
if (lightFailures === 0 && darkFailures === 0) { |
||||||
|
console.log('✓ All contrast ratios meet WCAG AA standards!'); |
||||||
|
} else { |
||||||
|
console.log('✗ Some contrast ratios need adjustment.'); |
||||||
|
console.log(''); |
||||||
|
console.log('RECOMMENDATIONS:'); |
||||||
|
if (lightFailures > 0) { |
||||||
|
console.log('- Light mode: Consider darkening text colors or lightening backgrounds'); |
||||||
|
} |
||||||
|
if (darkFailures > 0) { |
||||||
|
console.log('- Dark mode: Consider lightening text colors or darkening backgrounds'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,257 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
event: NostrEvent; |
||||||
|
} |
||||||
|
|
||||||
|
let { event }: Props = $props(); |
||||||
|
|
||||||
|
interface MediaItem { |
||||||
|
url: string; |
||||||
|
type: 'image' | 'video' | 'audio' | 'file'; |
||||||
|
mimeType?: string; |
||||||
|
width?: number; |
||||||
|
height?: number; |
||||||
|
size?: number; |
||||||
|
source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeUrl(url: string): string { |
||||||
|
try { |
||||||
|
const parsed = new URL(url); |
||||||
|
// Remove query params and fragments for comparison |
||||||
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function extractMedia(): MediaItem[] { |
||||||
|
const media: MediaItem[] = []; |
||||||
|
const seen = new Set<string>(); |
||||||
|
|
||||||
|
// 1. Image tag (NIP-23) - cover image |
||||||
|
const imageTag = event.tags.find((t) => t[0] === 'image'); |
||||||
|
if (imageTag && imageTag[1]) { |
||||||
|
const normalized = normalizeUrl(imageTag[1]); |
||||||
|
if (!seen.has(normalized)) { |
||||||
|
media.push({ |
||||||
|
url: imageTag[1], |
||||||
|
type: 'image', |
||||||
|
source: 'image-tag' |
||||||
|
}); |
||||||
|
seen.add(normalized); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 2. imeta tags (NIP-92) |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'imeta') { |
||||||
|
let url: string | undefined; |
||||||
|
let mimeType: string | undefined; |
||||||
|
let width: number | undefined; |
||||||
|
let height: number | undefined; |
||||||
|
|
||||||
|
for (let i = 1; i < tag.length; i++) { |
||||||
|
const item = tag[i]; |
||||||
|
if (item.startsWith('url ')) { |
||||||
|
url = item.substring(4).trim(); |
||||||
|
} else if (item.startsWith('m ')) { |
||||||
|
mimeType = item.substring(2).trim(); |
||||||
|
} else if (item.startsWith('x ')) { |
||||||
|
width = parseInt(item.substring(2).trim(), 10); |
||||||
|
} else if (item.startsWith('y ')) { |
||||||
|
height = parseInt(item.substring(2).trim(), 10); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (url) { |
||||||
|
const normalized = normalizeUrl(url); |
||||||
|
if (!seen.has(normalized)) { |
||||||
|
let type: 'image' | 'video' | 'audio' = 'image'; |
||||||
|
if (mimeType) { |
||||||
|
if (mimeType.startsWith('video/')) type = 'video'; |
||||||
|
else if (mimeType.startsWith('audio/')) type = 'audio'; |
||||||
|
} |
||||||
|
|
||||||
|
media.push({ |
||||||
|
url, |
||||||
|
type, |
||||||
|
mimeType, |
||||||
|
width, |
||||||
|
height, |
||||||
|
source: 'imeta' |
||||||
|
}); |
||||||
|
seen.add(normalized); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 3. file tags (NIP-94) |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'file' && tag[1]) { |
||||||
|
const normalized = normalizeUrl(tag[1]); |
||||||
|
if (!seen.has(normalized)) { |
||||||
|
let mimeType: string | undefined; |
||||||
|
let size: number | undefined; |
||||||
|
|
||||||
|
for (let i = 2; i < tag.length; i++) { |
||||||
|
const item = tag[i]; |
||||||
|
if (item && !item.startsWith('size ')) { |
||||||
|
mimeType = item; |
||||||
|
} else if (item.startsWith('size ')) { |
||||||
|
size = parseInt(item.substring(5).trim(), 10); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
media.push({ |
||||||
|
url: tag[1], |
||||||
|
type: 'file', |
||||||
|
mimeType, |
||||||
|
size, |
||||||
|
source: 'file-tag' |
||||||
|
}); |
||||||
|
seen.add(normalized); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 4. Extract from markdown content (images) |
||||||
|
const imageRegex = /!\[.*?\]\((.*?)\)/g; |
||||||
|
let match; |
||||||
|
while ((match = imageRegex.exec(event.content)) !== null) { |
||||||
|
const url = match[1]; |
||||||
|
const normalized = normalizeUrl(url); |
||||||
|
if (!seen.has(normalized)) { |
||||||
|
media.push({ |
||||||
|
url, |
||||||
|
type: 'image', |
||||||
|
source: 'content' |
||||||
|
}); |
||||||
|
seen.add(normalized); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return media; |
||||||
|
} |
||||||
|
|
||||||
|
const mediaItems = $derived(extractMedia()); |
||||||
|
const coverImage = $derived(mediaItems.find((m) => m.source === 'image-tag')); |
||||||
|
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag')); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if coverImage} |
||||||
|
<div class="cover-image mb-4"> |
||||||
|
<img |
||||||
|
src={coverImage.url} |
||||||
|
alt="" |
||||||
|
class="w-full max-h-96 object-cover rounded" |
||||||
|
loading="lazy" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if otherMedia.length > 0} |
||||||
|
<div class="media-gallery mb-4"> |
||||||
|
{#each otherMedia as item} |
||||||
|
{#if item.type === 'image'} |
||||||
|
<div class="media-item"> |
||||||
|
<img |
||||||
|
src={item.url} |
||||||
|
alt="" |
||||||
|
class="max-w-full rounded" |
||||||
|
loading="lazy" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{:else if item.type === 'video'} |
||||||
|
<div class="media-item"> |
||||||
|
<video |
||||||
|
src={item.url} |
||||||
|
controls |
||||||
|
preload="metadata" |
||||||
|
class="max-w-full rounded" |
||||||
|
style="max-height: 500px;" |
||||||
|
> |
||||||
|
<track kind="captions" /> |
||||||
|
Your browser does not support the video tag. |
||||||
|
</video> |
||||||
|
</div> |
||||||
|
{:else if item.type === 'audio'} |
||||||
|
<div class="media-item"> |
||||||
|
<audio src={item.url} controls preload="metadata" class="w-full"> |
||||||
|
Your browser does not support the audio tag. |
||||||
|
</audio> |
||||||
|
</div> |
||||||
|
{:else if item.type === 'file'} |
||||||
|
<div class="media-item file-item"> |
||||||
|
<a |
||||||
|
href={item.url} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="file-link" |
||||||
|
> |
||||||
|
📎 {item.mimeType || 'File'} {item.size ? `(${(item.size / 1024).toFixed(1)} KB)` : ''} |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.cover-image { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cover-image img { |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .cover-image img { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.media-gallery { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
||||||
|
gap: 1rem; |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.media-item { |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
overflow: hidden; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
padding: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .media-item { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
.media-item img, |
||||||
|
.media-item video { |
||||||
|
max-width: 100%; |
||||||
|
height: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.file-item { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.file-link { |
||||||
|
color: var(--fog-accent, #3b82f6); |
||||||
|
text-decoration: none; |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.file-link:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,165 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
comment: NostrEvent; |
||||||
|
parentEvent?: NostrEvent; |
||||||
|
onReply?: (comment: NostrEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { comment, parentEvent, onReply }: Props = $props(); |
||||||
|
|
||||||
|
let parentPreview = $state<string | null>(null); |
||||||
|
let parentHighlighted = $state(false); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
if (parentEvent) { |
||||||
|
// Create preview from parent (first 100 chars, plaintext) |
||||||
|
const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim(); |
||||||
|
parentPreview = plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - comment.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
const minutes = Math.floor(diff / 60); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
if (minutes > 0) return `${minutes}m ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
const clientTag = comment.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function scrollToParent() { |
||||||
|
if (parentEvent) { |
||||||
|
const element = document.getElementById(`comment-${parentEvent.id}`); |
||||||
|
if (element) { |
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
||||||
|
parentHighlighted = true; |
||||||
|
setTimeout(() => { |
||||||
|
parentHighlighted = false; |
||||||
|
}, 2000); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleReply() { |
||||||
|
onReply?.(comment); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article |
||||||
|
id="comment-{comment.id}" |
||||||
|
class="comment {parentHighlighted ? 'highlighted' : ''}" |
||||||
|
> |
||||||
|
{#if parentEvent && parentPreview} |
||||||
|
<div |
||||||
|
class="parent-preview" |
||||||
|
onclick={scrollToParent} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Enter' || e.key === ' ') { |
||||||
|
e.preventDefault(); |
||||||
|
scrollToParent(); |
||||||
|
} |
||||||
|
}} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
↑ Replying to: {parentPreview} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="comment-header flex items-center gap-2 mb-2"> |
||||||
|
<ProfileBadge pubkey={comment.pubkey} /> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="comment-content mb-2"> |
||||||
|
<MarkdownRenderer content={comment.content} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="comment-actions flex gap-2"> |
||||||
|
<button |
||||||
|
onclick={handleReply} |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
> |
||||||
|
Reply |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.comment { |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .comment { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.comment.highlighted { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-color: var(--fog-accent, #3b82f6); |
||||||
|
animation: highlight 2s ease-out; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .comment.highlighted { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes highlight { |
||||||
|
0% { |
||||||
|
background: var(--fog-accent, #3b82f6); |
||||||
|
opacity: 0.3; |
||||||
|
} |
||||||
|
100% { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.parent-preview { |
||||||
|
padding: 0.5rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-left: 3px solid var(--fog-accent, #3b82f6); |
||||||
|
border-radius: 0.25rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .parent-preview { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.parent-preview:hover { |
||||||
|
background: var(--fog-accent, #3b82f6); |
||||||
|
opacity: 0.1; |
||||||
|
} |
||||||
|
|
||||||
|
.comment-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
threadId: string; // The kind 11 thread event ID |
||||||
|
parentEvent?: NostrEvent; // If replying to a comment |
||||||
|
onPublished?: () => void; |
||||||
|
onCancel?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { threadId, parentEvent, onPublished, onCancel }: Props = $props(); |
||||||
|
|
||||||
|
let content = $state(''); |
||||||
|
let publishing = $state(false); |
||||||
|
let includeClientTag = $state(true); |
||||||
|
|
||||||
|
async function publish() { |
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to comment'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!content.trim()) { |
||||||
|
alert('Comment cannot be empty'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
publishing = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const tags: string[][] = [ |
||||||
|
['K', '11'], // Kind of the event being commented on |
||||||
|
['E', threadId] // Event ID of the thread |
||||||
|
]; |
||||||
|
|
||||||
|
// If replying to a comment, add parent references |
||||||
|
if (parentEvent) { |
||||||
|
tags.push(['E', parentEvent.id]); // Parent comment event ID |
||||||
|
tags.push(['e', parentEvent.id]); // Also add lowercase e tag for compatibility |
||||||
|
tags.push(['p', parentEvent.pubkey]); // Parent comment author |
||||||
|
tags.push(['P', parentEvent.pubkey]); // NIP-22 uppercase P tag |
||||||
|
tags.push(['k', '1111']); // Kind of parent (comment) |
||||||
|
} |
||||||
|
|
||||||
|
if (includeClientTag) { |
||||||
|
tags.push(['client', 'Aitherboard']); |
||||||
|
} |
||||||
|
|
||||||
|
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
||||||
|
kind: 1111, |
||||||
|
pubkey: sessionManager.getCurrentPubkey()!, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content: content.trim() |
||||||
|
}; |
||||||
|
|
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const result = await signAndPublish(event, [...config.defaultRelays]); |
||||||
|
|
||||||
|
if (result.success.length > 0) { |
||||||
|
content = ''; |
||||||
|
onPublished?.(); |
||||||
|
} else { |
||||||
|
alert('Failed to publish comment'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error publishing comment:', error); |
||||||
|
alert('Error publishing comment'); |
||||||
|
} finally { |
||||||
|
publishing = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="comment-form"> |
||||||
|
<textarea |
||||||
|
bind:value={content} |
||||||
|
placeholder={parentEvent ? 'Write a reply...' : 'Write a comment...'} |
||||||
|
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text" |
||||||
|
rows="4" |
||||||
|
disabled={publishing} |
||||||
|
></textarea> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-2"> |
||||||
|
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
bind:checked={includeClientTag} |
||||||
|
class="rounded" |
||||||
|
/> |
||||||
|
Include client tag |
||||||
|
</label> |
||||||
|
|
||||||
|
<div class="flex gap-2"> |
||||||
|
{#if onCancel} |
||||||
|
<button |
||||||
|
onclick={onCancel} |
||||||
|
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" |
||||||
|
disabled={publishing} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
<button |
||||||
|
onclick={publish} |
||||||
|
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50" |
||||||
|
disabled={publishing || !content.trim()} |
||||||
|
> |
||||||
|
{publishing ? 'Publishing...' : parentEvent ? 'Reply' : 'Comment'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.comment-form { |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
textarea { |
||||||
|
resize: vertical; |
||||||
|
min-height: 100px; |
||||||
|
} |
||||||
|
|
||||||
|
textarea:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #3b82f6); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,128 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Comment from './Comment.svelte'; |
||||||
|
import CommentForm from './CommentForm.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
threadId: string; // The kind 11 thread event ID |
||||||
|
} |
||||||
|
|
||||||
|
let { threadId }: Props = $props(); |
||||||
|
|
||||||
|
let comments = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
let replyingTo = $state<NostrEvent | null>(null); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
loadComments(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadComments() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
|
||||||
|
// Fetch comments (kind 1111) that reference this thread |
||||||
|
// NIP-22: Comments use K tag for kind and E tag for event |
||||||
|
const filters = [ |
||||||
|
{ |
||||||
|
kinds: [1111], |
||||||
|
'#K': ['11'], // Comments on kind 11 threads |
||||||
|
'#E': [threadId] // Comments on this specific thread |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
filters, |
||||||
|
[...config.defaultRelays], |
||||||
|
{ useCache: true, cacheResults: true, onUpdate: (updated) => { |
||||||
|
comments = sortComments(updated); |
||||||
|
}} |
||||||
|
); |
||||||
|
|
||||||
|
comments = sortComments(events); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading comments:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function sortComments(events: NostrEvent[]): NostrEvent[] { |
||||||
|
// Sort by created_at ascending (oldest first) |
||||||
|
return [...events].sort((a, b) => a.created_at - b.created_at); |
||||||
|
} |
||||||
|
|
||||||
|
function getParentEvent(comment: NostrEvent): NostrEvent | undefined { |
||||||
|
// NIP-22: E tag points to parent event |
||||||
|
const eTag = comment.tags.find((t) => t[0] === 'E' || t[0] === 'e'); |
||||||
|
if (eTag && eTag[1]) { |
||||||
|
// Find parent in comments array |
||||||
|
return comments.find((c) => c.id === eTag[1]); |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
function handleReply(comment: NostrEvent) { |
||||||
|
replyingTo = comment; |
||||||
|
} |
||||||
|
|
||||||
|
function handleCommentPublished() { |
||||||
|
replyingTo = null; |
||||||
|
loadComments(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="comment-thread"> |
||||||
|
<h2 class="text-xl font-bold mb-4">Comments</h2> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p> |
||||||
|
{:else if comments.length === 0} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No comments yet. Be the first to comment!</p> |
||||||
|
{:else} |
||||||
|
<div class="comments-list"> |
||||||
|
{#each comments as comment (comment.id)} |
||||||
|
{@const parent = getParentEvent(comment)} |
||||||
|
<Comment |
||||||
|
{comment} |
||||||
|
parentEvent={parent} |
||||||
|
onReply={handleReply} |
||||||
|
/> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if replyingTo} |
||||||
|
<div class="reply-form-container mt-4"> |
||||||
|
<CommentForm |
||||||
|
threadId={threadId} |
||||||
|
parentEvent={replyingTo} |
||||||
|
onPublished={handleCommentPublished} |
||||||
|
onCancel={() => (replyingTo = null)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="new-comment-container mt-4"> |
||||||
|
<CommentForm |
||||||
|
{threadId} |
||||||
|
onPublished={handleCommentPublished} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.comment-thread { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.comments-list { |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
parentEvent?: NostrEvent; // If replying to a post |
||||||
|
onPublished?: () => void; |
||||||
|
onCancel?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { parentEvent, onPublished, onCancel }: Props = $props(); |
||||||
|
|
||||||
|
let content = $state(''); |
||||||
|
let publishing = $state(false); |
||||||
|
let includeClientTag = $state(true); |
||||||
|
|
||||||
|
async function publish() { |
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to post'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!content.trim()) { |
||||||
|
alert('Post cannot be empty'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
publishing = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
// If replying, add NIP-10 threading tags |
||||||
|
if (parentEvent) { |
||||||
|
const rootTag = parentEvent.tags.find((t) => t[0] === 'root'); |
||||||
|
const rootId = rootTag?.[1] || parentEvent.id; |
||||||
|
|
||||||
|
tags.push(['e', parentEvent.id, '', 'reply']); |
||||||
|
tags.push(['p', parentEvent.pubkey]); |
||||||
|
tags.push(['root', rootId]); |
||||||
|
} |
||||||
|
|
||||||
|
if (includeClientTag) { |
||||||
|
tags.push(['client', 'Aitherboard']); |
||||||
|
} |
||||||
|
|
||||||
|
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
||||||
|
kind: 1, |
||||||
|
pubkey: sessionManager.getCurrentPubkey()!, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content: content.trim() |
||||||
|
}; |
||||||
|
|
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const result = await signAndPublish(event, [...config.defaultRelays]); |
||||||
|
|
||||||
|
if (result.success.length > 0) { |
||||||
|
content = ''; |
||||||
|
onPublished?.(); |
||||||
|
} else { |
||||||
|
alert('Failed to publish post'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error publishing post:', error); |
||||||
|
alert('Error publishing post'); |
||||||
|
} finally { |
||||||
|
publishing = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="create-kind1-form"> |
||||||
|
{#if parentEvent} |
||||||
|
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm"> |
||||||
|
Replying to: {parentEvent.content.slice(0, 100)}... |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<textarea |
||||||
|
bind:value={content} |
||||||
|
placeholder={parentEvent ? 'Write a reply...' : 'What\'s on your mind?'} |
||||||
|
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text" |
||||||
|
rows="6" |
||||||
|
disabled={publishing} |
||||||
|
></textarea> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-2"> |
||||||
|
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
bind:checked={includeClientTag} |
||||||
|
class="rounded" |
||||||
|
/> |
||||||
|
Include client tag |
||||||
|
</label> |
||||||
|
|
||||||
|
<div class="flex gap-2"> |
||||||
|
{#if onCancel} |
||||||
|
<button |
||||||
|
onclick={onCancel} |
||||||
|
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" |
||||||
|
disabled={publishing} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
<button |
||||||
|
onclick={publish} |
||||||
|
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50" |
||||||
|
disabled={publishing || !content.trim()} |
||||||
|
> |
||||||
|
{publishing ? 'Publishing...' : parentEvent ? 'Reply' : 'Post'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.create-kind1-form { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
textarea { |
||||||
|
resize: vertical; |
||||||
|
min-height: 120px; |
||||||
|
} |
||||||
|
|
||||||
|
textarea:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #3b82f6); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Kind1Post from './Kind1Post.svelte'; |
||||||
|
import CreateKind1Form from './CreateKind1Form.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
let posts = $state<NostrEvent[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
let replyingTo = $state<NostrEvent | null>(null); |
||||||
|
let showNewPostForm = $state(false); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
loadFeed(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadFeed() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
|
||||||
|
const filters = [ |
||||||
|
{ |
||||||
|
kinds: [1], |
||||||
|
limit: 50 |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
filters, |
||||||
|
[...config.defaultRelays], |
||||||
|
{ useCache: true, cacheResults: true, onUpdate: (updated) => { |
||||||
|
posts = sortPosts(updated); |
||||||
|
}} |
||||||
|
); |
||||||
|
|
||||||
|
posts = sortPosts(events); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading feed:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function sortPosts(events: NostrEvent[]): NostrEvent[] { |
||||||
|
// Sort by created_at descending (newest first) |
||||||
|
return [...events].sort((a, b) => b.created_at - a.created_at); |
||||||
|
} |
||||||
|
|
||||||
|
function handleReply(post: NostrEvent) { |
||||||
|
replyingTo = post; |
||||||
|
showNewPostForm = true; |
||||||
|
} |
||||||
|
|
||||||
|
function handlePostPublished() { |
||||||
|
replyingTo = null; |
||||||
|
showNewPostForm = false; |
||||||
|
loadFeed(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="kind1-feed"> |
||||||
|
<div class="feed-header mb-4"> |
||||||
|
<h1 class="text-2xl font-bold mb-4">Feed</h1> |
||||||
|
<button |
||||||
|
onclick={() => (showNewPostForm = !showNewPostForm)} |
||||||
|
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90" |
||||||
|
> |
||||||
|
{showNewPostForm ? 'Cancel' : 'New Post'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showNewPostForm} |
||||||
|
<div class="new-post-form mb-4"> |
||||||
|
<CreateKind1Form |
||||||
|
parentEvent={replyingTo} |
||||||
|
onPublished={handlePostPublished} |
||||||
|
onCancel={() => { |
||||||
|
showNewPostForm = false; |
||||||
|
replyingTo = null; |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p> |
||||||
|
{:else if posts.length === 0} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p> |
||||||
|
{:else} |
||||||
|
<div class="posts-list"> |
||||||
|
{#each posts as post (post.id)} |
||||||
|
<Kind1Post {post} onReply={handleReply} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.kind1-feed { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.feed-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import Kind1ReactionButtons from '../reactions/Kind1ReactionButtons.svelte'; |
||||||
|
import ZapButton from '../zaps/ZapButton.svelte'; |
||||||
|
import ZapReceipt from '../zaps/ZapReceipt.svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
post: NostrEvent; |
||||||
|
onReply?: (post: NostrEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { post, onReply }: Props = $props(); |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - post.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
const minutes = Math.floor(diff / 60); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
if (minutes > 0) return `${minutes}m ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
const clientTag = post.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function isReply(): boolean { |
||||||
|
// Check if this is a reply (has e tag pointing to another event) |
||||||
|
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id); |
||||||
|
} |
||||||
|
|
||||||
|
function getRootEventId(): string | null { |
||||||
|
const rootTag = post.tags.find((t) => t[0] === 'root'); |
||||||
|
return rootTag?.[1] || null; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article class="kind1-post"> |
||||||
|
<div class="post-header flex items-center gap-2 mb-2"> |
||||||
|
<ProfileBadge pubkey={post.pubkey} /> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
{#if isReply()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="post-content mb-2"> |
||||||
|
<MarkdownRenderer content={post.content} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="post-actions flex items-center gap-4"> |
||||||
|
<Kind1ReactionButtons event={post} /> |
||||||
|
<ZapButton event={post} /> |
||||||
|
<ZapReceipt eventId={post.id} pubkey={post.pubkey} /> |
||||||
|
{#if onReply} |
||||||
|
<button |
||||||
|
onclick={() => onReply(post)} |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
> |
||||||
|
Reply |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.kind1-post { |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind1-post { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.post-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.post-actions { |
||||||
|
padding-top: 0.5rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .post-actions { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,138 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
pubkey: string; |
||||||
|
} |
||||||
|
|
||||||
|
let { pubkey }: Props = $props(); |
||||||
|
|
||||||
|
let paymentAddresses = $state<Array<{ type: string; address: string }>>([]); |
||||||
|
let loading = $state(true); |
||||||
|
|
||||||
|
const recognizedTypes = ['bitcoin', 'lightning', 'ethereum', 'nano', 'monero', 'cashme', 'revolut', 'venmo']; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
loadPaymentAddresses(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadPaymentAddresses() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
|
||||||
|
// Fetch kind 10133 (payment targets) |
||||||
|
const paymentEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [10133], authors: [pubkey], limit: 1 }], |
||||||
|
[...config.defaultRelays, ...config.profileRelays], |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
const addresses: Array<{ type: string; address: string }> = []; |
||||||
|
const seen = new Set<string>(); |
||||||
|
|
||||||
|
// Extract from kind 10133 |
||||||
|
if (paymentEvents.length > 0) { |
||||||
|
const event = paymentEvents[0]; |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'payto' && tag[1] && tag[2]) { |
||||||
|
const key = `${tag[1]}:${tag[2]}`; |
||||||
|
if (!seen.has(key)) { |
||||||
|
addresses.push({ type: tag[1], address: tag[2] }); |
||||||
|
seen.add(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Also get lud16 from profile (kind 0) |
||||||
|
const profile = await fetchProfile(pubkey); |
||||||
|
if (profile && profile.lud16) { |
||||||
|
for (const lud16 of profile.lud16) { |
||||||
|
const key = `lightning:${lud16}`; |
||||||
|
if (!seen.has(key)) { |
||||||
|
addresses.push({ type: 'lightning', address: lud16 }); |
||||||
|
seen.add(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
paymentAddresses = addresses; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading payment addresses:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function copyAddress(address: string) { |
||||||
|
navigator.clipboard.writeText(address); |
||||||
|
alert('Address copied to clipboard'); |
||||||
|
} |
||||||
|
|
||||||
|
function isZappable(type: string): boolean { |
||||||
|
return type === 'lightning'; |
||||||
|
} |
||||||
|
|
||||||
|
function getTypeLabel(type: string): string { |
||||||
|
return recognizedTypes.includes(type) ? type.charAt(0).toUpperCase() + type.slice(1) : type; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="payment-addresses"> |
||||||
|
{#if loading} |
||||||
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">Loading payment addresses...</span> |
||||||
|
{:else if paymentAddresses.length > 0} |
||||||
|
<div class="addresses-list"> |
||||||
|
<h3 class="text-lg font-semibold mb-2">Payment Addresses</h3> |
||||||
|
{#each paymentAddresses as { type, address }} |
||||||
|
<div class="address-item flex items-center gap-2 mb-2"> |
||||||
|
<span class="text-sm font-medium">{getTypeLabel(type)}:</span> |
||||||
|
<code class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight px-2 py-1 rounded"> |
||||||
|
{address} |
||||||
|
</code> |
||||||
|
<button |
||||||
|
onclick={() => copyAddress(address)} |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
title="Copy address" |
||||||
|
> |
||||||
|
Copy |
||||||
|
</button> |
||||||
|
{#if isZappable(type)} |
||||||
|
<a |
||||||
|
href="lightning:{address}" |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
> |
||||||
|
⚡ Zap |
||||||
|
</a> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.payment-addresses { |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.address-item { |
||||||
|
padding: 0.5rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .address-item { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
code { |
||||||
|
font-family: monospace; |
||||||
|
word-break: break-all; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,135 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import PaymentAddresses from './PaymentAddresses.svelte'; |
||||||
|
import Kind1Post from '../feed/Kind1Post.svelte'; |
||||||
|
import { fetchProfile } from '../../services/auth/profile-fetcher.js'; |
||||||
|
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { page } from '$app/stores'; |
||||||
|
import type { ProfileData } from '../../services/auth/profile-fetcher.js'; |
||||||
|
|
||||||
|
let profile = $state<ProfileData | null>(null); |
||||||
|
let userStatus = $state<string | null>(null); |
||||||
|
let posts = $state<any[]>([]); |
||||||
|
let loading = $state(true); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
if ($page.params.pubkey) { |
||||||
|
loadProfile(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if ($page.params.pubkey && !loading) { |
||||||
|
loadProfile(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadProfile() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const pubkey = $page.params.pubkey; |
||||||
|
if (!pubkey) return; |
||||||
|
|
||||||
|
// Load profile |
||||||
|
const profileData = await fetchProfile(pubkey); |
||||||
|
profile = profileData; |
||||||
|
|
||||||
|
// Load user status |
||||||
|
const status = await fetchUserStatus(pubkey); |
||||||
|
userStatus = status; |
||||||
|
|
||||||
|
// Load kind 1 posts |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const feedEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [1], authors: [pubkey], limit: 20 }], |
||||||
|
[...config.defaultRelays, ...config.profileRelays], |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
posts = feedEvents.sort((a, b) => b.created_at - a.created_at); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading profile:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="profile-page"> |
||||||
|
{#if loading} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading profile...</p> |
||||||
|
{:else if profile} |
||||||
|
<div class="profile-header mb-6"> |
||||||
|
{#if profile.picture} |
||||||
|
<img |
||||||
|
src={profile.picture} |
||||||
|
alt={profile.name || 'Profile picture'} |
||||||
|
class="profile-picture w-24 h-24 rounded-full mb-4" |
||||||
|
/> |
||||||
|
{/if} |
||||||
|
<h1 class="text-3xl font-bold mb-2">{profile.name || 'Anonymous'}</h1> |
||||||
|
{#if profile.about} |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p> |
||||||
|
{/if} |
||||||
|
{#if userStatus} |
||||||
|
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light italic mb-2"> |
||||||
|
{userStatus} |
||||||
|
</p> |
||||||
|
{/if} |
||||||
|
{#if profile.website && profile.website.length > 0} |
||||||
|
<div class="websites mb-2"> |
||||||
|
{#each profile.website as website} |
||||||
|
<a |
||||||
|
href={website} |
||||||
|
target="_blank" |
||||||
|
rel="noopener noreferrer" |
||||||
|
class="text-fog-accent dark:text-fog-dark-accent hover:underline mr-2" |
||||||
|
> |
||||||
|
{website} |
||||||
|
</a> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.nip05 && profile.nip05.length > 0} |
||||||
|
<div class="nip05 mb-2"> |
||||||
|
{#each profile.nip05 as nip05} |
||||||
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
{nip05} |
||||||
|
</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<PaymentAddresses pubkey={$page.params.pubkey} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="profile-posts"> |
||||||
|
<h2 class="text-xl font-bold mb-4">Posts</h2> |
||||||
|
{#if posts.length === 0} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet.</p> |
||||||
|
{:else} |
||||||
|
<div class="posts-list"> |
||||||
|
{#each posts as post (post.id)} |
||||||
|
<Kind1Post {post} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.profile-page { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.profile-picture { |
||||||
|
object-fit: cover; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,176 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import ZapInvoiceModal from './ZapInvoiceModal.svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
event: NostrEvent; |
||||||
|
pubkey?: string; // Optional: zap a specific pubkey (for profile zaps) |
||||||
|
} |
||||||
|
|
||||||
|
let { event, pubkey }: Props = $props(); |
||||||
|
|
||||||
|
let showInvoiceModal = $state(false); |
||||||
|
let invoice = $state<string | null>(null); |
||||||
|
let lnurl = $state<string | null>(null); |
||||||
|
let amount = $state<number>(1000); // Default 1000 sats |
||||||
|
|
||||||
|
const targetPubkey = $derived(pubkey || event.pubkey); |
||||||
|
|
||||||
|
async function handleZap() { |
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to zap'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Fetch profile to get lud16 or lnurl |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const profileEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [0], authors: [targetPubkey], limit: 1 }], |
||||||
|
[...config.defaultRelays, ...config.profileRelays], |
||||||
|
{ useCache: true } |
||||||
|
); |
||||||
|
|
||||||
|
let zapRequest: NostrEvent | null = null; |
||||||
|
|
||||||
|
if (profileEvents.length > 0) { |
||||||
|
const profile = profileEvents[0]; |
||||||
|
// Extract lud16 from profile tags |
||||||
|
const lud16Tag = profile.tags.find((t) => t[0] === 'lud16'); |
||||||
|
const lud16 = lud16Tag?.[1]; |
||||||
|
|
||||||
|
if (lud16) { |
||||||
|
// Create zap request |
||||||
|
const tags: string[][] = [ |
||||||
|
['relays', ...config.defaultRelays], |
||||||
|
['amount', amount.toString()], |
||||||
|
['lnurl', lud16], |
||||||
|
['p', targetPubkey] |
||||||
|
]; |
||||||
|
|
||||||
|
if (event.kind !== 0) { |
||||||
|
// Zap to an event, not just a profile |
||||||
|
tags.push(['e', event.id]); |
||||||
|
tags.push(['k', event.kind.toString()]); |
||||||
|
} |
||||||
|
|
||||||
|
const currentPubkey = sessionManager.getCurrentPubkey()!; |
||||||
|
const zapRequestEvent = { |
||||||
|
kind: 9734, |
||||||
|
pubkey: currentPubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content: 'Zap!' |
||||||
|
}; |
||||||
|
|
||||||
|
const signed = await sessionManager.signEvent(zapRequestEvent); |
||||||
|
zapRequest = signed; |
||||||
|
|
||||||
|
// Try to send via lightning: URI (primary method) |
||||||
|
const lightningUri = `lightning:${lud16}?amount=${amount}&nostr=${encodeURIComponent(JSON.stringify(zapRequest))}`; |
||||||
|
|
||||||
|
try { |
||||||
|
window.location.href = lightningUri; |
||||||
|
return; // Success, wallet should handle it |
||||||
|
} catch (error) { |
||||||
|
console.log('lightning: URI not supported, falling back to lnurl'); |
||||||
|
} |
||||||
|
|
||||||
|
// Fallback: Fetch invoice from lnurl |
||||||
|
await fetchInvoiceFromLnurl(lud16, zapRequest); |
||||||
|
} else { |
||||||
|
alert('User has no lightning address configured'); |
||||||
|
} |
||||||
|
} else { |
||||||
|
alert('Could not fetch user profile'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error creating zap:', error); |
||||||
|
alert('Error creating zap'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchInvoiceFromLnurl(lud16: string, zapRequest: NostrEvent) { |
||||||
|
try { |
||||||
|
// Parse lud16 (format: user@domain.com) |
||||||
|
const [username, domain] = lud16.split('@'); |
||||||
|
const callbackUrl = `https://${domain}/.well-known/lnurlp/${username}`; |
||||||
|
|
||||||
|
// Fetch lnurlp |
||||||
|
const response = await fetch(callbackUrl); |
||||||
|
const data = await response.json(); |
||||||
|
|
||||||
|
if (data.callback) { |
||||||
|
// Create zap request JSON |
||||||
|
const zapRequestJson = JSON.stringify(zapRequest); |
||||||
|
|
||||||
|
// Call callback with zap request |
||||||
|
const callbackUrlWithParams = `${data.callback}?amount=${amount * 1000}&nostr=${encodeURIComponent(zapRequestJson)}`; |
||||||
|
const invoiceResponse = await fetch(callbackUrlWithParams); |
||||||
|
const invoiceData = await invoiceResponse.json(); |
||||||
|
|
||||||
|
if (invoiceData.pr) { |
||||||
|
invoice = invoiceData.pr; |
||||||
|
lnurl = lud16; |
||||||
|
showInvoiceModal = true; |
||||||
|
} else { |
||||||
|
alert('Failed to get invoice from wallet'); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching invoice:', error); |
||||||
|
alert('Error fetching invoice from wallet'); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<button |
||||||
|
onclick={handleZap} |
||||||
|
class="zap-button" |
||||||
|
title="Zap" |
||||||
|
aria-label="Zap" |
||||||
|
> |
||||||
|
⚡ Zap |
||||||
|
</button> |
||||||
|
|
||||||
|
{#if showInvoiceModal && invoice} |
||||||
|
<ZapInvoiceModal |
||||||
|
{invoice} |
||||||
|
{lnurl} |
||||||
|
{amount} |
||||||
|
onClose={() => { |
||||||
|
showInvoiceModal = false; |
||||||
|
invoice = null; |
||||||
|
}} |
||||||
|
/> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.zap-button { |
||||||
|
padding: 0.25rem 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .zap-button { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.zap-button:hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-color: var(--fog-accent, #3b82f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .zap-button:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,164 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
interface Props { |
||||||
|
invoice: string; |
||||||
|
lnurl: string | null; |
||||||
|
amount: number; |
||||||
|
onClose: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { invoice, lnurl, amount, onClose }: Props = $props(); |
||||||
|
|
||||||
|
function copyInvoice() { |
||||||
|
navigator.clipboard.writeText(invoice); |
||||||
|
alert('Invoice copied to clipboard'); |
||||||
|
} |
||||||
|
|
||||||
|
// Generate QR code (simplified - in production, use a QR library) |
||||||
|
let qrCodeUrl = $state<string>(''); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
// Use a QR code API service |
||||||
|
qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(invoice)}`; |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="modal-overlay" onclick={onClose} onkeydown={(e) => e.key === 'Escape' && onClose()}> |
||||||
|
<div class="modal-content" onclick={(e) => e.stopPropagation()}> |
||||||
|
<div class="modal-header"> |
||||||
|
<h2>Pay Invoice</h2> |
||||||
|
<button onclick={onClose} class="close-button">×</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-body"> |
||||||
|
<p class="mb-4">Scan this QR code with your lightning wallet or copy the invoice:</p> |
||||||
|
|
||||||
|
{#if qrCodeUrl} |
||||||
|
<div class="qr-code-container mb-4"> |
||||||
|
<img src={qrCodeUrl} alt="Lightning invoice QR code" /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="invoice-container mb-4"> |
||||||
|
<label class="block text-sm font-semibold mb-2">Invoice:</label> |
||||||
|
<textarea |
||||||
|
readonly |
||||||
|
value={invoice} |
||||||
|
class="w-full p-2 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text text-xs font-mono" |
||||||
|
rows="4" |
||||||
|
></textarea> |
||||||
|
<button |
||||||
|
onclick={copyInvoice} |
||||||
|
class="mt-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90" |
||||||
|
> |
||||||
|
Copy Invoice |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
Amount: {amount} sats |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="modal-footer"> |
||||||
|
<button onclick={onClose}>Close</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
backdrop-filter: blur(4px); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-content { |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 8px; |
||||||
|
max-width: 500px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
overflow: auto; |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-content { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 1.5rem; |
||||||
|
cursor: pointer; |
||||||
|
padding: 0; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .close-button { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-body { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.qr-code-container { |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.qr-code-container img { |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 4px; |
||||||
|
padding: 1rem; |
||||||
|
background: white; |
||||||
|
} |
||||||
|
|
||||||
|
.invoice-container textarea { |
||||||
|
resize: none; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer { |
||||||
|
padding: 1rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
text-align: right; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .modal-footer { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.modal-footer button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
background: var(--fog-accent, #3b82f6); |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
eventId: string; // The event that was zapped |
||||||
|
pubkey?: string; // Optional: filter by zapped pubkey |
||||||
|
} |
||||||
|
|
||||||
|
let { eventId, pubkey }: Props = $props(); |
||||||
|
|
||||||
|
let zapReceipts = $state<NostrEvent[]>([]); |
||||||
|
let totalAmount = $state<number>(0); |
||||||
|
let loading = $state(true); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
loadZapReceipts(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadZapReceipts() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const threshold = config.zapThreshold; |
||||||
|
|
||||||
|
// Fetch zap receipts (kind 9735) for this event |
||||||
|
const filters = [ |
||||||
|
{ |
||||||
|
kinds: [9735], |
||||||
|
'#e': [eventId] |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
if (pubkey) { |
||||||
|
filters[0]['#p'] = [pubkey]; |
||||||
|
} |
||||||
|
|
||||||
|
const receipts = await nostrClient.fetchEvents( |
||||||
|
filters, |
||||||
|
[...config.defaultRelays], |
||||||
|
{ useCache: true, cacheResults: true, onUpdate: (updated) => { |
||||||
|
processReceipts(updated); |
||||||
|
}} |
||||||
|
); |
||||||
|
|
||||||
|
processReceipts(receipts); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading zap receipts:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function processReceipts(receipts: NostrEvent[]) { |
||||||
|
const config = nostrClient.getConfig(); |
||||||
|
const threshold = config.zapThreshold; |
||||||
|
|
||||||
|
// Filter by threshold and extract amounts |
||||||
|
const validReceipts = receipts.filter((receipt) => { |
||||||
|
const amountTag = receipt.tags.find((t) => t[0] === 'amount'); |
||||||
|
if (amountTag && amountTag[1]) { |
||||||
|
const amount = parseInt(amountTag[1], 10); |
||||||
|
return !isNaN(amount) && amount >= threshold; |
||||||
|
} |
||||||
|
return false; |
||||||
|
}); |
||||||
|
|
||||||
|
zapReceipts = validReceipts; |
||||||
|
|
||||||
|
// Calculate total |
||||||
|
totalAmount = validReceipts.reduce((sum, receipt) => { |
||||||
|
const amountTag = receipt.tags.find((t) => t[0] === 'amount'); |
||||||
|
if (amountTag && amountTag[1]) { |
||||||
|
const amount = parseInt(amountTag[1], 10); |
||||||
|
return sum + (isNaN(amount) ? 0 : amount); |
||||||
|
} |
||||||
|
return sum; |
||||||
|
}, 0); |
||||||
|
} |
||||||
|
|
||||||
|
function getAmount(receipt: NostrEvent): number { |
||||||
|
const amountTag = receipt.tags.find((t) => t[0] === 'amount'); |
||||||
|
if (amountTag && amountTag[1]) { |
||||||
|
const amount = parseInt(amountTag[1], 10); |
||||||
|
return isNaN(amount) ? 0 : amount; |
||||||
|
} |
||||||
|
return 0; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="zap-receipts"> |
||||||
|
{#if loading} |
||||||
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">Loading zaps...</span> |
||||||
|
{:else if zapReceipts.length > 0} |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<span class="text-lg">⚡</span> |
||||||
|
<span class="text-sm font-semibold">{totalAmount.toLocaleString()} sats</span> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
({zapReceipts.length} {zapReceipts.length === 1 ? 'zap' : 'zaps'}) |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.zap-receipts { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../../lib/components/layout/Header.svelte'; |
||||||
|
import ProfilePage from '../../../lib/modules/profiles/ProfilePage.svelte'; |
||||||
|
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<ProfilePage /> |
||||||
|
</main> |
||||||
@ -1,42 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import Header from '../../lib/components/layout/Header.svelte'; |
|
||||||
import ThreadList from '../../lib/modules/threads/ThreadList.svelte'; |
|
||||||
import CreateThreadForm from '../../lib/modules/threads/CreateThreadForm.svelte'; |
|
||||||
import { sessionManager } from '../../lib/services/auth/session-manager.js'; |
|
||||||
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
|
||||||
import { onMount } from 'svelte'; |
|
||||||
|
|
||||||
let showCreateForm = $state(false); |
|
||||||
|
|
||||||
onMount(async () => { |
|
||||||
await nostrClient.initialize(); |
|
||||||
}); |
|
||||||
</script> |
|
||||||
|
|
||||||
<Header /> |
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8"> |
|
||||||
<div class="mb-4"> |
|
||||||
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1> |
|
||||||
{#if sessionManager.isLoggedIn()} |
|
||||||
<button |
|
||||||
onclick={() => (showCreateForm = !showCreateForm)} |
|
||||||
class="mb-4 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent hover:bg-fog-text dark:hover:bg-fog-dark-text text-white transition-colors rounded" |
|
||||||
> |
|
||||||
{showCreateForm ? 'Cancel' : 'Create Thread'} |
|
||||||
</button> |
|
||||||
{#if showCreateForm} |
|
||||||
<CreateThreadForm /> |
|
||||||
{/if} |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
|
|
||||||
<ThreadList /> |
|
||||||
</main> |
|
||||||
|
|
||||||
<style> |
|
||||||
main { |
|
||||||
max-width: var(--content-width); |
|
||||||
margin: 0 auto; |
|
||||||
} |
|
||||||
</style> |
|
||||||
Loading…
Reference in new issue