Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
357157ca02
  1. 4
      public/healthz.json
  2. 25
      src/lib/components/EventMenu.svelte
  3. 303
      src/lib/components/write/CreateEventForm.svelte
  4. 115
      src/lib/modules/comments/CommentForm.svelte
  5. 178
      src/lib/modules/feed/FeedPage.svelte
  6. 302
      src/lib/modules/feed/HighlightCard.svelte
  7. 18
      src/routes/+layout.svelte
  8. 2
      src/routes/find/+page.svelte
  9. 118
      src/routes/write/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-04T11:59:22.072Z", "buildTime": "2026-02-04T14:48:13.641Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770206362072 "timestamp": 1770216493641
} }

25
src/lib/components/EventMenu.svelte

@ -18,6 +18,7 @@
import { signAndPublish } from '../services/nostr/auth-handler.js'; import { signAndPublish } from '../services/nostr/auth-handler.js';
import RelatedEventsModal from './modals/RelatedEventsModal.svelte'; import RelatedEventsModal from './modals/RelatedEventsModal.svelte';
import { KIND } from '../types/kind-lookup.js'; import { KIND } from '../types/kind-lookup.js';
import { goto } from '$app/navigation';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -240,7 +241,29 @@
} }
function highlightNote() { function highlightNote() {
highlightedState = toggleHighlight(event.id); // Extract content and e/a tags for highlight
const content = event.content || '';
// Find e-tag or a-tag (prefer a-tag if available)
let referenceTag: string[] | null = null;
const aTag = event.tags.find(tag => tag[0] === 'a');
const eTag = event.tags.find(tag => tag[0] === 'e');
if (aTag) {
referenceTag = aTag;
} else if (eTag) {
referenceTag = eTag;
}
// Store highlight data in sessionStorage
const highlightData = {
content,
tags: referenceTag ? [referenceTag] : []
};
sessionStorage.setItem('aitherboard_highlightData', JSON.stringify(highlightData));
// Navigate to write form with kind 9802 (highlight)
goto('/write?kind=9802');
closeMenu(); closeMenu();
} }

303
src/lib/components/write/CreateEventForm.svelte

@ -33,19 +33,78 @@
interface Props { interface Props {
initialKind?: number | null; initialKind?: number | null;
initialContent?: string | null;
initialTags?: string[][] | null;
} }
let { initialKind = null }: Props = $props(); let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props();
const STORAGE_KEY = 'aitherboard_writeForm_draft';
let selectedKind = $state<number>(1); let selectedKind = $state<number>(1);
let customKindId = $state<string>(''); let customKindId = $state<string>('');
let content = $state(''); let content = $state('');
let tags = $state<string[][]>([]); let tags = $state<string[][]>([]);
let publishing = $state(false); let publishing = $state(false);
// Restore draft from localStorage on mount (only if no initial props)
$effect(() => {
if (typeof window === 'undefined') return;
// Only restore if no initial content/tags were provided (from highlight feature)
if (propInitialContent === null && propInitialTags === null) {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved);
if (draft.content !== undefined && content === '') {
content = draft.content;
}
if (draft.tags && draft.tags.length > 0 && tags.length === 0) {
tags = draft.tags;
}
if (draft.selectedKind !== undefined && initialKind === null) {
selectedKind = draft.selectedKind;
}
}
} catch (error) {
console.error('Error restoring draft:', error);
}
}
});
// Save draft to localStorage when content or tags change
$effect(() => {
if (typeof window === 'undefined') return;
if (publishing) return; // Don't save while publishing
// Debounce saves to avoid excessive localStorage writes
const timeoutId = setTimeout(() => {
try {
const draft = {
content,
tags,
selectedKind
};
// Only save if there's actual content
if (content.trim() || tags.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
} else {
// Clear if empty
localStorage.removeItem(STORAGE_KEY);
}
} catch (error) {
console.error('Error saving draft:', error);
}
}, 500);
return () => clearTimeout(timeoutId);
});
let publicationModalOpen = $state(false); let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false); let showJsonModal = $state(false);
let showPreviewModal = $state(false); let showPreviewModal = $state(false);
let showExampleModal = $state(false);
let showGifPicker = $state(false); let showGifPicker = $state(false);
let showEmojiPicker = $state(false); let showEmojiPicker = $state(false);
let textareaRef: HTMLTextAreaElement | null = $state(null); let textareaRef: HTMLTextAreaElement | null = $state(null);
@ -60,6 +119,16 @@
} }
}); });
// Sync content and tags when initial props change (only if form is empty)
$effect(() => {
if (propInitialContent !== null && propInitialContent !== undefined && content === '') {
content = propInitialContent;
}
if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) {
tags = [...propInitialTags];
}
});
// Clear content for metadata-only kinds // Clear content for metadata-only kinds
$effect(() => { $effect(() => {
if (selectedKind === 30040 || selectedKind === 10895) { if (selectedKind === 30040 || selectedKind === 10895) {
@ -618,11 +687,16 @@
try { try {
// Add file attachments as imeta tags (like jumble) // Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim(); let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] && t[1])]; // Create a plain array (not a Proxy) by mapping and filtering
const allTags: string[][] = tags
.filter(t => t[0] && t[1])
.map(tag => [...tag]); // Create new array for each tag to avoid Proxy
for (const file of uploadedFiles) { for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble) // Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag); // Ensure imetaTag is also a plain array
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
allTags.push(imetaTag);
// Add URL to content field // Add URL to content field
if (contentWithUrls && !contentWithUrls.endsWith('\n')) { if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
@ -635,7 +709,8 @@
allTags.push(['client', 'aitherboard']); allTags.push(['client', 'aitherboard']);
} }
const eventTemplate = { // Create a plain object (not a Proxy) to avoid cloning issues
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: effectiveKind, kind: effectiveKind,
pubkey: session.pubkey, pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
@ -657,7 +732,12 @@
if (results.success.length > 0) { if (results.success.length > 0) {
content = ''; content = '';
tags = [];
uploadedFiles = []; // Clear uploaded files after successful publish uploadedFiles = []; // Clear uploaded files after successful publish
// Clear draft from localStorage after successful publish
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
setTimeout(() => { setTimeout(() => {
goto(`/event/${signedEvent.id}`); goto(`/event/${signedEvent.id}`);
}, 5000); }, 5000);
@ -674,6 +754,19 @@
} }
} }
function clearForm() {
if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) {
content = '';
tags = [];
uploadedFiles = [];
customKindId = '';
// Clear draft from localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
}
async function republishFromCache() { async function republishFromCache() {
if (!publicationResults) return; if (!publicationResults) return;
@ -687,11 +780,16 @@
const session = sessionManager.getSession(); const session = sessionManager.getSession();
if (!session) return; if (!session) return;
const eventTemplate = { // Create plain arrays/objects to avoid Proxy cloning issues
const plainTags: string[][] = tags
.filter(t => t[0] && t[1])
.map(tag => [...tag]); // Create new array for each tag to avoid Proxy
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: effectiveKind, kind: effectiveKind,
pubkey: session.pubkey, pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]), tags: plainTags,
content content
}; };
@ -713,11 +811,12 @@
<div class="help-header"> <div class="help-header">
<p class="help-description">{helpText.description}</p> <p class="help-description">{helpText.description}</p>
<div class="example-button-wrapper"> <div class="example-button-wrapper">
<button class="example-button" type="button" title="Show example JSON">?</button> <button
<div class="example-tooltip"> class="example-button"
<div class="example-tooltip-header">Example of {effectiveKind} event</div> type="button"
<pre class="example-json">{exampleJSON}</pre> title="Show example JSON"
</div> onclick={() => showExampleModal = true}
>?</button>
</div> </div>
</div> </div>
{#if helpText.suggestedTags.length > 0} {#if helpText.suggestedTags.length > 0}
@ -816,6 +915,15 @@
> >
Preview Preview
</button> </button>
<button
type="button"
onclick={clearForm}
class="content-button"
disabled={publishing}
title="Clear form"
>
Clear
</button>
<input <input
type="file" type="file"
bind:this={fileInputRef} bind:this={fileInputRef}
@ -978,6 +1086,45 @@
</div> </div>
{/if} {/if}
<!-- Example JSON Modal -->
{#if showExampleModal}
<div
class="modal-overlay"
onclick={() => showExampleModal = false}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showExampleModal = false;
}
}}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div
class="modal-content example-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="none"
>
<div class="modal-header">
<h2>Example of {effectiveKind} event</h2>
<button onclick={() => showExampleModal = false} class="close-button">×</button>
</div>
<div class="modal-body">
<pre class="example-json">{exampleJSON}</pre>
</div>
<div class="modal-footer">
<button onclick={() => {
navigator.clipboard.writeText(exampleJSON);
alert('Example JSON copied to clipboard');
}}>Copy</button>
<button onclick={() => showExampleModal = false}>Close</button>
</div>
</div>
</div>
{/if}
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0} {#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}
<div class="republish-section"> <div class="republish-section">
<p class="republish-text">All relays failed. You can attempt to republish from cache.</p> <p class="republish-text">All relays failed. You can attempt to republish from cache.</p>
@ -992,6 +1139,14 @@
display: flex; display: flex;
gap: 2rem; gap: 2rem;
max-width: 1200px; max-width: 1200px;
width: 100%;
}
@media (max-width: 768px) {
.create-form-container {
gap: 1rem;
padding: 0 0.5rem;
}
} }
.create-form { .create-form {
@ -1001,12 +1156,26 @@
gap: 1.5rem; gap: 1.5rem;
} }
@media (max-width: 768px) {
.create-form {
gap: 1rem;
}
}
.form-header { .form-header {
display: flex; display: flex;
flex-direction: row;
gap: 2rem; gap: 2rem;
align-items: flex-start; align-items: flex-start;
} }
@media (max-width: 768px) {
.form-header {
flex-direction: column;
gap: 1rem;
}
}
.form-title { .form-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
@ -1028,6 +1197,13 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
@media (max-width: 768px) {
.help-text-panel {
padding: 0.75rem;
font-size: 0.8125rem;
}
}
:global(.dark) .help-text-panel { :global(.dark) .help-text-panel {
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569); border-color: var(--fog-dark-border, #475569);
@ -1052,7 +1228,6 @@
} }
.example-button-wrapper { .example-button-wrapper {
position: relative;
flex-shrink: 0; flex-shrink: 0;
} }
@ -1090,49 +1265,21 @@
border-color: var(--fog-dark-accent, #94a3b8); border-color: var(--fog-dark-accent, #94a3b8);
} }
.example-tooltip { .example-modal {
position: absolute; max-width: 90vw;
top: 100%; width: 100%;
right: 0; max-width: 800px;
margin-top: 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem;
min-width: 400px;
max-width: 600px;
max-height: 500px;
overflow: auto;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
:global(.dark) .example-tooltip {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.example-button-wrapper:hover .example-tooltip {
opacity: 1;
pointer-events: auto;
}
.example-tooltip-header {
font-weight: 600;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
} }
:global(.dark) .example-tooltip-header { @media (max-width: 768px) {
color: var(--fog-dark-text, #f9fafb); .example-modal {
border-bottom-color: var(--fog-dark-border, #374151); max-width: 95vw;
margin: 1rem;
}
.modal-content {
max-height: 90vh;
}
} }
.example-json { .example-json {
@ -1235,7 +1382,8 @@
.content-input { .content-input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
padding-bottom: 2.5rem; /* Always have padding for buttons */ padding-bottom: 3.5rem; /* Extra padding for buttons at bottom */
padding-left: 0.75rem; /* Ensure left padding */
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
@ -1245,6 +1393,11 @@
resize: vertical; resize: vertical;
box-sizing: border-box; box-sizing: border-box;
} }
.content-input.has-buttons {
padding-bottom: 3.5rem; /* Extra padding when buttons are present */
padding-left: 0.75rem; /* Ensure text doesn't overlap left-positioned buttons */
}
:global(.dark) .content-input { :global(.dark) .content-input {
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
@ -1263,6 +1416,18 @@
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.tag-row {
flex-direction: column;
align-items: stretch;
}
.tag-row .tag-input {
width: 100%;
}
} }
.tag-input { .tag-input {
@ -1344,6 +1509,17 @@
gap: 0.5rem; gap: 0.5rem;
} }
@media (max-width: 768px) {
.form-actions {
flex-direction: column;
}
.publish-button {
width: 100%;
padding: 0.875rem 1.5rem;
}
}
.publish-button { .publish-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b); background: var(--fog-accent, #64748b);
@ -1421,11 +1597,12 @@
.textarea-buttons { .textarea-buttons {
position: absolute; position: absolute;
bottom: 0.5rem; bottom: 0.75rem;
left: 0.5rem; left: 0.75rem;
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
z-index: 10; z-index: 10;
pointer-events: auto;
} }
.toolbar-button { .toolbar-button {
@ -1437,6 +1614,7 @@
color: var(--fog-text, #1f2937); color: var(--fog-text, #1f2937);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.toolbar-button:hover:not(:disabled) { .toolbar-button:hover:not(:disabled) {
@ -1453,6 +1631,7 @@
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
} }
:global(.dark) .toolbar-button:hover:not(:disabled) { :global(.dark) .toolbar-button:hover:not(:disabled) {
@ -1465,6 +1644,18 @@
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; margin-top: 0.5rem;
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.content-buttons {
gap: 0.375rem;
}
.content-button {
font-size: 0.8125rem;
padding: 0.375rem 0.75rem;
}
} }
.content-button { .content-button {

115
src/lib/modules/comments/CommentForm.svelte

@ -24,8 +24,51 @@
let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props(); let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props();
// Create unique storage key based on thread and parent
const STORAGE_KEY = $derived(`aitherboard_commentForm_${threadId}_${parentEvent?.id || 'root'}`);
let content = $state(''); let content = $state('');
let publishing = $state(false); let publishing = $state(false);
// Restore draft from localStorage on mount
$effect(() => {
if (typeof window === 'undefined') return;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const draft = JSON.parse(saved);
if (draft.content !== undefined && content === '') {
content = draft.content;
}
}
} catch (error) {
console.error('Error restoring comment draft:', error);
}
});
// Save draft to localStorage when content changes
$effect(() => {
if (typeof window === 'undefined') return;
if (publishing) return; // Don't save while publishing
// Debounce saves to avoid excessive localStorage writes
const timeoutId = setTimeout(() => {
try {
// Only save if there's actual content
if (content.trim()) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ content }));
} else {
// Clear if empty
localStorage.removeItem(STORAGE_KEY);
}
} catch (error) {
console.error('Error saving comment draft:', error);
}
}, 500);
return () => clearTimeout(timeoutId);
});
let showStatusModal = $state(false); let showStatusModal = $state(false);
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null); let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null);
let showGifPicker = $state(false); let showGifPicker = $state(false);
@ -183,6 +226,10 @@
if (result.success.length > 0) { if (result.success.length > 0) {
content = ''; content = '';
uploadedFiles = []; // Clear uploaded files after successful publish uploadedFiles = []; // Clear uploaded files after successful publish
// Clear draft from localStorage after successful publish
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
onPublished?.(); onPublished?.();
} }
} catch (error) { } catch (error) {
@ -199,6 +246,17 @@
} }
} }
function clearForm() {
if (confirm('Are you sure you want to clear the comment? This will delete all unsaved content.')) {
content = '';
uploadedFiles = [];
// Clear draft from localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
}
function handleGifSelect(gifUrl: string) { function handleGifSelect(gifUrl: string) {
if (!textareaRef) return; if (!textareaRef) return;
// Insert GIF URL as plain text // Insert GIF URL as plain text
@ -452,8 +510,8 @@
{/if} {/if}
</div> </div>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2 comment-form-actions">
<div class="flex gap-2"> <div class="flex gap-2 comment-form-left">
<button <button
type="button" type="button"
onclick={() => showJsonModal = true} onclick={() => showJsonModal = true}
@ -472,6 +530,15 @@
> >
Preview Preview
</button> </button>
<button
type="button"
onclick={clearForm}
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50"
disabled={publishing}
title="Clear comment"
>
Clear
</button>
<input <input
type="file" type="file"
bind:this={fileInputRef} bind:this={fileInputRef}
@ -489,7 +556,7 @@
📤 📤
</label> </label>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 comment-form-right">
{#if onCancel} {#if onCancel}
<button <button
onclick={onCancel} onclick={onCancel}
@ -611,6 +678,12 @@
margin-top: 1rem; margin-top: 1rem;
} }
@media (max-width: 768px) {
.comment-form {
margin-top: 0.75rem;
}
}
.textarea-wrapper { .textarea-wrapper {
position: relative; position: relative;
} }
@ -823,4 +896,40 @@
.upload-label { .upload-label {
filter: grayscale(100%); filter: grayscale(100%);
} }
.comment-form-actions {
flex-wrap: wrap;
gap: 0.5rem;
}
.comment-form-left,
.comment-form-right {
flex-wrap: wrap;
}
@media (max-width: 768px) {
.comment-form-actions {
flex-direction: column;
align-items: stretch;
}
.comment-form-left {
width: 100%;
justify-content: flex-start;
}
.comment-form-right {
width: 100%;
justify-content: flex-end;
}
.comment-form-right button {
flex: 1;
min-width: 0;
}
textarea {
font-size: 16px; /* Prevent zoom on iOS */
}
}
</style> </style>

178
src/lib/modules/feed/FeedPage.svelte

@ -3,6 +3,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import HighlightCard from './HighlightCard.svelte';
import ThreadDrawer from './ThreadDrawer.svelte'; import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
@ -16,6 +17,8 @@
let posts = $state<NostrEvent[]>([]); let posts = $state<NostrEvent[]>([]);
let allPosts = $state<NostrEvent[]>([]); // Store all posts before filtering let allPosts = $state<NostrEvent[]>([]); // Store all posts before filtering
let highlights = $state<NostrEvent[]>([]); // Store highlight events (kind 9802)
let allHighlights = $state<NostrEvent[]>([]); // Store all highlights before filtering
let loading = $state(true); let loading = $state(true);
let loadingMore = $state(false); let loadingMore = $state(false);
let hasMore = $state(true); let hasMore = $state(true);
@ -258,15 +261,18 @@
} }
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const filters = [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }]; const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
// Subscribe to new kind 1 events // Subscribe to new kind 1 events and kind 9802 highlights
subscriptionId = nostrClient.subscribe( subscriptionId = nostrClient.subscribe(
filters, filters,
relays, relays,
(event: NostrEvent) => { (event: NostrEvent) => {
// Only add events that are newer than what we already have // Only add events that are newer than what we already have
const existingIds = new Set(posts.map(p => p.id)); const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]);
if (!existingIds.has(event.id)) { if (!existingIds.has(event.id)) {
handleUpdate([event]); handleUpdate([event]);
} }
@ -379,7 +385,11 @@
const cacheResults = !singleRelay; const cacheResults = !singleRelay;
// Load initial feed - use cache for fast initial load (unless single relay mode) // Load initial feed - use cache for fast initial load (unless single relay mode)
const filters = [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }]; // Load both kind 1 posts and kind 9802 highlights
const filters = [
{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 },
{ kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 20 }
];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
filters, filters,
relays, relays,
@ -393,6 +403,10 @@
console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? 'single relay' : 'relays'}`); console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? 'single relay' : 'relays'}`);
// Separate posts and highlights
const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
// Also immediately query relays to ensure we get fresh data in background // Also immediately query relays to ensure we get fresh data in background
// For single relay mode, also do a background query to ensure we get results // For single relay mode, also do a background query to ensure we get results
if (!singleRelay || events.length === 0) { if (!singleRelay || events.length === 0) {
@ -409,7 +423,7 @@
console.log(`[FeedPage] Background query returned ${newEvents.length} events`); console.log(`[FeedPage] Background query returned ${newEvents.length} events`);
// Only update if we got new events that aren't already in posts // Only update if we got new events that aren't already in posts
if (newEvents.length > 0) { if (newEvents.length > 0) {
const existingIds = new Set(posts.map(p => p.id)); const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]);
const trulyNew = newEvents.filter(e => !existingIds.has(e.id)); const trulyNew = newEvents.filter(e => !existingIds.has(e.id));
if (trulyNew.length > 0) { if (trulyNew.length > 0) {
handleUpdate(trulyNew); handleUpdate(trulyNew);
@ -421,15 +435,26 @@
} }
// Sort by created_at descending and deduplicate // Sort by created_at descending and deduplicate
const uniqueMap = new Map<string, NostrEvent>(); const uniquePostsMap = new Map<string, NostrEvent>();
for (const event of events) { for (const event of postsList) {
if (!uniqueMap.has(event.id)) { if (!uniquePostsMap.has(event.id)) {
uniqueMap.set(event.id, event); uniquePostsMap.set(event.id, event);
}
}
const uniquePosts = Array.from(uniquePostsMap.values());
const sortedPosts = uniquePosts.sort((a, b) => b.created_at - a.created_at);
allPosts = sortedPosts;
const uniqueHighlightsMap = new Map<string, NostrEvent>();
for (const event of highlightsList) {
if (!uniqueHighlightsMap.has(event.id)) {
uniqueHighlightsMap.set(event.id, event);
} }
} }
const unique = Array.from(uniqueMap.values()); const uniqueHighlights = Array.from(uniqueHighlightsMap.values());
const sorted = unique.sort((a, b) => b.created_at - a.created_at); const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at);
allPosts = sorted; allHighlights = sortedHighlights;
highlights = [...allHighlights];
// Always set posts, even if empty // Always set posts, even if empty
// Apply filter if one is selected // Apply filter if one is selected
@ -439,12 +464,13 @@
posts = [...allPosts]; posts = [...allPosts];
} }
console.log(`[FeedPage] Loaded ${sorted.length} events, posts array has ${posts.length} items`); console.log(`[FeedPage] Loaded ${sortedPosts.length} posts and ${sortedHighlights.length} highlights`);
if (sorted.length > 0) { if (sortedPosts.length > 0 || sortedHighlights.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at)); const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)];
oldestTimestamp = Math.min(...allTimestamps);
// Batch load reactions for all posts // Batch load reactions for all posts
await loadReactionsForPosts(sorted); await loadReactionsForPosts(sortedPosts);
} else { } else {
console.log('[FeedPage] No events found. Relays:', relays); console.log('[FeedPage] No events found. Relays:', relays);
} }
@ -471,11 +497,18 @@
const useCache = !singleRelay; const useCache = !singleRelay;
const cacheResults = !singleRelay; const cacheResults = !singleRelay;
const filters = [{ const filters = [
kinds: [KIND.SHORT_TEXT_NOTE], {
limit: 20, kinds: [KIND.SHORT_TEXT_NOTE],
until: oldestTimestamp || undefined limit: 20,
}]; until: oldestTimestamp || undefined
},
{
kinds: [KIND.HIGHLIGHTED_ARTICLE],
limit: 20,
until: oldestTimestamp || undefined
}
];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
filters, filters,
@ -492,13 +525,29 @@
return; return;
} }
// Separate posts and highlights
const newPosts = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
const newHighlights = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
// Filter out duplicates // Filter out duplicates
const existingIds = new Set(allPosts.map(p => p.id)); const existingPostIds = new Set(allPosts.map(p => p.id));
const newEvents = events.filter(e => !existingIds.has(e.id)); const existingHighlightIds = new Set(allHighlights.map(h => h.id));
const uniqueNewPosts = newPosts.filter(e => !existingPostIds.has(e.id));
const uniqueNewHighlights = newHighlights.filter(e => !existingHighlightIds.has(e.id));
if (newEvents.length > 0) { if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) {
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at); if (uniqueNewPosts.length > 0) {
allPosts = [...allPosts, ...sorted]; const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at);
allPosts = [...allPosts, ...sorted];
// Batch load reactions for new posts
await loadReactionsForPosts(sorted);
}
if (uniqueNewHighlights.length > 0) {
const sorted = uniqueNewHighlights.sort((a, b) => b.created_at - a.created_at);
allHighlights = [...allHighlights, ...sorted];
highlights = [...allHighlights];
}
// Apply filter if one is selected // Apply filter if one is selected
if (selectedListId) { if (selectedListId) {
@ -507,12 +556,11 @@
posts = [...allPosts]; posts = [...allPosts];
} }
const oldest = Math.min(...newEvents.map(e => e.created_at)); const allNewTimestamps = [...uniqueNewPosts.map(e => e.created_at), ...uniqueNewHighlights.map(e => e.created_at)];
const oldest = Math.min(...allNewTimestamps);
if (oldest < (oldestTimestamp || Infinity)) { if (oldest < (oldestTimestamp || Infinity)) {
oldestTimestamp = oldest; oldestTimestamp = oldest;
} }
// Batch load reactions for new posts
await loadReactionsForPosts(sorted);
hasMore = events.length >= 20; hasMore = events.length >= 20;
} else if (events.length > 0) { } else if (events.length > 0) {
// All events were duplicates, but we got some results // All events were duplicates, but we got some results
@ -578,32 +626,56 @@
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`); console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`);
// Merge and sort, then deduplicate by ID // Separate posts and highlights
const merged = [...allPosts, ...newEvents]; const newPosts = newEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
// Deduplicate by ID (keep first occurrence) const newHighlights = newEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE);
const uniqueMap = new Map<string, NostrEvent>();
for (const event of merged) { // Merge and sort posts, then deduplicate by ID
if (event && event.id && !uniqueMap.has(event.id)) { if (newPosts.length > 0) {
uniqueMap.set(event.id, event); const mergedPosts = [...allPosts, ...newPosts];
const uniquePostsMap = new Map<string, NostrEvent>();
for (const event of mergedPosts) {
if (event && event.id && !uniquePostsMap.has(event.id)) {
uniquePostsMap.set(event.id, event);
}
} }
} const uniquePosts = Array.from(uniquePostsMap.values());
const unique = Array.from(uniqueMap.values()); const sortedPosts = uniquePosts.sort((a, b) => b.created_at - a.created_at);
const sorted = unique.sort((a, b) => b.created_at - a.created_at);
// Only update if we actually have new events to prevent loops
if (sorted.length > allPosts.length || sorted.some((e, i) => e.id !== allPosts[i]?.id)) {
allPosts = sorted;
// Apply filter if one is selected // Only update if we actually have new events to prevent loops
if (selectedListId) { if (sortedPosts.length > allPosts.length || sortedPosts.some((e, i) => e.id !== allPosts[i]?.id)) {
handleListFilterChange(selectedListId); allPosts = sortedPosts;
} else {
posts = [...allPosts];
} }
}
// Merge and sort highlights, then deduplicate by ID
if (newHighlights.length > 0) {
const mergedHighlights = [...allHighlights, ...newHighlights];
const uniqueHighlightsMap = new Map<string, NostrEvent>();
for (const event of mergedHighlights) {
if (event && event.id && !uniqueHighlightsMap.has(event.id)) {
uniqueHighlightsMap.set(event.id, event);
}
}
const uniqueHighlights = Array.from(uniqueHighlightsMap.values());
const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at);
console.debug(`[FeedPage] Updated posts to ${sorted.length} events`); // Only update if we actually have new events to prevent loops
if (sortedHighlights.length > allHighlights.length || sortedHighlights.some((e, i) => e.id !== allHighlights[i]?.id)) {
allHighlights = sortedHighlights;
highlights = [...allHighlights];
}
} }
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
} else {
posts = [...allPosts];
}
console.debug(`[FeedPage] Updated: ${allPosts.length} posts, ${allHighlights.length} highlights`);
pendingUpdates = []; pendingUpdates = [];
}, 500); }, 500);
} }
@ -705,8 +777,12 @@
</div> </div>
{:else} {:else}
<div class="feed-posts"> <div class="feed-posts">
{#each posts as post (post.id)} {#each [...posts, ...highlights].sort((a, b) => b.created_at - a.created_at) as event (event.id)}
<FeedPost post={post} onOpenEvent={openDrawer} reactions={reactionsMap.get(post.id)} /> {#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<HighlightCard highlight={event} onOpenEvent={openDrawer} />
{:else}
<FeedPost post={event} onOpenEvent={openDrawer} reactions={reactionsMap.get(event.id)} />
{/if}
{/each} {/each}
</div> </div>

302
src/lib/modules/feed/HighlightCard.svelte

@ -0,0 +1,302 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
}
let { highlight, onOpenEvent }: Props = $props();
let sourceEvent = $state<NostrEvent | null>(null);
let loadingSource = $state(false);
// Extract source event ID from e-tag or a-tag
function getSourceEventId(): string | null {
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]);
if (eTag && eTag[1]) {
return eTag[1];
}
return null;
}
// Extract source address from a-tag
function getSourceAddress(): string | null {
const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
return aTag[1];
}
return null;
}
onMount(async () => {
await loadSourceEvent();
});
async function loadSourceEvent() {
const sourceEventId = getSourceEventId();
if (!sourceEventId || loadingSource) return;
loadingSource = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [sourceEventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
sourceEvent = events[0];
}
} catch (error) {
console.error('Error loading source event:', error);
} finally {
loadingSource = false;
}
}
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - highlight.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 = highlight.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function handleCardClick(e: MouseEvent) {
// Don't open drawer if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.closest('button') ||
target.closest('a') ||
target.closest('.post-actions')
) {
return;
}
// Open source event if available
if (onOpenEvent && sourceEvent) {
onOpenEvent(sourceEvent);
} else if (sourceEvent) {
// Navigate to source event page
window.location.href = `/event/${sourceEvent.id}`;
}
}
function handleCardKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
const target = e.target as HTMLElement;
if (
target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.closest('button') ||
target.closest('a') ||
target.closest('.post-actions')
) {
return;
}
e.preventDefault();
if (onOpenEvent && sourceEvent) {
onOpenEvent(sourceEvent);
} else if (sourceEvent) {
window.location.href = `/event/${sourceEvent.id}`;
}
}
</script>
<article
class="highlight-card"
data-highlight-id={highlight.id}
id="highlight-{highlight.id}"
onclick={handleCardClick}
onkeydown={handleCardKeydown}
class:cursor-pointer={!!sourceEvent}
{...(sourceEvent ? { role: "button", tabindex: 0 } : {})}
>
<div class="highlight-header">
<div class="highlight-badge">✨ Highlight</div>
<div class="highlight-meta">
<ProfileBadge pubkey={highlight.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<EventMenu event={highlight} showContentActions={true} />
</div>
</div>
</div>
<div class="highlight-content">
<MarkdownRenderer content={highlight.content} event={highlight} />
</div>
{#if sourceEvent}
<div class="source-event-link">
<a href="/event/{sourceEvent.id}" class="source-link" onclick={(e) => e.stopPropagation()}>
View source event →
</a>
</div>
{:else if loadingSource}
<div class="source-event-link">
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">Loading source event...</span>
</div>
{:else if getSourceEventId()}
<div class="source-event-link">
<a href="/event/{getSourceEventId()}" class="source-link" onclick={(e) => e.stopPropagation()}>
View source event →
</a>
</div>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(highlight.kind).number}</span>
<span class="kind-description">{getKindInfo(highlight.kind).description}</span>
</div>
</article>
<style>
.highlight-card {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
position: relative;
border-left: 4px solid #fbbf24; /* Yellow accent for highlights */
}
:global(.dark) .highlight-card {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: #fbbf24;
}
.highlight-header {
margin-bottom: 0.75rem;
}
.highlight-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
:global(.dark) .highlight-badge {
background: #78350f;
color: #fef3c7;
}
.highlight-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.highlight-content {
margin-bottom: 0.75rem;
padding: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
border-left: 3px solid #fbbf24;
}
:global(.dark) .highlight-content {
background: var(--fog-dark-highlight, #374151);
border-left-color: #fbbf24;
}
.source-event-link {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .source-event-link {
border-top-color: var(--fog-dark-border, #374151);
}
.source-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
font-size: 0.875rem;
}
.source-link:hover {
text-decoration: underline;
}
:global(.dark) .source-link {
color: var(--fog-dark-accent, #94a3b8);
}
.highlight-card.cursor-pointer {
cursor: pointer;
}
.highlight-card.cursor-pointer:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .highlight-card.cursor-pointer:hover {
background: var(--fog-dark-highlight, #374151);
}
.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, #9ca3af);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.625rem;
opacity: 0.8;
}
</style>

18
src/routes/+layout.svelte

@ -2,8 +2,24 @@
import '../app.css'; import '../app.css';
import { sessionManager } from '../lib/services/auth/session-manager.js'; import { sessionManager } from '../lib/services/auth/session-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment';
// Restore session on app load (only if no session exists) // Restore session immediately if in browser (before onMount)
if (browser) {
// Try to restore session synchronously if possible
// This ensures session is restored before any components render
(async () => {
try {
if (!sessionManager.isLoggedIn()) {
await sessionManager.restoreSession();
}
} catch (error) {
console.error('Failed to restore session:', error);
}
})();
}
// Also restore in onMount as fallback
onMount(async () => { onMount(async () => {
try { try {
// Only restore if there's no active session // Only restore if there's no active session

2
src/routes/find/+page.svelte

@ -111,8 +111,6 @@
<div class="find-sections"> <div class="find-sections">
<!-- Find Event Section --> <!-- Find Event Section -->
<section class="find-section"> <section class="find-section">
<h2 class="section-title">Find Event</h2>
<p class="section-description">Enter an event ID (hex, note, nevent, or naddr)</p>
<FindEventForm /> <FindEventForm />
</section> </section>

118
src/routes/write/+page.svelte

@ -6,14 +6,12 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
// Read kind from URL synchronously so it's available on first render
const kindParam = $derived($page.url.searchParams.get('kind'));
let initialKind = $state<number | null>(null); let initialKind = $state<number | null>(null);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Set initial kind from URL if available
onMount(async () => { $effect(() => {
await nostrClient.initialize();
// Check for kind parameter in URL
const kindParam = $page.url.searchParams.get('kind');
if (kindParam) { if (kindParam) {
const kind = parseInt(kindParam, 10); const kind = parseInt(kindParam, 10);
if (!isNaN(kind)) { if (!isNaN(kind)) {
@ -21,6 +19,47 @@
} }
} }
}); });
let initialContent = $state<string | null>(null);
let initialTags = $state<string[][] | null>(null);
// Subscribe to session changes to reactively update login status
let currentSession = $state(sessionManager.session.value);
const isLoggedIn = $derived(currentSession !== null);
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session) => {
currentSession = session;
});
return unsubscribe;
});
onMount(async () => {
await nostrClient.initialize();
// Ensure session is restored (fallback in case layout restoration didn't complete)
if (!sessionManager.isLoggedIn()) {
try {
await sessionManager.restoreSession();
} catch (error) {
console.error('Failed to restore session in write page:', error);
}
}
// Check for highlight data in sessionStorage
const highlightDataStr = sessionStorage.getItem('aitherboard_highlightData');
if (highlightDataStr) {
try {
const highlightData = JSON.parse(highlightDataStr);
initialContent = highlightData.content || null;
initialTags = highlightData.tags || null;
// Clear sessionStorage after reading
sessionStorage.removeItem('aitherboard_highlightData');
} catch (error) {
console.error('Error parsing highlight data:', error);
}
}
});
</script> </script>
<Header /> <Header />
@ -36,7 +75,11 @@
</div> </div>
{:else} {:else}
<div class="form-container"> <div class="form-container">
<CreateEventForm initialKind={initialKind} /> <CreateEventForm
initialKind={initialKind}
initialContent={initialContent}
initialTags={initialTags}
/>
</div> </div>
{/if} {/if}
</div> </div>
@ -53,68 +96,9 @@
margin: 0 auto; margin: 0 auto;
} }
.mode-selector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mode-button {
padding: 1.5rem;
border: 2px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
:global(.dark) .mode-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.mode-button:hover {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.dark) .mode-button:hover {
border-color: var(--fog-dark-accent, #94a3b8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.form-container { .form-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.back-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
align-self: flex-start;
}
:global(.dark) .back-button {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.back-button:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .back-button:hover {
background: var(--fog-dark-border, #475569);
}
</style> </style>

Loading…
Cancel
Save