9 changed files with 1141 additions and 174 deletions
@ -0,0 +1,135 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId |
||||||
|
quotedEventId?: string; // Optional - used to load quoted event if not provided |
||||||
|
targetId?: string; // Optional ID to scroll to (defaults to quoted event ID) |
||||||
|
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded |
||||||
|
} |
||||||
|
|
||||||
|
let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded }: Props = $props(); |
||||||
|
|
||||||
|
let loadedQuotedEvent = $state<NostrEvent | null>(null); |
||||||
|
let loadingQuoted = $state(false); |
||||||
|
|
||||||
|
// Derive the effective quoted event: prefer provided, fall back to loaded |
||||||
|
let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent); |
||||||
|
|
||||||
|
// Sync provided quoted event changes and load if needed |
||||||
|
$effect(() => { |
||||||
|
if (providedQuotedEvent) { |
||||||
|
// If provided quoted event is available, use it |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If no provided quoted event and we have an ID, try to load it |
||||||
|
if (!loadedQuotedEvent && quotedEventId && !loadingQuoted) { |
||||||
|
loadQuotedEvent(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadQuotedEvent() { |
||||||
|
const eventId = quotedEventId || quotedEvent?.id; |
||||||
|
if (!eventId || loadingQuoted) return; |
||||||
|
|
||||||
|
loadingQuoted = true; |
||||||
|
try { |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [1], ids: [eventId] }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
loadedQuotedEvent = events[0]; |
||||||
|
if (onQuotedLoaded) { |
||||||
|
onQuotedLoaded(loadedQuotedEvent); |
||||||
|
} |
||||||
|
// After loading, try to scroll to it |
||||||
|
setTimeout(() => scrollToQuoted(), 100); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading quoted event:', error); |
||||||
|
} finally { |
||||||
|
loadingQuoted = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getQuotedPreview(): string { |
||||||
|
if (!quotedEvent) { |
||||||
|
return loadingQuoted ? 'Loading...' : 'Quoted event not found'; |
||||||
|
} |
||||||
|
// Create preview from quoted event (first 100 chars, plaintext) |
||||||
|
const plaintext = quotedEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim(); |
||||||
|
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); |
||||||
|
} |
||||||
|
|
||||||
|
async function scrollToQuoted() { |
||||||
|
const eventId = quotedEvent?.id || quotedEventId; |
||||||
|
if (!eventId) return; |
||||||
|
|
||||||
|
// If quoted event not loaded yet, load it first |
||||||
|
if (!quotedEvent && quotedEventId) { |
||||||
|
await loadQuotedEvent(); |
||||||
|
} |
||||||
|
|
||||||
|
const elementId = targetId || `event-${eventId}`; |
||||||
|
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); |
||||||
|
|
||||||
|
// If still not found, wait a bit for DOM to update |
||||||
|
if (!element) { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); |
||||||
|
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); |
||||||
|
} |
||||||
|
|
||||||
|
if (element) { |
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
||||||
|
element.classList.add('highlight-quoted'); |
||||||
|
setTimeout(() => { |
||||||
|
element?.classList.remove('highlight-quoted'); |
||||||
|
}, 2000); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div |
||||||
|
class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity" |
||||||
|
onclick={scrollToQuoted} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Enter' || e.key === ' ') { |
||||||
|
e.preventDefault(); |
||||||
|
scrollToQuoted(); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
<span class="font-semibold">Quoting:</span> {getQuotedPreview()} |
||||||
|
{#if loadingQuoted} |
||||||
|
<span class="text-xs opacity-70"> (loading...)</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.quoted-context { |
||||||
|
border-left: 2px solid var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .quoted-context { |
||||||
|
border-left-color: var(--fog-dark-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.highlight-quoted) { |
||||||
|
outline: 2px solid var(--fog-accent, #64748b); |
||||||
|
outline-offset: 2px; |
||||||
|
transition: outline 0.3s ease; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark .highlight-quoted) { |
||||||
|
outline-color: var(--fog-dark-accent, #64748b); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,440 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { fade, slide } from 'svelte/transition'; |
||||||
|
import FeedPost from './FeedPost.svelte'; |
||||||
|
import ZapReceiptReply from './ZapReceiptReply.svelte'; |
||||||
|
import Comment from '../comments/Comment.svelte'; |
||||||
|
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
opEvent: NostrEvent | null; // The original post/event |
||||||
|
isOpen: boolean; |
||||||
|
onClose: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { opEvent, isOpen, onClose }: Props = $props(); |
||||||
|
|
||||||
|
let loading = $state(false); |
||||||
|
let threadEvents = $state<NostrEvent[]>([]); |
||||||
|
let reactions = $state<NostrEvent[]>([]); |
||||||
|
|
||||||
|
// Load thread when drawer opens |
||||||
|
$effect(() => { |
||||||
|
if (isOpen && opEvent) { |
||||||
|
loadThread(); |
||||||
|
} else { |
||||||
|
// Reset when closed |
||||||
|
threadEvents = []; |
||||||
|
reactions = []; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadThread() { |
||||||
|
if (!opEvent) return; |
||||||
|
|
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const eventId = opEvent.id; |
||||||
|
|
||||||
|
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments |
||||||
|
const replyFilters = [ |
||||||
|
{ kinds: [9735], '#e': [eventId] }, // Zap receipts |
||||||
|
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies) |
||||||
|
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies |
||||||
|
{ kinds: [1111], '#e': [eventId] } // Kind 1111 comments |
||||||
|
]; |
||||||
|
|
||||||
|
// Fetch all reply types |
||||||
|
const allReplies = await nostrClient.fetchEvents( |
||||||
|
replyFilters, |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
// Load reactions (kind 7) for the OP |
||||||
|
const reactionEvents = await nostrClient.fetchEvents( |
||||||
|
[{ kinds: [7], '#e': [eventId] }], |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
reactions = reactionEvents; |
||||||
|
|
||||||
|
// Recursively fetch nested replies |
||||||
|
await fetchNestedReplies(allReplies, relays, eventId); |
||||||
|
|
||||||
|
threadEvents = allReplies; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading thread:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string) { |
||||||
|
let hasNewReplies = true; |
||||||
|
let iterations = 0; |
||||||
|
const maxIterations = 10; |
||||||
|
const allReplies = new Map<string, NostrEvent>(); |
||||||
|
|
||||||
|
// Add initial replies |
||||||
|
for (const reply of initialReplies) { |
||||||
|
allReplies.set(reply.id, reply); |
||||||
|
} |
||||||
|
|
||||||
|
while (hasNewReplies && iterations < maxIterations) { |
||||||
|
iterations++; |
||||||
|
hasNewReplies = false; |
||||||
|
|
||||||
|
const replyIds = Array.from(allReplies.keys()); |
||||||
|
|
||||||
|
if (replyIds.length > 0) { |
||||||
|
// Fetch replies to any of our replies |
||||||
|
const nestedFilters = [ |
||||||
|
{ kinds: [9735], '#e': replyIds }, |
||||||
|
{ kinds: [1244], '#e': replyIds }, |
||||||
|
{ kinds: [1], '#e': replyIds }, |
||||||
|
{ kinds: [1111], '#e': replyIds } |
||||||
|
]; |
||||||
|
|
||||||
|
const nestedReplies = await nostrClient.fetchEvents( |
||||||
|
nestedFilters, |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
for (const reply of nestedReplies) { |
||||||
|
if (!allReplies.has(reply.id)) { |
||||||
|
allReplies.set(reply.id, reply); |
||||||
|
hasNewReplies = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
threadEvents = Array.from(allReplies.values()); |
||||||
|
} |
||||||
|
|
||||||
|
function getParentEvent(event: NostrEvent): NostrEvent | undefined { |
||||||
|
// Find parent event in thread |
||||||
|
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id); |
||||||
|
if (eTag && eTag[1]) { |
||||||
|
// Check if parent is the OP |
||||||
|
if (opEvent && eTag[1] === opEvent.id) { |
||||||
|
return opEvent; |
||||||
|
} |
||||||
|
// Check if parent is another reply |
||||||
|
return threadEvents.find((e) => e.id === eTag[1]); |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
function sortThreadItems(): Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> { |
||||||
|
const items: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||||
|
|
||||||
|
for (const event of threadEvents) { |
||||||
|
if (event.kind === 9735) { |
||||||
|
items.push({ event, type: 'zap' }); |
||||||
|
} else if (event.kind === 1244) { |
||||||
|
items.push({ event, type: 'yak' }); |
||||||
|
} else if (event.kind === 1) { |
||||||
|
items.push({ event, type: 'reply' }); |
||||||
|
} else if (event.kind === 1111) { |
||||||
|
items.push({ event, type: 'comment' }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Build thread structure |
||||||
|
const eventMap = new Map<string, { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }>(); |
||||||
|
const replyMap = new Map<string, string[]>(); |
||||||
|
const rootItems: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||||
|
|
||||||
|
// First pass: build maps |
||||||
|
for (const item of items) { |
||||||
|
eventMap.set(item.event.id, item); |
||||||
|
} |
||||||
|
|
||||||
|
// Second pass: determine parent-child relationships |
||||||
|
for (const item of items) { |
||||||
|
const eTag = item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id); |
||||||
|
const parentId = eTag?.[1]; |
||||||
|
|
||||||
|
if (parentId) { |
||||||
|
// Check if parent is OP or another reply |
||||||
|
if (opEvent && parentId === opEvent.id) { |
||||||
|
// Direct reply to OP |
||||||
|
rootItems.push(item); |
||||||
|
} else if (eventMap.has(parentId)) { |
||||||
|
// Reply to another reply |
||||||
|
if (!replyMap.has(parentId)) { |
||||||
|
replyMap.set(parentId, []); |
||||||
|
} |
||||||
|
replyMap.get(parentId)!.push(item.event.id); |
||||||
|
} else { |
||||||
|
// Parent not found - treat as root |
||||||
|
rootItems.push(item); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// No parent tag - treat as root |
||||||
|
rootItems.push(item); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Third pass: recursively collect in thread order |
||||||
|
const result: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||||
|
const processed = new Set<string>(); |
||||||
|
|
||||||
|
function addThread(item: { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }) { |
||||||
|
if (processed.has(item.event.id)) return; |
||||||
|
processed.add(item.event.id); |
||||||
|
|
||||||
|
result.push(item); |
||||||
|
|
||||||
|
const replies = replyMap.get(item.event.id) || []; |
||||||
|
const replyItems = replies |
||||||
|
.map(id => eventMap.get(id)) |
||||||
|
.filter((item): item is { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' } => item !== undefined) |
||||||
|
.sort((a, b) => a.event.created_at - b.event.created_at); |
||||||
|
|
||||||
|
for (const reply of replyItems) { |
||||||
|
addThread(reply); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort root items by created_at |
||||||
|
rootItems.sort((a, b) => a.event.created_at - b.event.created_at); |
||||||
|
for (const root of rootItems) { |
||||||
|
addThread(root); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) { |
||||||
|
if (e.target === e.currentTarget) { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleEscape(e: KeyboardEvent) { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:window onkeydown={handleEscape} /> |
||||||
|
|
||||||
|
{#if isOpen} |
||||||
|
<div |
||||||
|
class="drawer-backdrop" |
||||||
|
onclick={handleBackdropClick} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Escape') { |
||||||
|
onClose(); |
||||||
|
} |
||||||
|
}} |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
aria-label="Close drawer" |
||||||
|
transition:fade={{ duration: 200 }} |
||||||
|
> |
||||||
|
<div class="drawer" transition:slide={{ axis: 'x', duration: 300 }}> |
||||||
|
<div class="drawer-header"> |
||||||
|
<h2 class="drawer-title">Thread</h2> |
||||||
|
<button class="close-button" onclick={onClose} aria-label="Close drawer"> |
||||||
|
× |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="drawer-content"> |
||||||
|
{#if loading} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> |
||||||
|
{:else if opEvent} |
||||||
|
<!-- OP with reactions --> |
||||||
|
<div class="op-section"> |
||||||
|
<FeedPost post={opEvent} /> |
||||||
|
<div class="reactions-section"> |
||||||
|
<FeedReactionButtons event={opEvent} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Threaded replies --> |
||||||
|
{#if threadEvents.length > 0} |
||||||
|
<div class="replies-section"> |
||||||
|
<h3 class="replies-title">Replies</h3> |
||||||
|
<div class="replies-list"> |
||||||
|
{#each sortThreadItems() as item (item.event.id)} |
||||||
|
{@const parentEvent = getParentEvent(item.event)} |
||||||
|
{#if item.type === 'zap'} |
||||||
|
<ZapReceiptReply |
||||||
|
zapReceipt={item.event} |
||||||
|
parentEvent={parentEvent || opEvent} |
||||||
|
/> |
||||||
|
{:else if item.type === 'yak'} |
||||||
|
<!-- Yak back (voice reply) - TODO: create component or use existing --> |
||||||
|
<div class="yak-reply"> |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Voice reply (kind 1244) - TODO: implement component</p> |
||||||
|
</div> |
||||||
|
{:else if item.type === 'reply'} |
||||||
|
<FeedPost |
||||||
|
post={item.event} |
||||||
|
parentEvent={parentEvent || opEvent} |
||||||
|
/> |
||||||
|
{:else if item.type === 'comment'} |
||||||
|
<Comment |
||||||
|
comment={item.event} |
||||||
|
parentEvent={parentEvent || opEvent} |
||||||
|
/> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet.</p> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.drawer-backdrop { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
z-index: 1000; |
||||||
|
display: flex; |
||||||
|
justify-content: flex-end; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .drawer-backdrop { |
||||||
|
background: rgba(0, 0, 0, 0.7); |
||||||
|
} |
||||||
|
|
||||||
|
.drawer { |
||||||
|
width: 100%; |
||||||
|
max-width: 600px; |
||||||
|
height: 100%; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .drawer { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.drawer-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
padding: 1rem; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .drawer-header { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.drawer-title { |
||||||
|
font-size: 1.25rem; |
||||||
|
font-weight: 600; |
||||||
|
margin: 0; |
||||||
|
color: var(--fog-text, #111827); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .drawer-title { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.close-button { |
||||||
|
background: none; |
||||||
|
border: none; |
||||||
|
font-size: 2rem; |
||||||
|
line-height: 1; |
||||||
|
cursor: pointer; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
padding: 0; |
||||||
|
width: 2rem; |
||||||
|
height: 2rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
|
||||||
|
.close-button:hover { |
||||||
|
color: var(--fog-text, #111827); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .close-button:hover { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.drawer-content { |
||||||
|
flex: 1; |
||||||
|
overflow-y: auto; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.op-section { |
||||||
|
margin-bottom: 2rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 2px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .op-section { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.reactions-section { |
||||||
|
margin-top: 1rem; |
||||||
|
padding-top: 1rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .reactions-section { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.replies-section { |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.replies-title { |
||||||
|
font-size: 1.125rem; |
||||||
|
font-weight: 600; |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: var(--fog-text, #111827); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .replies-title { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.replies-list { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.yak-reply { |
||||||
|
padding: 1rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
border-radius: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .yak-reply { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
Loading…
Reference in new issue