26 changed files with 2880 additions and 82 deletions
@ -0,0 +1,189 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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