15 changed files with 834 additions and 240 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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