15 changed files with 834 additions and 240 deletions
@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
|
||||
<script lang="ts"> |
||||
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { getKindInfo } from '../../types/kind-lookup.js'; |
||||
|
||||
interface Props { |
||||
event: NostrEvent; |
||||
} |
||||
|
||||
let { event }: Props = $props(); |
||||
|
||||
function getDTag(): string | null { |
||||
const dTag = event.tags.find((t) => t[0] === 'd'); |
||||
return dTag?.[1] || null; |
||||
} |
||||
|
||||
function getWikistrUrl(): string | null { |
||||
const dTag = getDTag(); |
||||
if (!dTag) return null; |
||||
return `https://wikistr.imwald.eu/${dTag}*${event.pubkey}`; |
||||
} |
||||
|
||||
function getRelativeTime(): string { |
||||
const now = Math.floor(Date.now() / 1000); |
||||
const diff = now - event.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 = event.tags.find((t) => t[0] === 'client'); |
||||
return clientTag?.[1] || null; |
||||
} |
||||
|
||||
</script> |
||||
|
||||
<article class="replaceable-event-card"> |
||||
<div class="card-header flex items-center justify-between mb-2"> |
||||
<div class="flex items-center gap-2"> |
||||
<ProfileBadge pubkey={event.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> |
||||
|
||||
<div class="card-content mb-2"> |
||||
{#if getDTag()} |
||||
<div class="d-tag-display mb-2"> |
||||
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text">d-tag:</span> |
||||
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light ml-1">{getDTag()}</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if event.content} |
||||
<div class="content-preview text-sm text-fog-text dark:text-fog-dark-text mb-2"> |
||||
{event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="card-actions"> |
||||
{#if getWikistrUrl()} |
||||
<a |
||||
href={getWikistrUrl()} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="wikistr-link inline-flex items-center gap-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm" |
||||
> |
||||
<span>View on wikistr</span> |
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> |
||||
</svg> |
||||
</a> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="kind-badge"> |
||||
<span class="kind-number">{getKindInfo(event.kind).number}</span> |
||||
<span class="kind-description">{getKindInfo(event.kind).description}</span> |
||||
</div> |
||||
</article> |
||||
|
||||
<style> |
||||
.replaceable-event-card { |
||||
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 var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .replaceable-event-card { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
border-left-color: var(--fog-dark-accent, #64748b); |
||||
} |
||||
|
||||
.card-header { |
||||
padding-bottom: 0.5rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .card-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.content-preview { |
||||
line-height: 1.6; |
||||
} |
||||
|
||||
.wikistr-link { |
||||
text-decoration: none; |
||||
transition: opacity 0.2s; |
||||
} |
||||
|
||||
.wikistr-link:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.kind-badge { |
||||
position: absolute; |
||||
bottom: 0.5rem; |
||||
right: 0.5rem; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: flex-end; |
||||
gap: 0.125rem; |
||||
font-size: 0.625rem; |
||||
line-height: 1; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .kind-badge { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.kind-number { |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.kind-description { |
||||
font-size: 0.5rem; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.replaceable-event-card { |
||||
position: relative; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
|
||||
/** |
||||
* Kind number to description lookup |
||||
* Based on NIPs and common Nostr event kinds |
||||
*/ |
||||
|
||||
export interface KindInfo { |
||||
number: number; |
||||
description: string; |
||||
showInFeed?: boolean; // Whether this kind should be displayed on the Feed page
|
||||
isReplaceable?: boolean; // Whether this is a replaceable event (requires d-tag)
|
||||
isSecondaryKind?: boolean; // Whether this is a secondary kind (used to display the main kind)
|
||||
} |
||||
|
||||
export const KIND_LOOKUP: Record<number, KindInfo> = { |
||||
// Core kinds
|
||||
0: { number: 0, description: 'Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
1: { number: 1, description: 'Short Text Note', showInFeed: true, isReplaceable: false }, |
||||
3: { number: 3, description: 'Contacts', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
24: { number: 4, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
5: { number: 5, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Replaceable events
|
||||
30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, |
||||
30041: { number: 30041, description: 'Publication Content', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, |
||||
30040: { number: 30040, description: 'Curated Publication or E-Book', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, |
||||
30817: { number: 30817, description: 'Wiki Page (Markdown)', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, |
||||
30818: { number: 30818, description: 'Wiki Page (Asciidoc)', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, |
||||
|
||||
// Threads and comments
|
||||
11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Media
|
||||
20: { number: 20, description: 'Picture Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
21: { number: 21, description: 'Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
22: { number: 22, description: 'Short Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
1222: { number: 23, description: 'Voice Note (Yak)', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
1244: { number: 24, description: 'Voice Reply (Yak Back)', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Polls
|
||||
1068: { number: 1068, description: 'Poll', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, |
||||
1018: { number: 1018, description: 'Poll Response', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Labels
|
||||
1985: { number: 1985, description: 'Label', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// User status
|
||||
30315: { number: 30315, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Zaps
|
||||
9735: { number: 9735, description: 'Zap Receipt', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, |
||||
|
||||
// Relay lists
|
||||
10002: { number: 10002, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Blocked relays
|
||||
10006: { number: 10006, description: 'Blocked Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Favorite relays
|
||||
10012: { number: 10012, description: 'Favorite Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Interest lists
|
||||
10015: { number: 10015, description: 'Interest List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Local relays
|
||||
10432: { number: 10432, description: 'Local Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Mute lists
|
||||
10000: { number: 10000, description: 'Mute List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Pin lists
|
||||
10001: { number: 10001, description: 'Pin List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// Payment addresses
|
||||
10133: { number: 10133, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, |
||||
|
||||
// RSS feeds
|
||||
10895: { number: 10895, description: 'RSS Feed', showInFeed: false, isReplaceable: false } |
||||
|
||||
}; |
||||
|
||||
/** |
||||
* Get kind info for a given kind number |
||||
*/ |
||||
export function getKindInfo(kind: number): KindInfo { |
||||
return KIND_LOOKUP[kind] || { number: kind, description: `Kind ${kind}`, showInFeed: false }; |
||||
} |
||||
|
||||
/** |
||||
* Get kind description for a given kind number |
||||
*/ |
||||
export function getKindDescription(kind: number): string { |
||||
return getKindInfo(kind).description; |
||||
} |
||||
|
||||
/** |
||||
* Get all kinds that should be displayed in the Feed |
||||
*/ |
||||
export function getFeedKinds(): number[] { |
||||
return Object.values(KIND_LOOKUP) |
||||
.filter(kind => kind.showInFeed === true) |
||||
.map(kind => kind.number); |
||||
} |
||||
|
||||
/** |
||||
* Get all replaceable event kinds (that require d-tags) |
||||
*/ |
||||
export function getReplaceableKinds(): number[] { |
||||
return Object.values(KIND_LOOKUP) |
||||
.filter(kind => kind.isReplaceable === true) |
||||
.map(kind => kind.number); |
||||
} |
||||
Loading…
Reference in new issue