Browse Source
update workflow implemented updated ReadMe.md added getting started page got rid of broken zap stuffmaster
26 changed files with 1150 additions and 1904 deletions
@ -0,0 +1,454 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { prewarmCaches, type PrewarmProgress } from '../../services/cache/cache-prewarmer.js'; |
||||||
|
import { markVersionUpdated, getAppVersion } from '../../services/version-manager.js'; |
||||||
|
import { getDB } from '../../services/cache/indexeddb-store.js'; |
||||||
|
import Icon from '../ui/Icon.svelte'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onComplete: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { onComplete }: Props = $props(); |
||||||
|
|
||||||
|
let updating = $state(false); |
||||||
|
let updateComplete = $state(false); |
||||||
|
let showPWAUpdatePrompt = $state(false); |
||||||
|
let appVersion = $state('0.2.0'); |
||||||
|
let isPWAInstalled = $state(false); |
||||||
|
let progress = $state<PrewarmProgress>({ |
||||||
|
step: 'Preparing update...', |
||||||
|
progress: 0, |
||||||
|
total: 7, |
||||||
|
current: 0 |
||||||
|
}); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
appVersion = await getAppVersion(); |
||||||
|
|
||||||
|
// Check if PWA is installed |
||||||
|
if (typeof window !== 'undefined') { |
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches; |
||||||
|
const isIOSStandalone = (window.navigator as any).standalone === true; |
||||||
|
isPWAInstalled = isStandalone || isIOSStandalone; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function handleUpdate() { |
||||||
|
updating = true; |
||||||
|
progress = { |
||||||
|
step: 'Starting update...', |
||||||
|
progress: 0, |
||||||
|
total: 7, |
||||||
|
current: 0 |
||||||
|
}; |
||||||
|
|
||||||
|
try { |
||||||
|
// Step 1: Ensure database is up to date |
||||||
|
progress = { |
||||||
|
step: 'Updating database structure...', |
||||||
|
progress: 5, |
||||||
|
total: 7, |
||||||
|
current: 1 |
||||||
|
}; |
||||||
|
await getDB(); // This will trigger IndexedDB upgrade if needed |
||||||
|
|
||||||
|
// Step 2: Prewarm caches |
||||||
|
await prewarmCaches((prog) => { |
||||||
|
progress = { |
||||||
|
...prog, |
||||||
|
progress: 10 + Math.round((prog.progress * 80) / 100) // Map 0-100 to 10-90 |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
// Step 3: Mark version as updated |
||||||
|
progress = { |
||||||
|
step: 'Finalizing update...', |
||||||
|
progress: 95, |
||||||
|
total: 7, |
||||||
|
current: 7 |
||||||
|
}; |
||||||
|
await markVersionUpdated(); |
||||||
|
|
||||||
|
// Step 4: Complete |
||||||
|
progress = { |
||||||
|
step: 'Update complete!', |
||||||
|
progress: 100, |
||||||
|
total: 7, |
||||||
|
current: 7 |
||||||
|
}; |
||||||
|
|
||||||
|
// Small delay to show completion |
||||||
|
await new Promise(resolve => setTimeout(resolve, 500)); |
||||||
|
|
||||||
|
updateComplete = true; |
||||||
|
|
||||||
|
// If PWA is installed, ask if they want to update it |
||||||
|
if (isPWAInstalled) { |
||||||
|
showPWAUpdatePrompt = true; |
||||||
|
} else { |
||||||
|
// Not a PWA, route to about page and complete |
||||||
|
onComplete(); |
||||||
|
goto('/about'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
progress = { |
||||||
|
step: `Update error: ${error instanceof Error ? error.message : 'Unknown error'}`, |
||||||
|
progress: 0, |
||||||
|
total: 7, |
||||||
|
current: 0 |
||||||
|
}; |
||||||
|
console.error('Update failed:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function handlePWAUpdate() { |
||||||
|
try { |
||||||
|
// Check for service worker update |
||||||
|
if ('serviceWorker' in navigator) { |
||||||
|
const registration = await navigator.serviceWorker.getRegistration(); |
||||||
|
if (registration) { |
||||||
|
// Check for updates |
||||||
|
await registration.update(); |
||||||
|
|
||||||
|
// If there's a waiting service worker, skip waiting and reload |
||||||
|
if (registration.waiting) { |
||||||
|
registration.waiting.postMessage({ type: 'SKIP_WAITING' }); |
||||||
|
} |
||||||
|
|
||||||
|
// Store route to about page before reload |
||||||
|
sessionStorage.setItem('postUpdateRedirect', '/about'); |
||||||
|
|
||||||
|
// Reload to apply update |
||||||
|
window.location.reload(); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// If no service worker or update mechanism, route to about and reload |
||||||
|
sessionStorage.setItem('postUpdateRedirect', '/about'); |
||||||
|
window.location.reload(); |
||||||
|
} catch (error) { |
||||||
|
console.error('PWA update failed:', error); |
||||||
|
// Still reload to ensure update is applied |
||||||
|
sessionStorage.setItem('postUpdateRedirect', '/about'); |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div |
||||||
|
class="update-modal-overlay" |
||||||
|
role="dialog" |
||||||
|
aria-modal="true" |
||||||
|
aria-labelledby="update-modal-title" |
||||||
|
onclick={(e) => e.target === e.currentTarget && !updating && onComplete()} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Escape' && !updating) { |
||||||
|
onComplete(); |
||||||
|
} |
||||||
|
}} |
||||||
|
tabindex="-1" |
||||||
|
> |
||||||
|
<div class="update-modal"> |
||||||
|
<div class="update-modal-header"> |
||||||
|
<h2 id="update-modal-title" class="update-modal-title">New Version Available</h2> |
||||||
|
<p class="update-modal-subtitle"> |
||||||
|
Version {appVersion} is now available. Update to get the latest features and improvements. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if !updating} |
||||||
|
<div class="update-modal-content"> |
||||||
|
<p class="update-modal-description"> |
||||||
|
This update will: |
||||||
|
</p> |
||||||
|
<ul class="update-modal-list"> |
||||||
|
<li>Update the database structure</li> |
||||||
|
<li>Preload common data for faster performance</li> |
||||||
|
<li>Prepare caches for quick access</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="update-modal-actions"> |
||||||
|
<button |
||||||
|
onclick={handleUpdate} |
||||||
|
class="update-button" |
||||||
|
aria-label="Update to new version" |
||||||
|
> |
||||||
|
<Icon name="download" size={16} /> |
||||||
|
<span>Update Now</span> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onclick={onComplete} |
||||||
|
class="update-button-secondary" |
||||||
|
aria-label="Update later" |
||||||
|
> |
||||||
|
Update Later |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{:else if !updateComplete} |
||||||
|
<div class="update-modal-content"> |
||||||
|
<div class="update-progress"> |
||||||
|
<div class="update-progress-bar-container"> |
||||||
|
<div |
||||||
|
class="update-progress-bar" |
||||||
|
style="width: {progress.progress}%" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
<p class="update-progress-text"> |
||||||
|
{progress.step} ({progress.progress}%) |
||||||
|
</p> |
||||||
|
<p class="update-progress-step"> |
||||||
|
Step {progress.current} of {progress.total} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{:else if showPWAUpdatePrompt} |
||||||
|
<div class="update-modal-content"> |
||||||
|
<p class="update-modal-description"> |
||||||
|
The app update is complete! Since you have aitherboard installed as a PWA, would you like to update it now? |
||||||
|
</p> |
||||||
|
<p class="update-modal-note"> |
||||||
|
This will reload the app to apply the PWA update. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<div class="update-modal-actions"> |
||||||
|
<button |
||||||
|
onclick={handlePWAUpdate} |
||||||
|
class="update-button" |
||||||
|
aria-label="Update PWA now" |
||||||
|
> |
||||||
|
<Icon name="download" size={16} /> |
||||||
|
<span>Update PWA Now</span> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
onComplete(); |
||||||
|
goto('/about'); |
||||||
|
}} |
||||||
|
class="update-button-secondary" |
||||||
|
aria-label="Update PWA later" |
||||||
|
> |
||||||
|
Update Later |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.update-modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 10000; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal { |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.5rem; |
||||||
|
max-width: 500px; |
||||||
|
width: 100%; |
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal { |
||||||
|
background: var(--fog-dark-post, #334155); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-header { |
||||||
|
padding: 1.5rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-title { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
font-size: 1.25rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-title { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-subtitle { |
||||||
|
margin: 0; |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-subtitle { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-content { |
||||||
|
padding: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-description { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-description { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-list { |
||||||
|
margin: 0; |
||||||
|
padding-left: 1.5rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-list { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-list li { |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-note { |
||||||
|
margin: 1rem 0 0 0; |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-note { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
|
||||||
|
.update-modal-actions { |
||||||
|
padding: 1.5rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
display: flex; |
||||||
|
gap: 0.75rem; |
||||||
|
justify-content: flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-modal-actions { |
||||||
|
border-top-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.update-button { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 0.375rem; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875rem; |
||||||
|
font-weight: 500; |
||||||
|
transition: opacity 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.update-button:hover { |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-button { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.update-button-secondary { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.875rem; |
||||||
|
font-weight: 500; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.update-button-secondary:hover { |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-button-secondary { |
||||||
|
background: var(--fog-dark-highlight, #475569); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-button-secondary:hover { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
color: white; |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.update-progress { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.update-progress-bar-container { |
||||||
|
width: 100%; |
||||||
|
height: 8px; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 4px; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-progress-bar-container { |
||||||
|
background: var(--fog-dark-highlight, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.update-progress-bar { |
||||||
|
height: 100%; |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
border-radius: 4px; |
||||||
|
transition: width 0.3s ease; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-progress-bar { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.update-progress-text { |
||||||
|
margin: 0; |
||||||
|
font-size: 0.875rem; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-progress-text { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.update-progress-step { |
||||||
|
margin: 0; |
||||||
|
font-size: 0.75rem; |
||||||
|
color: var(--fog-text-light, #52667a); |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .update-progress-step { |
||||||
|
color: var(--fog-dark-text-light, #a8b8d0); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -1,237 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
||||||
import ReplyContext from '../../components/content/ReplyContext.svelte'; |
|
||||||
import EventMenu from '../../components/EventMenu.svelte'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
import { getKindInfo } from '../../types/kind-lookup.js'; |
|
||||||
import Icon from '../../components/ui/Icon.svelte'; |
|
||||||
import { getEventLink } from '../../services/event-links.js'; |
|
||||||
import { goto } from '$app/navigation'; |
|
||||||
import IconButton from '../../components/ui/IconButton.svelte'; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
zapReceipt: NostrEvent; // Kind 9735 zap receipt |
|
||||||
parentEvent?: NostrEvent; // The event this zap receipt is for |
|
||||||
onReply?: (receipt: NostrEvent) => void; |
|
||||||
} |
|
||||||
|
|
||||||
let { zapReceipt, parentEvent, onReply }: Props = $props(); |
|
||||||
let expanded = $state(false); |
|
||||||
let contentElement: HTMLElement | null = $state(null); |
|
||||||
let needsExpansion = $state(false); |
|
||||||
|
|
||||||
function getRelativeTime(): string { |
|
||||||
const now = Math.floor(Date.now() / 1000); |
|
||||||
const diff = now - zapReceipt.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 getAmount(): number { |
|
||||||
const amountTag = zapReceipt.tags.find((t) => t[0] === 'amount'); |
|
||||||
if (amountTag && amountTag[1]) { |
|
||||||
const amount = parseInt(amountTag[1], 10); |
|
||||||
return isNaN(amount) ? 0 : amount; |
|
||||||
} |
|
||||||
return 0; |
|
||||||
} |
|
||||||
|
|
||||||
function getZappedPubkey(): string | null { |
|
||||||
const pTag = zapReceipt.tags.find((t) => t[0] === 'p'); |
|
||||||
return pTag?.[1] || null; |
|
||||||
} |
|
||||||
|
|
||||||
function getZappedEventId(): string | null { |
|
||||||
const eTag = zapReceipt.tags.find((t) => t[0] === 'e'); |
|
||||||
return eTag?.[1] || null; |
|
||||||
} |
|
||||||
|
|
||||||
function getZapperPubkey(): string { |
|
||||||
return zapReceipt.pubkey; |
|
||||||
} |
|
||||||
|
|
||||||
function isReply(): boolean { |
|
||||||
// Check if this zap receipt is a reply (has e tag pointing to another event) |
|
||||||
return zapReceipt.tags.some((t) => t[0] === 'e' && t[1] !== zapReceipt.id); |
|
||||||
} |
|
||||||
|
|
||||||
$effect(() => { |
|
||||||
if (contentElement) { |
|
||||||
checkContentHeight(); |
|
||||||
// Use ResizeObserver to detect when content changes (e.g., images loading) |
|
||||||
const observer = new ResizeObserver(() => { |
|
||||||
checkContentHeight(); |
|
||||||
}); |
|
||||||
observer.observe(contentElement); |
|
||||||
return () => observer.disconnect(); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
function checkContentHeight() { |
|
||||||
if (contentElement) { |
|
||||||
// Use requestAnimationFrame to ensure DOM is fully updated |
|
||||||
requestAnimationFrame(() => { |
|
||||||
if (contentElement) { |
|
||||||
needsExpansion = contentElement.scrollHeight > 500; |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function toggleExpanded() { |
|
||||||
expanded = !expanded; |
|
||||||
} |
|
||||||
</script> |
|
||||||
|
|
||||||
<article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}> |
|
||||||
<div class="card-content" class:expanded bind:this={contentElement}> |
|
||||||
{#if parentEvent} |
|
||||||
<div class="zap-reply-context"> |
|
||||||
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light flex items-center gap-1"> |
|
||||||
<Icon name="zap" size={14} /> |
|
||||||
<span>Zapping</span> |
|
||||||
</span> |
|
||||||
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" /> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<div class="zap-header flex items-center gap-2 mb-2"> |
|
||||||
<ProfileBadge pubkey={getZapperPubkey()} /> |
|
||||||
<Icon name="zap" size={18} /> |
|
||||||
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span> |
|
||||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
|
||||||
<div class="ml-auto flex items-center gap-2"> |
|
||||||
<IconButton |
|
||||||
icon="eye" |
|
||||||
label="View" |
|
||||||
size={16} |
|
||||||
onclick={() => goto(getEventLink(zapReceipt))} |
|
||||||
/> |
|
||||||
{#if onReply} |
|
||||||
<IconButton |
|
||||||
icon="message-square" |
|
||||||
label="Reply" |
|
||||||
size={16} |
|
||||||
onclick={() => onReply(zapReceipt)} |
|
||||||
/> |
|
||||||
{/if} |
|
||||||
<EventMenu event={zapReceipt} showContentActions={true} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{#if zapReceipt.content} |
|
||||||
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text"> |
|
||||||
{zapReceipt.content} |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<div class="zap-actions flex items-center gap-4"> |
|
||||||
{#if onReply} |
|
||||||
<button |
|
||||||
onclick={() => onReply(zapReceipt)} |
|
||||||
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
||||||
> |
|
||||||
Reply |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
|
|
||||||
{#if needsExpansion} |
|
||||||
<button |
|
||||||
onclick={toggleExpanded} |
|
||||||
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2" |
|
||||||
> |
|
||||||
{expanded ? 'Show less' : 'Show more'} |
|
||||||
</button> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<div class="kind-badge"> |
|
||||||
<span class="kind-number">{getKindInfo(zapReceipt.kind).number}</span> |
|
||||||
<span class="kind-description">{getKindInfo(zapReceipt.kind).description}</span> |
|
||||||
</div> |
|
||||||
</article> |
|
||||||
|
|
||||||
<style> |
|
||||||
.zap-receipt-reply { |
|
||||||
padding: 1rem; |
|
||||||
margin-bottom: 1rem; |
|
||||||
background: var(--fog-post, #ffffff); |
|
||||||
border: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
border-radius: 0.25rem; |
|
||||||
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */ |
|
||||||
position: relative; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .zap-receipt-reply { |
|
||||||
background: var(--fog-dark-post, #1f2937); |
|
||||||
border-color: var(--fog-dark-border, #374151); |
|
||||||
border-left-color: #fbbf24; |
|
||||||
} |
|
||||||
|
|
||||||
.zap-content { |
|
||||||
line-height: 1.6; |
|
||||||
} |
|
||||||
|
|
||||||
.zap-actions { |
|
||||||
padding-top: 0.5rem; |
|
||||||
padding-right: 6rem; /* Reserve space for kind badge */ |
|
||||||
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
margin-top: 0.5rem; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .zap-actions { |
|
||||||
border-top-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.card-content { |
|
||||||
max-height: 500px; |
|
||||||
overflow: hidden; |
|
||||||
transition: max-height 0.3s ease; |
|
||||||
} |
|
||||||
|
|
||||||
.card-content.expanded { |
|
||||||
max-height: none; |
|
||||||
} |
|
||||||
|
|
||||||
.show-more-button { |
|
||||||
width: 100%; |
|
||||||
text-align: center; |
|
||||||
padding: 0.5rem; |
|
||||||
background: transparent; |
|
||||||
border: none; |
|
||||||
cursor: pointer; |
|
||||||
} |
|
||||||
|
|
||||||
.kind-badge { |
|
||||||
position: absolute; |
|
||||||
bottom: 0.5rem; |
|
||||||
right: 0.5rem; |
|
||||||
display: flex; |
|
||||||
flex-direction: row; |
|
||||||
align-items: center; |
|
||||||
gap: 0.25rem; |
|
||||||
font-size: 0.625rem; |
|
||||||
line-height: 1; |
|
||||||
color: var(--fog-text-light, #52667a); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .kind-badge { |
|
||||||
color: var(--fog-dark-text-light, #a8b8d0); |
|
||||||
} |
|
||||||
|
|
||||||
.kind-number { |
|
||||||
font-weight: 600; |
|
||||||
} |
|
||||||
|
|
||||||
.kind-description { |
|
||||||
font-size: 0.625rem; |
|
||||||
opacity: 0.8; |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -1,184 +0,0 @@ |
|||||||
<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 { KIND } from '../../types/kind-lookup.js'; |
|
||||||
import ZapInvoiceModal from './ZapInvoiceModal.svelte'; |
|
||||||
import Icon from '../../components/ui/Icon.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: [KIND.METADATA], 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 !== KIND.METADATA) { |
|
||||||
// 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" |
|
||||||
data-zap-button |
|
||||||
title="Zap (z)" |
|
||||||
aria-label="Zap" |
|
||||||
> |
|
||||||
<Icon name="zap" size={16} /> |
|
||||||
<span>Zap</span> |
|
||||||
</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; |
|
||||||
line-height: 1.5; |
|
||||||
display: inline-flex; |
|
||||||
align-items: center; |
|
||||||
gap: 0.375rem; |
|
||||||
} |
|
||||||
|
|
||||||
: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, #64748b); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .zap-button:hover { |
|
||||||
background: var(--fog-dark-highlight, #374151); |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -1,174 +0,0 @@ |
|||||||
<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()} |
|
||||||
role="dialog" |
|
||||||
aria-modal="true" |
|
||||||
aria-labelledby="zap-invoice-title" |
|
||||||
tabindex="-1" |
|
||||||
> |
|
||||||
<div class="modal-content"> |
|
||||||
<div class="modal-header"> |
|
||||||
<h2 id="zap-invoice-title">Pay Invoice</h2> |
|
||||||
<button onclick={onClose} class="close-button" aria-label="Close invoice modal">×</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 for="invoice-textarea" class="block text-sm font-semibold mb-2">Invoice:</label> |
|
||||||
<textarea |
|
||||||
id="invoice-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" |
|
||||||
aria-label="Lightning invoice" |
|
||||||
></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, #64748b); |
|
||||||
color: white; |
|
||||||
border: none; |
|
||||||
border-radius: 4px; |
|
||||||
cursor: pointer; |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -1,132 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
||||||
import { onMount } from 'svelte'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
import { KIND } from '../../types/kind-lookup.js'; |
|
||||||
import Icon from '../../components/ui/Icon.svelte'; |
|
||||||
|
|
||||||
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; |
|
||||||
const timeout = 30000; // 30 seconds |
|
||||||
|
|
||||||
try { |
|
||||||
const config = nostrClient.getConfig(); |
|
||||||
const threshold = config.zapThreshold; |
|
||||||
|
|
||||||
// Create a timeout promise |
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => { |
|
||||||
setTimeout(() => reject(new Error('Zap loading timeout')), timeout); |
|
||||||
}); |
|
||||||
|
|
||||||
// Fetch zap receipts (kind 9735) for this event |
|
||||||
const filters: any[] = [ |
|
||||||
{ |
|
||||||
kinds: [KIND.ZAP_RECEIPT], |
|
||||||
'#e': [eventId] |
|
||||||
} |
|
||||||
]; |
|
||||||
|
|
||||||
if (pubkey) { |
|
||||||
filters[0]['#p'] = [pubkey]; |
|
||||||
} |
|
||||||
|
|
||||||
// Race between loading and timeout |
|
||||||
const receipts = await Promise.race([ |
|
||||||
nostrClient.fetchEvents( |
|
||||||
filters, |
|
||||||
[...config.defaultRelays], |
|
||||||
{ useCache: true, cacheResults: true, onUpdate: (updated) => { |
|
||||||
processReceipts(updated); |
|
||||||
}} |
|
||||||
), |
|
||||||
timeoutPromise |
|
||||||
]); |
|
||||||
|
|
||||||
processReceipts(receipts); |
|
||||||
} catch (error) { |
|
||||||
console.error('Error loading zap receipts:', error); |
|
||||||
// On timeout or error, show empty state |
|
||||||
zapReceipts = []; |
|
||||||
totalAmount = 0; |
|
||||||
} 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"> |
|
||||||
<Icon name="zap" size={18} /> |
|
||||||
<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> |
|
||||||
{:else} |
|
||||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">No zaps</span> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
|
|
||||||
<style> |
|
||||||
.zap-receipts { |
|
||||||
display: flex; |
|
||||||
align-items: center; |
|
||||||
display: inline-flex; |
|
||||||
align-items: center; |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -0,0 +1,173 @@ |
|||||||
|
/** |
||||||
|
* Cache prewarming service |
||||||
|
* Preloads common data to make the app work quickly after update |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nostrClient } from '../nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../nostr/relay-manager.js'; |
||||||
|
import { config } from '../nostr/config.js'; |
||||||
|
import { KIND, getFeedKinds } from '../../types/kind-lookup.js'; |
||||||
|
import { sessionManager } from '../auth/session-manager.js'; |
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
|
||||||
|
export interface PrewarmProgress { |
||||||
|
step: string; |
||||||
|
progress: number; // 0-100
|
||||||
|
total: number; |
||||||
|
current: number; |
||||||
|
} |
||||||
|
|
||||||
|
type ProgressCallback = (progress: PrewarmProgress) => void; |
||||||
|
|
||||||
|
/** |
||||||
|
* Prewarm all caches to make app work quickly |
||||||
|
*/ |
||||||
|
export async function prewarmCaches( |
||||||
|
onProgress?: ProgressCallback |
||||||
|
): Promise<void> { |
||||||
|
const steps = [ |
||||||
|
{ name: 'Initializing database...', weight: 5 }, |
||||||
|
{ name: 'Loading user profile...', weight: 10 }, |
||||||
|
{ name: 'Loading user lists...', weight: 15 }, |
||||||
|
{ name: 'Loading recent events...', weight: 20 }, |
||||||
|
{ name: 'Loading profiles...', weight: 15 }, |
||||||
|
{ name: 'Loading RSS feeds...', weight: 10 }, |
||||||
|
{ name: 'Finalizing...', weight: 5 } |
||||||
|
]; |
||||||
|
|
||||||
|
let totalProgress = 0; |
||||||
|
const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0); |
||||||
|
|
||||||
|
const updateProgress = (stepIndex: number, stepProgress: number = 100) => { |
||||||
|
// Calculate progress up to current step
|
||||||
|
let progress = 0; |
||||||
|
for (let i = 0; i < stepIndex; i++) { |
||||||
|
progress += steps[i].weight; |
||||||
|
} |
||||||
|
// Add progress for current step
|
||||||
|
progress += (steps[stepIndex].weight * stepProgress) / 100; |
||||||
|
|
||||||
|
totalProgress = Math.round((progress / totalWeight) * 100); |
||||||
|
|
||||||
|
if (onProgress) { |
||||||
|
onProgress({ |
||||||
|
step: steps[stepIndex].name, |
||||||
|
progress: totalProgress, |
||||||
|
total: steps.length, |
||||||
|
current: stepIndex + 1 |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
try { |
||||||
|
// Step 1: Initialize database
|
||||||
|
updateProgress(0, 0); |
||||||
|
await getDB(); |
||||||
|
updateProgress(0, 100); |
||||||
|
|
||||||
|
// Step 2: Load user profile if logged in
|
||||||
|
updateProgress(1, 0); |
||||||
|
if (sessionManager.isLoggedIn()) { |
||||||
|
const pubkey = sessionManager.getSession()?.pubkey; |
||||||
|
if (pubkey) { |
||||||
|
const profileRelays = relayManager.getProfileReadRelays(); |
||||||
|
await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], |
||||||
|
profileRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
updateProgress(1, 100); |
||||||
|
|
||||||
|
// Step 3: Load user lists (contacts and follow sets)
|
||||||
|
updateProgress(2, 0); |
||||||
|
if (sessionManager.isLoggedIn()) { |
||||||
|
const pubkey = sessionManager.getSession()?.pubkey; |
||||||
|
if (pubkey) { |
||||||
|
const relays = [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays, |
||||||
|
...relayManager.getFeedReadRelays() |
||||||
|
]; |
||||||
|
const uniqueRelays = [...new Set(relays)]; |
||||||
|
|
||||||
|
await Promise.all([ |
||||||
|
nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.CONTACTS], authors: [pubkey], limit: 1 }], |
||||||
|
uniqueRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
), |
||||||
|
nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.FOLLOW_SET], authors: [pubkey] }], |
||||||
|
uniqueRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
) |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
updateProgress(2, 100); |
||||||
|
|
||||||
|
// Step 4: Load recent feed events
|
||||||
|
updateProgress(3, 0); |
||||||
|
const feedKinds = getFeedKinds(); |
||||||
|
const feedRelays = relayManager.getFeedReadRelays(); |
||||||
|
await nostrClient.fetchEvents( |
||||||
|
[{ kinds: feedKinds.slice(0, 10), limit: 50 }], // Load first 10 feed kinds, limit 50 events
|
||||||
|
feedRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
); |
||||||
|
updateProgress(3, 100); |
||||||
|
|
||||||
|
// Step 5: Load profiles for recent events (if logged in)
|
||||||
|
updateProgress(4, 0); |
||||||
|
if (sessionManager.isLoggedIn()) { |
||||||
|
// Get some recent events to extract pubkeys
|
||||||
|
const { getRecentCachedEvents } = await import('./event-cache.js'); |
||||||
|
const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 20); |
||||||
|
const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 20); |
||||||
|
|
||||||
|
if (pubkeys.length > 0) { |
||||||
|
const profileRelays = relayManager.getProfileReadRelays(); |
||||||
|
await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }], |
||||||
|
profileRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
updateProgress(4, 100); |
||||||
|
|
||||||
|
// Step 6: Load RSS feeds (if logged in)
|
||||||
|
updateProgress(5, 0); |
||||||
|
if (sessionManager.isLoggedIn()) { |
||||||
|
const pubkey = sessionManager.getSession()?.pubkey; |
||||||
|
if (pubkey) { |
||||||
|
const relays = [ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays, |
||||||
|
...relayManager.getFeedReadRelays() |
||||||
|
]; |
||||||
|
const uniqueRelays = [...new Set(relays)]; |
||||||
|
|
||||||
|
await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 10 }], |
||||||
|
uniqueRelays, |
||||||
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
updateProgress(5, 100); |
||||||
|
|
||||||
|
// Step 7: Finalize
|
||||||
|
updateProgress(6, 0); |
||||||
|
// Small delay to ensure all operations complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); |
||||||
|
updateProgress(6, 100); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
// Prewarming errors are non-critical - app can still work
|
||||||
|
// Just log and continue
|
||||||
|
console.warn('Cache prewarming encountered errors (non-critical):', error); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
/** |
||||||
|
* Version management and update system |
||||||
|
*/ |
||||||
|
|
||||||
|
const VERSION_STORAGE_KEY = 'aitherboard_version'; |
||||||
|
let cachedVersion: string | null = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the current app version |
||||||
|
* Reads from healthz.json generated at build time, or falls back to package.json version |
||||||
|
*/ |
||||||
|
export async function getAppVersion(): Promise<string> { |
||||||
|
if (cachedVersion) return cachedVersion; |
||||||
|
|
||||||
|
try { |
||||||
|
// Try to read from healthz.json (generated at build time)
|
||||||
|
const response = await fetch('/healthz.json'); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
cachedVersion = (data.version || '0.2.0') as string; |
||||||
|
return cachedVersion; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
// Failed to fetch healthz.json, use fallback
|
||||||
|
} |
||||||
|
|
||||||
|
// Fallback version (should match package.json)
|
||||||
|
cachedVersion = '0.2.0'; |
||||||
|
return cachedVersion; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the current app version synchronously (uses cached value or fallback) |
||||||
|
*/ |
||||||
|
export function getAppVersionSync(): string { |
||||||
|
return cachedVersion || '0.2.0'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the stored version (last version user had) |
||||||
|
*/ |
||||||
|
export function getStoredVersion(): string | null { |
||||||
|
if (typeof window === 'undefined') return null; |
||||||
|
return localStorage.getItem(VERSION_STORAGE_KEY); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a new version is available |
||||||
|
*/ |
||||||
|
export async function isNewVersionAvailable(): Promise<boolean> { |
||||||
|
const stored = getStoredVersion(); |
||||||
|
if (!stored) return true; // First time user
|
||||||
|
const current = await getAppVersion(); |
||||||
|
return stored !== current; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store the current version |
||||||
|
*/ |
||||||
|
export async function storeVersion(version?: string): Promise<void> { |
||||||
|
if (typeof window === 'undefined') return; |
||||||
|
const versionToStore = version || await getAppVersion(); |
||||||
|
localStorage.setItem(VERSION_STORAGE_KEY, versionToStore); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Mark version as updated (user has seen and updated to this version) |
||||||
|
*/ |
||||||
|
export async function markVersionUpdated(): Promise<void> { |
||||||
|
const current = await getAppVersion(); |
||||||
|
await storeVersion(current); |
||||||
|
} |
||||||
@ -0,0 +1,329 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Header from '../../lib/components/layout/Header.svelte'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import Icon from '../../lib/components/ui/Icon.svelte'; |
||||||
|
import { getAppVersion } from '../../lib/services/version-manager.js'; |
||||||
|
|
||||||
|
let appVersion = $state('0.2.0'); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
appVersion = await getAppVersion(); |
||||||
|
}); |
||||||
|
|
||||||
|
function handleBack() { |
||||||
|
if (typeof window !== 'undefined' && window.history.length > 1) { |
||||||
|
window.history.back(); |
||||||
|
} else { |
||||||
|
goto('/'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Changelog for current version |
||||||
|
const changelog: Record<string, string[]> = { |
||||||
|
'0.2.0': [ |
||||||
|
'Version management and update system', |
||||||
|
'About page with product information', |
||||||
|
'Improved user experience with automatic routing to About page on first visit and after updates', |
||||||
|
'Enhanced settings page with About button', |
||||||
|
'Better version tracking and display' |
||||||
|
] |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<Header /> |
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8"> |
||||||
|
<div class="about-page"> |
||||||
|
<div class="about-header"> |
||||||
|
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/About</h1> |
||||||
|
<button |
||||||
|
onclick={handleBack} |
||||||
|
class="back-button flex items-center gap-2" |
||||||
|
aria-label="Go back to previous page" |
||||||
|
> |
||||||
|
<Icon name="arrow-left" size={16} /> |
||||||
|
<span>Back</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="about-content space-y-8"> |
||||||
|
<!-- Product Information --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">About aitherboard</h2> |
||||||
|
<div class="section-content"> |
||||||
|
<p> |
||||||
|
<strong>aitherboard</strong> is a decentralized messageboard built on the <a href="https://nostr.com" target="_blank" rel="noopener noreferrer" class="link">Nostr protocol</a>. |
||||||
|
It provides a modern, accessible interface for participating in the decentralized social web. |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
Built with <strong>Svelte 5</strong> and <strong>TypeScript</strong>, aitherboard offers a clean, minimal design |
||||||
|
inspired by traditional messageboards while leveraging the power of decentralized protocols. |
||||||
|
</p> |
||||||
|
<p> |
||||||
|
All data is stored client-side in your browser using IndexedDB, ensuring privacy and offline access. |
||||||
|
The application works as a Progressive Web App (PWA), allowing you to install it on your device |
||||||
|
for a native-like experience. |
||||||
|
The developer has a strong interest in accessibility and usability, and aitherboard is designed to be accessible to all users, including those who want to stay anonymous. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Features --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">Key Features</h2> |
||||||
|
<div class="section-content"> |
||||||
|
<ul class="features-list"> |
||||||
|
<li><strong>Threads & Discussions</strong> - Create and participate in threaded conversations</li> |
||||||
|
<li><strong>Feed</strong> - Twitter-like feed posts (kind 1) with real-time updates</li> |
||||||
|
<li><strong>Comments</strong> - Flat-threaded comments on threads and posts</li> |
||||||
|
<li><strong>Reactions</strong> - Upvote, downvote, and react to content. Use custom GIFs and emojis.</li> |
||||||
|
<li><strong>Profiles</strong> - View and manage user profiles with payment addresses</li> |
||||||
|
<li><strong>Offline Support</strong> - Full offline access with IndexedDB caching and archiving.</li> |
||||||
|
<li><strong>PWA</strong> - Install as a Progressive Web App</li> |
||||||
|
<li><strong>Search</strong> - Full-text search across content</li> |
||||||
|
<li><strong>Keyboard Shortcuts</strong> - Navigate efficiently with keyboard shortcuts</li> |
||||||
|
<li><strong>Full Search Utility</strong> - Search for content using advanced filters and parameters</li> |
||||||
|
<li><strong>Advanced Markup Editor</strong> - Edit content using advanced markup and formatting options: Markdown and AsciiDoc supported</li> |
||||||
|
<li><strong>Follows supported</strong> - Use any list, including your contact list, to create a feed.</li> |
||||||
|
<li><strong>Repo Viewer</strong> - View and navigate git repositories directly in the app. Report issues to developers, over Nostr. See the project's documenation page.</li> |
||||||
|
<li><strong>Relay Feeds</strong> - See what is happening on the relays you are connected to. Explore new relays.</li> |
||||||
|
<li><strong>Universal Write</strong> - Create events for any kind, with hints for which information is required or optionalfor each kind.</li> |
||||||
|
<li><strong>Everything Marked-up</strong> - Every event displayed supports Markdown or AsciiDoc formatting.</li> |
||||||
|
<li><strong>Universal Read</strong> - View any event, with all its metadata and content. Supports e-books and other publications!</li> |
||||||
|
<li><strong>Hashtag Browsing</strong> - Browse events by hashtags, with real-time updates and search capabilities.</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Version Information --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">Version Information</h2> |
||||||
|
<div class="section-content"> |
||||||
|
<p class="version-info"> |
||||||
|
<strong>Current Version:</strong> <span class="version-badge">{appVersion}</span> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Changelog --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">What's New in Version {appVersion}</h2> |
||||||
|
<div class="section-content"> |
||||||
|
{#if changelog[appVersion]} |
||||||
|
<ul class="changelog-list"> |
||||||
|
{#each changelog[appVersion] as change} |
||||||
|
<li>{change}</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
{:else} |
||||||
|
<p>No changelog available for this version.</p> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Links --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">Links</h2> |
||||||
|
<div class="section-content"> |
||||||
|
<ul class="links-list"> |
||||||
|
<li> |
||||||
|
<a href="https://gitcitadel.com/" target="_blank" rel="noopener noreferrer" class="link"> |
||||||
|
<Icon name="link" size={16} /> |
||||||
|
<span>Homepage</span> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href="https://git.imwald.eu/silberengel/aitherboard.git" target="_blank" rel="noopener noreferrer" class="link"> |
||||||
|
<Icon name="code" size={16} /> |
||||||
|
<span>Repository</span> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href="https://github.com/nostr-protocol/nips" target="_blank" rel="noopener noreferrer" class="link"> |
||||||
|
<Icon name="link" size={16} /> |
||||||
|
<span>Nostr spec repo: Nostr Implementation Proposals (NIPS)</span> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<!-- Author --> |
||||||
|
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded"> |
||||||
|
<h2 class="section-title">Author</h2> |
||||||
|
<div class="section-content"> |
||||||
|
<p> |
||||||
|
Created by <a href="https://aitherboard.imwald.eu/profile/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z" target="_blank" rel="noopener noreferrer" class="link">silberengel@gitcitadel.com</a> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</main> |
||||||
|
|
||||||
|
<style> |
||||||
|
.about-page { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.about-header { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 1rem; |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.back-button { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border: 1px solid var(--fog-border, #cbd5e1); |
||||||
|
border-radius: 4px; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1e293b); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
font-size: 0.875em; |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.back-button:hover { |
||||||
|
background: var(--fog-highlight, #f1f5f9); |
||||||
|
border-color: var(--fog-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .back-button { |
||||||
|
background: var(--fog-dark-post, #334155); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .back-button:hover { |
||||||
|
background: var(--fog-dark-highlight, #475569); |
||||||
|
border-color: var(--fog-dark-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
.about-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.about-section { |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.section-title { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
font-size: 1.25rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-text, #1e293b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .section-title { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.section-content { |
||||||
|
color: var(--fog-text, #475569); |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .section-content { |
||||||
|
color: var(--fog-dark-text, #cbd5e1); |
||||||
|
} |
||||||
|
|
||||||
|
.section-content p { |
||||||
|
margin: 0 0 1rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.section-content p:last-child { |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.link { |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
text-decoration: underline; |
||||||
|
transition: color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.link:hover { |
||||||
|
color: var(--fog-text, #1e293b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .link { |
||||||
|
color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .link:hover { |
||||||
|
color: var(--fog-dark-text, #f1f5f9); |
||||||
|
} |
||||||
|
|
||||||
|
.features-list { |
||||||
|
list-style: disc; |
||||||
|
padding-left: 1.5rem; |
||||||
|
margin: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.features-list li { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.version-info { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
flex-wrap: wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.version-badge { |
||||||
|
display: inline-block; |
||||||
|
padding: 0.25rem 0.75rem; |
||||||
|
background: var(--fog-accent, #64748b); |
||||||
|
color: white; |
||||||
|
border-radius: 4px; |
||||||
|
font-family: monospace; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .version-badge { |
||||||
|
background: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.changelog-list { |
||||||
|
list-style: disc; |
||||||
|
padding-left: 1.5rem; |
||||||
|
margin: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.changelog-list li { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.links-list { |
||||||
|
list-style: none; |
||||||
|
padding: 0; |
||||||
|
margin: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.links-list li { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.links-list .link { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue