|
|
|
|
@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
|
|
|
|
|
import QuotedContext from '../../components/content/QuotedContext.svelte'; |
|
|
|
|
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; |
|
|
|
|
import MediaViewer from '../../components/content/MediaViewer.svelte'; |
|
|
|
|
import CommentForm from '../comments/CommentForm.svelte'; |
|
|
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
|
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; |
|
|
|
|
import { stripMarkdown } from '../../services/text-utils.js'; |
|
|
|
|
@ -15,26 +16,62 @@
@@ -15,26 +16,62 @@
|
|
|
|
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
|
|
|
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; |
|
|
|
|
import { nip19 } from 'nostr-tools'; |
|
|
|
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
|
|
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
|
|
|
import { config } from '../../services/nostr/config.js'; |
|
|
|
|
import { onMount } from 'svelte'; |
|
|
|
|
|
|
|
|
|
interface Props { |
|
|
|
|
post: NostrEvent; |
|
|
|
|
fullView?: boolean; // If true, show full content (markdown, media, profile pics, reactions) - for drawer |
|
|
|
|
fullView?: boolean; // If true, show full content (markdown, media, profile pics, reactions) |
|
|
|
|
preloadedReactions?: NostrEvent[]; // Pre-loaded reactions to avoid duplicate fetches |
|
|
|
|
parentEvent?: NostrEvent; // Optional parent event if already loaded |
|
|
|
|
quotedEvent?: NostrEvent; // Optional quoted event if already loaded |
|
|
|
|
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onOpenEvent }: Props = $props(); |
|
|
|
|
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent }: Props = $props(); |
|
|
|
|
|
|
|
|
|
// Check if this event is bookmarked (async, so we use state) |
|
|
|
|
// Only check if user is logged in |
|
|
|
|
let bookmarked = $state(false); |
|
|
|
|
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
|
|
|
|
|
|
|
|
|
// Collapse state for feed view |
|
|
|
|
let isExpanded = $state(false); |
|
|
|
|
let shouldCollapse = $state(false); |
|
|
|
|
let cardElement: HTMLElement | null = $state(null); |
|
|
|
|
let contentElement: HTMLElement | null = $state(null); |
|
|
|
|
|
|
|
|
|
// Reply state |
|
|
|
|
let showReplyForm = $state(false); |
|
|
|
|
|
|
|
|
|
// Check if card should be collapsed (only in feed view) |
|
|
|
|
$effect(() => { |
|
|
|
|
if (fullView) { |
|
|
|
|
shouldCollapse = false; |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Wait for content to render, then check height |
|
|
|
|
const checkHeight = () => { |
|
|
|
|
if (!cardElement) return; |
|
|
|
|
|
|
|
|
|
// Measure the full card height (using scrollHeight to get full content height) |
|
|
|
|
const cardHeight = cardElement.scrollHeight; |
|
|
|
|
shouldCollapse = cardHeight > 500; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
// Check after content is rendered |
|
|
|
|
const timeoutId = setTimeout(() => { |
|
|
|
|
requestAnimationFrame(() => { |
|
|
|
|
requestAnimationFrame(checkHeight); |
|
|
|
|
}); |
|
|
|
|
}, 200); |
|
|
|
|
|
|
|
|
|
return () => clearTimeout(timeoutId); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
function toggleExpand() { |
|
|
|
|
isExpanded = !isExpanded; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
$effect(() => { |
|
|
|
|
if (isLoggedIn) { |
|
|
|
|
isBookmarked(post.id).then(b => { |
|
|
|
|
@ -337,95 +374,29 @@
@@ -337,95 +374,29 @@
|
|
|
|
|
return finalSegments.length > 0 ? finalSegments : segments; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Handle clicking on event links - fetch and open in side panel |
|
|
|
|
async function handleEventLinkClick(e: MouseEvent, eventId: string) { |
|
|
|
|
e.preventDefault(); |
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
|
|
|
|
console.log('handleEventLinkClick called with eventId:', eventId, 'onOpenEvent:', !!onOpenEvent); |
|
|
|
|
|
|
|
|
|
if (!onOpenEvent) { |
|
|
|
|
console.warn('onOpenEvent callback not provided'); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function getEventUrl(eventId: string): string { |
|
|
|
|
// Decode bech32 or use hex directly to get the event ID |
|
|
|
|
try { |
|
|
|
|
// Ensure nostr client is initialized |
|
|
|
|
await nostrClient.initialize(); |
|
|
|
|
|
|
|
|
|
// Decode bech32 if needed, or use hex ID directly |
|
|
|
|
let actualEventId: string | null = null; |
|
|
|
|
let kind: number | undefined; |
|
|
|
|
let pubkey: string | undefined; |
|
|
|
|
let dTag: string | undefined; |
|
|
|
|
|
|
|
|
|
// Check if it's a bech32 string |
|
|
|
|
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(eventId)) { |
|
|
|
|
try { |
|
|
|
|
const decoded = nip19.decode(eventId); |
|
|
|
|
if (decoded.type === 'note') { |
|
|
|
|
actualEventId = String(decoded.data); |
|
|
|
|
} else if (decoded.type === 'nevent') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
|
|
|
|
actualEventId = String(decoded.data.id); |
|
|
|
|
} |
|
|
|
|
} else if (decoded.type === 'naddr') { |
|
|
|
|
if (decoded.data && typeof decoded.data === 'object') { |
|
|
|
|
kind = decoded.data.kind; |
|
|
|
|
pubkey = String(decoded.data.pubkey); |
|
|
|
|
dTag = decoded.data.identifier; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error decoding bech32:', error, 'eventId:', eventId); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
} else if (/^[0-9a-f]{64}$/i.test(eventId)) { |
|
|
|
|
// It's already a hex ID |
|
|
|
|
actualEventId = eventId.toLowerCase(); |
|
|
|
|
if (eventId.length === 64 && /^[a-f0-9]{64}$/i.test(eventId)) { |
|
|
|
|
// Already hex - use it directly |
|
|
|
|
return `/event/${eventId}`; |
|
|
|
|
} else { |
|
|
|
|
console.error('Invalid event ID format:', eventId); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Fetch the event |
|
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
|
|
|
let event: NostrEvent | null = null; |
|
|
|
|
|
|
|
|
|
if (actualEventId) { |
|
|
|
|
// Fetch by event ID |
|
|
|
|
const events = await nostrClient.fetchEvents( |
|
|
|
|
[{ ids: [actualEventId] }], |
|
|
|
|
relays, |
|
|
|
|
{ timeout: config.singleRelayTimeout } |
|
|
|
|
); |
|
|
|
|
event = events[0] || null; |
|
|
|
|
if (!event) { |
|
|
|
|
console.warn('Event not found for ID:', actualEventId); |
|
|
|
|
} |
|
|
|
|
} else if (kind !== undefined && pubkey && dTag) { |
|
|
|
|
// Fetch naddr by kind+pubkey+d |
|
|
|
|
const events = await nostrClient.fetchEvents( |
|
|
|
|
[{ kinds: [kind], authors: [pubkey], '#d': [dTag] }], |
|
|
|
|
relays, |
|
|
|
|
{ timeout: config.singleRelayTimeout } |
|
|
|
|
); |
|
|
|
|
event = events[0] || null; |
|
|
|
|
if (!event) { |
|
|
|
|
console.warn('Event not found for naddr:', { kind, pubkey, dTag }); |
|
|
|
|
// Try to decode bech32 |
|
|
|
|
const decoded = nip19.decode(eventId); |
|
|
|
|
if (decoded.type === 'note' || decoded.type === 'nevent') { |
|
|
|
|
const id = decoded.type === 'note' ? String(decoded.data) : (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data ? String(decoded.data.id) : eventId); |
|
|
|
|
return `/event/${id}`; |
|
|
|
|
} else if (decoded.type === 'naddr') { |
|
|
|
|
// For naddr, use the bech32 string in the URL |
|
|
|
|
return `/event/${eventId}`; |
|
|
|
|
} else { |
|
|
|
|
return `/event/${eventId}`; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (event && onOpenEvent) { |
|
|
|
|
console.log('handleEventLinkClick: Opening event in side panel', event.id, event.kind); |
|
|
|
|
onOpenEvent(event); |
|
|
|
|
} else if (!event) { |
|
|
|
|
console.warn('Could not fetch event to open in side panel'); |
|
|
|
|
} else if (!onOpenEvent) { |
|
|
|
|
console.warn('onOpenEvent callback not available'); |
|
|
|
|
} |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error fetching event for link:', error, 'eventId:', eventId); |
|
|
|
|
} catch (decodeError) { |
|
|
|
|
// If decoding fails, use the ID as-is |
|
|
|
|
return `/event/${eventId}`; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -569,10 +540,12 @@
@@ -569,10 +540,12 @@
|
|
|
|
|
</script> |
|
|
|
|
|
|
|
|
|
<article |
|
|
|
|
bind:this={cardElement} |
|
|
|
|
class="Feed-post" |
|
|
|
|
data-post-id={post.id} |
|
|
|
|
id="event-{post.id}" |
|
|
|
|
data-event-id={post.id} |
|
|
|
|
class:collapsed={!fullView && shouldCollapse && !isExpanded} |
|
|
|
|
> |
|
|
|
|
{#if fullView} |
|
|
|
|
<!-- Full view: show complete content with markdown, media, profile pics, reactions --> |
|
|
|
|
@ -581,7 +554,6 @@
@@ -581,7 +554,6 @@
|
|
|
|
|
parentEvent={providedParentEvent || undefined} |
|
|
|
|
parentEventId={getReplyEventId() || undefined} |
|
|
|
|
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined} |
|
|
|
|
onOpenEvent={onOpenEvent} |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
@ -590,33 +562,21 @@
@@ -590,33 +562,21 @@
|
|
|
|
|
quotedEvent={providedQuotedEvent} |
|
|
|
|
quotedEventId={getQuotedEventId() || undefined} |
|
|
|
|
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined} |
|
|
|
|
onOpenEvent={onOpenEvent} |
|
|
|
|
/> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} /> |
|
|
|
|
|
|
|
|
|
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD} |
|
|
|
|
{@const title = getTitle()} |
|
|
|
|
{#if title && title !== 'Untitled'} |
|
|
|
|
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> |
|
|
|
|
{title} |
|
|
|
|
</h2> |
|
|
|
|
{/if} |
|
|
|
|
{@const title = getTitle()} |
|
|
|
|
{#if title && title !== 'Untitled'} |
|
|
|
|
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> |
|
|
|
|
{title} |
|
|
|
|
</h2> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
<div class="post-header flex flex-col gap-2 mb-2"> |
|
|
|
|
<div class="flex items-center gap-2 flex-wrap"> |
|
|
|
|
<div class="ml-auto flex items-center gap-2 flex-shrink-0"> |
|
|
|
|
{#if onOpenEvent} |
|
|
|
|
<button |
|
|
|
|
onclick={(e) => { e.stopPropagation(); onOpenEvent(post); }} |
|
|
|
|
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
|
|
|
style="font-size: 0.875em;" |
|
|
|
|
> |
|
|
|
|
View thread |
|
|
|
|
</button> |
|
|
|
|
{/if} |
|
|
|
|
<div class="flex items-center justify-end gap-2 flex-nowrap"> |
|
|
|
|
<div class="post-header-actions flex items-center gap-2 flex-shrink-0"> |
|
|
|
|
{#if isLoggedIn && bookmarked} |
|
|
|
|
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> |
|
|
|
|
{/if} |
|
|
|
|
@ -640,6 +600,7 @@
@@ -640,6 +600,7 @@
|
|
|
|
|
{/if} |
|
|
|
|
{/if} |
|
|
|
|
</div> |
|
|
|
|
<hr class="post-header-divider" /> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="post-content mb-2"> |
|
|
|
|
@ -653,23 +614,6 @@
@@ -653,23 +614,6 @@
|
|
|
|
|
{:else} |
|
|
|
|
<!-- Feed view: plaintext only, no profile pics, media as URLs --> |
|
|
|
|
<div class="post-header flex flex-col gap-2 mb-2"> |
|
|
|
|
<div class="flex items-center gap-2 flex-wrap"> |
|
|
|
|
<div class="ml-auto flex items-center gap-2 flex-shrink-0"> |
|
|
|
|
{#if onOpenEvent} |
|
|
|
|
<button |
|
|
|
|
onclick={(e) => { e.stopPropagation(); onOpenEvent(post); }} |
|
|
|
|
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
|
|
|
style="font-size: 0.875em;" |
|
|
|
|
> |
|
|
|
|
View thread |
|
|
|
|
</button> |
|
|
|
|
{/if} |
|
|
|
|
{#if isLoggedIn && bookmarked} |
|
|
|
|
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> |
|
|
|
|
{/if} |
|
|
|
|
<EventMenu event={post} showContentActions={true} /> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
<div class="flex items-center gap-2 flex-wrap"> |
|
|
|
|
<ProfileBadge pubkey={post.pubkey} inline={true} /> |
|
|
|
|
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span> |
|
|
|
|
@ -687,18 +631,17 @@
@@ -687,18 +631,17 @@
|
|
|
|
|
{/if} |
|
|
|
|
{/if} |
|
|
|
|
</div> |
|
|
|
|
<hr class="post-header-divider" /> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD} |
|
|
|
|
{@const title = getTitle()} |
|
|
|
|
{#if title && title !== 'Untitled'} |
|
|
|
|
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> |
|
|
|
|
{title} |
|
|
|
|
</h2> |
|
|
|
|
{/if} |
|
|
|
|
{@const title = getTitle()} |
|
|
|
|
{#if title && title !== 'Untitled'} |
|
|
|
|
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> |
|
|
|
|
{title} |
|
|
|
|
</h2> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
<div class="post-content mb-2"> |
|
|
|
|
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}> |
|
|
|
|
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap"> |
|
|
|
|
{#each parseContentWithNIP21Links() as segment} |
|
|
|
|
{#if segment.type === 'text'} |
|
|
|
|
@ -706,13 +649,14 @@
@@ -706,13 +649,14 @@
|
|
|
|
|
{:else if segment.type === 'profile' && segment.pubkey} |
|
|
|
|
<ProfileBadge pubkey={segment.pubkey} inline={true} /> |
|
|
|
|
{:else if segment.type === 'event' && segment.eventId} |
|
|
|
|
<button |
|
|
|
|
type="button" |
|
|
|
|
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline bg-transparent border-none p-0 cursor-pointer" |
|
|
|
|
onclick={(e) => handleEventLinkClick(e, segment.eventId!)} |
|
|
|
|
<a |
|
|
|
|
href={getEventUrl(segment.eventId)} |
|
|
|
|
target="_blank" |
|
|
|
|
rel="noopener noreferrer" |
|
|
|
|
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
|
|
|
> |
|
|
|
|
{segment.content} |
|
|
|
|
</button> |
|
|
|
|
</a> |
|
|
|
|
{:else if segment.type === 'url' && segment.url} |
|
|
|
|
{@const isMediaUrl = /\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|ogg|flac|aac|m4a)(\?|#|$)/i.test(segment.url)} |
|
|
|
|
{#if isMediaUrl} |
|
|
|
|
@ -770,14 +714,70 @@
@@ -770,14 +714,70 @@
|
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{#if !fullView && shouldCollapse} |
|
|
|
|
<div class="show-more-container"> |
|
|
|
|
<button |
|
|
|
|
onclick={toggleExpand} |
|
|
|
|
class="show-more-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
|
|
|
style="font-size: 0.875em;" |
|
|
|
|
> |
|
|
|
|
{isExpanded ? 'Show Less' : 'Show More'} |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
{#if !fullView} |
|
|
|
|
<div class="feed-card-footer flex items-center justify-between"> |
|
|
|
|
<div class="feed-card-actions flex items-center gap-2"> |
|
|
|
|
{#if isLoggedIn && bookmarked} |
|
|
|
|
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> |
|
|
|
|
{/if} |
|
|
|
|
<EventMenu event={post} showContentActions={true} /> |
|
|
|
|
</div> |
|
|
|
|
<div class="kind-badge feed-card-kind-badge"> |
|
|
|
|
<span class="kind-number">{getKindInfo(post.kind).number}</span> |
|
|
|
|
<span class="kind-description">{getKindInfo(post.kind).description}</span> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
<div class="kind-badge"> |
|
|
|
|
<span class="kind-number">{getKindInfo(post.kind).number}</span> |
|
|
|
|
<span class="kind-description">{getKindInfo(post.kind).description}</span> |
|
|
|
|
</div> |
|
|
|
|
{#if fullView} |
|
|
|
|
<div class="kind-badge"> |
|
|
|
|
<span class="kind-number">{getKindInfo(post.kind).number}</span> |
|
|
|
|
<span class="kind-description">{getKindInfo(post.kind).description}</span> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
</article> |
|
|
|
|
|
|
|
|
|
{#if isLoggedIn} |
|
|
|
|
<div class="reply-section mb-2"> |
|
|
|
|
<button |
|
|
|
|
onclick={() => showReplyForm = !showReplyForm} |
|
|
|
|
class="reply-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
|
|
|
style="font-size: 0.875em;" |
|
|
|
|
> |
|
|
|
|
Reply |
|
|
|
|
</button> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{#if showReplyForm} |
|
|
|
|
<div class="reply-form-container mb-4"> |
|
|
|
|
<CommentForm |
|
|
|
|
threadId={post.id} |
|
|
|
|
rootEvent={post} |
|
|
|
|
onPublished={() => { |
|
|
|
|
showReplyForm = false; |
|
|
|
|
}} |
|
|
|
|
onCancel={() => { |
|
|
|
|
showReplyForm = false; |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
{/if} |
|
|
|
|
{/if} |
|
|
|
|
|
|
|
|
|
{#if mediaViewerUrl && mediaViewerOpen} |
|
|
|
|
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} /> |
|
|
|
|
{/if} |
|
|
|
|
@ -789,6 +789,19 @@
@@ -789,6 +789,19 @@
|
|
|
|
|
background: var(--fog-post, #ffffff); |
|
|
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
|
|
border-radius: 0.25rem; |
|
|
|
|
position: relative; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.Feed-post.collapsed { |
|
|
|
|
max-height: 500px; |
|
|
|
|
overflow: hidden; |
|
|
|
|
display: flex; |
|
|
|
|
flex-direction: column; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.Feed-post.collapsed .post-content.collapsed-content { |
|
|
|
|
flex: 1; |
|
|
|
|
min-height: 0; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .Feed-post { |
|
|
|
|
@ -796,6 +809,40 @@
@@ -796,6 +809,40 @@
|
|
|
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-content.collapsed-content { |
|
|
|
|
max-height: 350px; |
|
|
|
|
overflow: hidden; |
|
|
|
|
position: relative; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-content.collapsed-content::after { |
|
|
|
|
content: ''; |
|
|
|
|
position: absolute; |
|
|
|
|
bottom: 0; |
|
|
|
|
left: 0; |
|
|
|
|
right: 0; |
|
|
|
|
height: 60px; |
|
|
|
|
background: linear-gradient(to bottom, transparent, var(--fog-post, #ffffff)); |
|
|
|
|
pointer-events: none; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .post-content.collapsed-content::after { |
|
|
|
|
background: linear-gradient(to bottom, transparent, var(--fog-dark-post, #1f2937)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.show-more-container { |
|
|
|
|
text-align: center; |
|
|
|
|
padding: 0.5rem 0; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.show-more-btn { |
|
|
|
|
background: none; |
|
|
|
|
border: none; |
|
|
|
|
cursor: pointer; |
|
|
|
|
padding: 0.25rem 0.5rem; |
|
|
|
|
font-weight: 500; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-content { |
|
|
|
|
line-height: 1.6; |
|
|
|
|
word-wrap: break-word; |
|
|
|
|
@ -839,6 +886,23 @@
@@ -839,6 +886,23 @@
|
|
|
|
|
border-top-color: var(--fog-dark-border, #374151); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.feed-card-footer { |
|
|
|
|
margin-top: 0.5rem; |
|
|
|
|
padding-top: 0.5rem; |
|
|
|
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
|
|
|
flex-shrink: 0; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .feed-card-footer { |
|
|
|
|
border-top-color: var(--fog-dark-border, #374151); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.feed-card-actions { |
|
|
|
|
display: flex; |
|
|
|
|
align-items: center; |
|
|
|
|
gap: 0.5rem; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.kind-badge { |
|
|
|
|
position: absolute; |
|
|
|
|
bottom: 0.5rem; |
|
|
|
|
@ -852,6 +916,10 @@
@@ -852,6 +916,10 @@
|
|
|
|
|
color: var(--fog-text-light, #9ca3af); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.feed-card-kind-badge { |
|
|
|
|
position: static; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .kind-badge { |
|
|
|
|
color: var(--fog-dark-text-light, #6b7280); |
|
|
|
|
} |
|
|
|
|
@ -876,6 +944,21 @@
@@ -876,6 +944,21 @@
|
|
|
|
|
line-height: 1.5; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-header-actions { |
|
|
|
|
flex-shrink: 0; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-header-divider { |
|
|
|
|
margin: 0 0 0.5rem 0; |
|
|
|
|
border: none; |
|
|
|
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
|
|
|
width: 100%; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
:global(.dark) .post-header-divider { |
|
|
|
|
border-top-color: var(--fog-dark-border, #374151); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
.post-header :global(.profile-badge) { |
|
|
|
|
display: inline-flex; |
|
|
|
|
align-items: center; |
|
|
|
|
|