Browse Source

Fixed most of the regex on the page.

master
Silberengel 11 months ago
parent
commit
13a2932cde
  1. 36
      src/lib/components/LoginModal.svelte
  2. 1
      src/lib/components/Navigation.svelte
  3. 340
      src/lib/utils/markdownParser.ts
  4. 390
      src/routes/contact/+page.svelte

36
src/lib/components/LoginModal.svelte

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import Login from './Login.svelte';
export let show = false;
export let onClose = () => {};
</script>
{#if show}
<div class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50">
<div class="relative w-auto my-6 mx-auto max-w-3xl">
<div class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none">
<!-- Header -->
<div class="flex items-start justify-between p-5 border-b border-solid border-gray-300 rounded-t">
<h3 class="text-xl font-medium text-gray-900">Login Required</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-400 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
on:click={onClose}
>
<span class="bg-transparent text-gray-500 h-6 w-6 text-2xl block outline-none focus:outline-none">×</span>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p class="text-base leading-relaxed text-gray-500 mb-4">
You need to be logged in to submit an issue. Your form data will be preserved.
</p>
<div class="flex justify-center">
<Login />
</div>
</div>
</div>
</div>
</div>
{/if}

1
src/lib/components/Navigation.svelte

@ -21,6 +21,7 @@ @@ -21,6 +21,7 @@
<NavLi href='/new/edit'>Publish</NavLi>
<NavLi href='/visualize'>Visualize</NavLi>
<NavLi href='/about'>About</NavLi>
<NavLi href='/contact'>Contact</NavLi>
<NavLi>
<DarkMode btnClass='btn-leather p-0'/>
</NavLi>

340
src/lib/utils/markdownParser.ts

@ -0,0 +1,340 @@ @@ -0,0 +1,340 @@
/**
* Markdown parser with special handling for nostr identifiers
*/
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { nip19 } from 'nostr-tools';
// Regular expressions for nostr identifiers - process these first
const NOSTR_NPUB_REGEX = /(?:nostr:)?(npub[a-zA-Z0-9]{59,60})/g;
// Regular expressions for markdown elements
const BLOCKQUOTE_REGEX = /^(?:>[ \t]*.+\n?(?:(?:>[ \t]*\n)*(?:>[ \t]*.+\n?))*)+/gm;
const ORDERED_LIST_REGEX = /^(\d+)\.[ \t]+(.+)$/gm;
const UNORDERED_LIST_REGEX = /^[-*][ \t]+(.+)$/gm;
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
const ITALIC_REGEX = /_([^_]+)_/g;
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm;
const CODE_BLOCK_REGEX = /```([^\n]*)\n([\s\S]*?)```/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
const HASHTAG_REGEX = /(?<!\S)#([a-zA-Z0-9_]+)(?!\S)/g;
const FOOTNOTE_REFERENCE_REGEX = /\[(\^[^\]]+)\]/g;
const FOOTNOTE_DEFINITION_REGEX = /^\[(\^[^\]]+)\]:\s*(.+?)(?:\n(?!\[)|\n\n|$)/gm;
// Cache for npub metadata
const npubCache = new Map<string, {name?: string, displayName?: string}>();
/**
* Get user metadata for an npub
*/
async function getUserMetadata(npub: string): Promise<{name?: string, displayName?: string}> {
if (npubCache.has(npub)) {
return npubCache.get(npub)!;
}
const fallback = { name: `${npub.slice(0, 8)}...${npub.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
npubCache.set(npub, fallback);
return fallback;
}
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
npubCache.set(npub, fallback);
return fallback;
}
const user = ndk.getUser({ npub: npub });
if (!user) {
npubCache.set(npub, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(npub, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(npub, metadata);
return metadata;
} catch (e) {
npubCache.set(npub, fallback);
return fallback;
}
} catch (e) {
npubCache.set(npub, fallback);
return fallback;
}
}
/**
* Process lists (ordered and unordered)
*/
function processLists(html: string): string {
const lines = html.split('\n');
let inList = false;
let isOrdered = false;
let currentList: string[] = [];
const processed: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const orderedMatch = ORDERED_LIST_REGEX.exec(line);
const unorderedMatch = UNORDERED_LIST_REGEX.exec(line);
if (orderedMatch || unorderedMatch) {
if (!inList) {
inList = true;
isOrdered = !!orderedMatch;
currentList = [];
}
const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1];
currentList.push(content);
} else {
if (inList) {
const listType = isOrdered ? 'ol' : 'ul';
const listClass = isOrdered ? 'list-decimal' : 'list-disc';
processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
currentList.forEach(item => {
processed.push(` <li class="ml-4">${item}</li>`);
});
processed.push(`</${listType}>`);
inList = false;
currentList = [];
}
processed.push(line);
}
// Reset regex lastIndex
ORDERED_LIST_REGEX.lastIndex = 0;
UNORDERED_LIST_REGEX.lastIndex = 0;
}
if (inList) {
const listType = isOrdered ? 'ol' : 'ul';
const listClass = isOrdered ? 'list-decimal' : 'list-disc';
processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
currentList.forEach(item => {
processed.push(` <li class="ml-4">${item}</li>`);
});
processed.push(`</${listType}>`);
}
return processed.join('\n');
}
/**
* Process blockquotes using placeholder approach
*/
function processBlockquotes(text: string): string {
const blockquotes: Array<{id: string, content: string}> = [];
let processedText = text;
// Extract and save blockquotes
processedText = processedText.replace(BLOCKQUOTE_REGEX, (match) => {
const id = `BLOCKQUOTE_${blockquotes.length}`;
const cleanContent = match
.split('\n')
.map(line => line.replace(/^>[ \t]*/, ''))
.join('\n')
.trim();
blockquotes.push({
id,
content: `<blockquote class="pl-4 py-2 my-4 border-l-4 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 rounded-r">${cleanContent}</blockquote>`
});
return id;
});
// Restore blockquotes
blockquotes.forEach(({id, content}) => {
processedText = processedText.replace(id, content);
});
return processedText;
}
/**
* Process code blocks and inline code before any HTML escaping
*/
function processCode(text: string): string {
const blocks: Array<{id: string, content: string}> = [];
const inlineCodes: Array<{id: string, content: string}> = [];
let processedText = text;
// First, extract and save code blocks
processedText = processedText.replace(CODE_BLOCK_REGEX, (match, lang, code) => {
const id = `CODE_BLOCK_${blocks.length}`;
blocks.push({
id,
content: `<pre><code${lang ? ` class="language-${lang.trim()}"` : ''}>${escapeHtml(code)}</code></pre>`
});
return id;
});
// Then extract and save inline code
processedText = processedText.replace(INLINE_CODE_REGEX, (match, code) => {
const id = `INLINE_CODE_${inlineCodes.length}`;
inlineCodes.push({
id,
content: `<code>${escapeHtml(code.trim())}</code>`
});
return id;
});
// Now escape HTML in the remaining text
processedText = escapeHtml(processedText);
// Restore code blocks
blocks.forEach(({id, content}) => {
processedText = processedText.replace(escapeHtml(id), content);
});
// Restore inline code
inlineCodes.forEach(({id, content}) => {
processedText = processedText.replace(escapeHtml(id), content);
});
return processedText;
}
/**
* Process footnotes with minimal spacing
*/
function processFootnotes(text: string): { text: string, footnotes: Map<string, string> } {
const footnotes = new Map<string, string>();
let counter = 0;
// Extract footnote definitions
text = text.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, content) => {
const cleanId = id.replace('^', '');
footnotes.set(cleanId, content.trim());
return '';
});
// Replace references
text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => {
const cleanId = id.replace('^', '');
if (footnotes.has(cleanId)) {
counter++;
return `<sup><a href="#footnote-${cleanId}" id="ref-${cleanId}" class="text-blue-600 hover:underline scroll-mt-32">[${counter}]</a></sup>`;
}
return match;
});
// Add footnotes section if we have any
if (footnotes.size > 0) {
text += '\n<div class="footnotes mt-8 pt-4 border-t border-gray-300 dark:border-gray-600">';
text += '<ol class="list-decimal pl-6 space-y-0.5">';
counter = 0;
for (const [id, content] of footnotes.entries()) {
counter++;
text += `<li id="footnote-${id}" class="text-sm text-gray-600 dark:text-gray-400 scroll-mt-32">${content}<a href="#ref-${id}" class="text-blue-600 hover:underline ml-1 scroll-mt-32">↩</a></li>`;
}
text += '</ol></div>';
}
return { text, footnotes };
}
/**
* Parse markdown text to HTML with special handling for nostr identifiers
*/
export async function parseMarkdown(text: string): Promise<string> {
if (!text) return '';
// First, process code blocks (protect these from HTML escaping)
let html = processCode(text); // still escape HTML *inside* code blocks
// 👉 NEW: process blockquotes *before* the rest of HTML is escaped
html = processBlockquotes(html);
// Process nostr identifiers
const npubMatches = Array.from(html.matchAll(NOSTR_NPUB_REGEX));
const npubPromises = npubMatches.map(async match => {
const [fullMatch, npub] = match;
const metadata = await getUserMetadata(npub);
const displayText = metadata.displayName || metadata.name || `${npub.slice(0, 8)}...${npub.slice(-4)}`;
return { fullMatch, npub, displayText };
});
const npubResults = await Promise.all(npubPromises);
for (const { fullMatch, npub, displayText } of npubResults) {
html = html.replace(
fullMatch,
`<a href="https://njump.me/${npub}" target="_blank" class="text-blue-600 hover:underline" title="${npub}">@${displayText}</a>`
);
}
// Process lists
html = processLists(html);
// Process footnotes
const { text: processedHtml } = processFootnotes(html);
html = processedHtml;
// Process basic markdown elements
html = html.replace(BOLD_REGEX, '<strong>$1$2</strong>');
html = html.replace(ITALIC_REGEX, '<em>$1</em>');
html = html.replace(HEADING_REGEX, (match, hashes, content) => {
const level = hashes.length;
const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs'];
return `<h${level} class="${sizes[level-1]} font-bold mt-4 mb-2">${content.trim()}</h${level}>`;
});
// Process links and images
html = html.replace(IMAGE_REGEX, '<img src="$2" alt="$1" class="max-w-full h-auto rounded">');
html = html.replace(LINK_REGEX, '<a href="$2" target="_blank" class="text-blue-600 hover:underline">$1</a>');
// Process hashtags
html = html.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>');
// Process horizontal rules
html = html.replace(HORIZONTAL_RULE_REGEX, '<hr class="my-6 border-t-2 border-gray-300 dark:border-gray-600">');
// Handle paragraphs and line breaks
html = html.replace(/\n{2,}/g, '</p><p class="my-4">');
html = html.replace(/\n/g, '<br>');
// Wrap content in paragraph if needed
if (!html.startsWith('<')) {
html = `<p class="my-4">${html}</p>`;
}
return html;
}
/**
* Escape HTML special characters to prevent XSS
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Escape special characters in a string for use in a regular expression
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

390
src/routes/contact/+page.svelte

@ -0,0 +1,390 @@ @@ -0,0 +1,390 @@
<script lang='ts'>
import { Heading, Img, P, A, Button, Label, Textarea, Input } from "flowbite-svelte";
import { ndkSignedIn, ndkInstance, activePubkey } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import { onMount } from 'svelte';
import NDK, { NDKEvent, NDKRelay, NDKRelaySet } from '@nostr-dev-kit/ndk';
// @ts-ignore - Workaround for Svelte component import issue
import LoginModal from '$lib/components/LoginModal.svelte';
import { parseMarkdown } from '$lib/utils/markdownParser';
import { nip19 } from 'nostr-tools';
// Function to close the success message
function closeSuccessMessage() {
submissionSuccess = false;
submittedEvent = null;
}
let subject = '';
let content = '';
let isSubmitting = false;
let showLoginModal = false;
let submissionSuccess = false;
let submissionError = '';
let submittedEvent: NDKEvent | null = null;
let issueLink = '';
let successfulRelays: string[] = [];
// Store form data when user needs to login
let savedFormData = {
subject: '',
content: ''
};
// Repository event address from the task
const repoAddress = 'naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr';
// Hard-coded relays to ensure we have working relays
const hardcodedRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
...standardRelays
];
// Hard-coded repository owner pubkey and ID from the task
// These values are extracted from the naddr
const repoOwnerPubkey = 'fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1';
const repoId = 'Alexandria';
onMount(() => {
console.log('Repository owner pubkey:', repoOwnerPubkey);
console.log('Repository ID:', repoId);
});
// Function to normalize relay URLs by removing trailing slashes
function normalizeRelayUrl(url: string): string {
return url.replace(/\/+$/, '');
}
async function handleSubmit() {
if (!subject || !content) {
submissionError = 'Please fill in all fields';
return;
}
// Check if user is logged in
if (!$ndkSignedIn) {
// Save form data
savedFormData = {
subject,
content
};
// Show login modal
showLoginModal = true;
return;
}
// User is logged in, proceed with submission
await submitIssue();
}
async function submitIssue() {
isSubmitting = true;
submissionError = '';
submissionSuccess = false;
try {
console.log('Starting issue submission...');
// Get NDK instance
const ndk = $ndkInstance;
if (!ndk) {
throw new Error('NDK instance not available');
}
if (!ndk.signer) {
throw new Error('No signer available. Make sure you are logged in.');
}
console.log('NDK instance available with signer');
console.log('Active pubkey:', $activePubkey);
// Log the repository reference values
console.log('Using repository reference values:', { repoOwnerPubkey, repoId });
// Create a new NDK event
const event = new NDKEvent(ndk);
event.kind = 1621; // issue_kind
event.tags.push(['subject', subject]);
event.tags.push(['alt', `git repository issue: ${subject}`]);
// Add repository reference with proper format
const aTagValue = `30617:${repoOwnerPubkey}:${repoId}`;
console.log('Adding a tag with value:', aTagValue);
event.tags.push([
'a',
aTagValue,
'',
'root'
]);
// Add repository owner as p tag with proper value
console.log('Adding p tag with value:', repoOwnerPubkey);
event.tags.push(['p', repoOwnerPubkey]);
// Set content
event.content = content;
console.log('Created NDK event:', event);
// Sign the event
console.log('Signing event...');
try {
await event.sign();
console.log('Event signed successfully');
} catch (error) {
console.error('Failed to sign event:', error);
throw new Error('Failed to sign event');
}
// Collect all unique relays
const uniqueRelays = new Set([
...hardcodedRelays.map(normalizeRelayUrl),
...standardRelays.map(normalizeRelayUrl),
...(ndk.pool ? Array.from(ndk.pool.relays.values())
.filter(relay => relay.url && !relay.url.includes('wss://nos.lol'))
.map(relay => normalizeRelayUrl(relay.url)) : [])
]);
console.log('Publishing to relays:', Array.from(uniqueRelays));
try {
// Create NDK relay set
const relaySet = NDKRelaySet.fromRelayUrls(Array.from(uniqueRelays), ndk);
// Track successful relays
successfulRelays = [];
// Set up listeners for successful publishes
const publishPromises = Array.from(uniqueRelays).map(relayUrl => {
return new Promise<void>(resolve => {
const relay = ndk.pool?.getRelay(relayUrl);
if (relay) {
relay.on('published', (publishedEvent: NDKEvent) => {
if (publishedEvent.id === event.id) {
console.log(`Event published to relay: ${relayUrl}`);
successfulRelays = [...successfulRelays, relayUrl];
resolve();
}
});
} else {
resolve(); // Resolve if relay not available
}
});
});
// Start publishing with timeout
const publishPromise = event.publish(relaySet);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Publish timeout')), 10000);
});
try {
await Promise.race([
publishPromise,
Promise.allSettled(publishPromises),
timeoutPromise
]);
console.log('Event published successfully to', successfulRelays.length, 'relays');
if (successfulRelays.length === 0) {
console.warn('Event published but no relay confirmations received');
}
} catch (error) {
if (successfulRelays.length > 0) {
console.warn('Partial publish success:', error);
} else {
throw new Error('Failed to publish to any relays');
}
}
// Store the submitted event and create issue link
submittedEvent = event;
// Create the issue link using the repository address
const noteId = nip19.noteEncode(event.id);
issueLink = `https://gitcitadel.com/r/${repoAddress}/issues/${noteId}`;
// Reset form and show success message
subject = '';
content = '';
submissionSuccess = true;
} catch (error) {
console.error('Failed to publish event:', error);
throw new Error('Failed to publish event');
}
} catch (error: any) {
console.error('Error submitting issue:', error);
submissionError = `Error submitting issue: ${error.message || 'Unknown error'}`;
} finally {
isSubmitting = false;
}
}
// Handle login completion
$: if ($ndkSignedIn && showLoginModal) {
showLoginModal = false;
// Restore saved form data
if (savedFormData.subject) subject = savedFormData.subject;
if (savedFormData.content) content = savedFormData.content;
// Submit the issue
submitIssue();
}
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4'>
<Heading tag='h1' class='h-leather mb-2'>Contact GitCitadel</Heading>
<P class="mb-3">
Make sure that you follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
</P>
<P class="mb-3">
You can contact us on Nostr <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A> or you can view submitted issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page.</A>
</P>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Submit an issue</Heading>
<P class="mb-3">
If you are logged into the Alexandria web application (using the button at the top-right of the window), then you can use the form, below, to submit an issue, that will appear on our repo page.
</P>
<form class="space-y-4 mt-6" on:submit|preventDefault={handleSubmit}>
<div>
<Label for="subject" class="mb-2">Subject</Label>
<Input id="subject" placeholder="Issue subject" bind:value={subject} required />
</div>
<div>
<Label for="content" class="mb-2">Description</Label>
<Textarea id="content" placeholder="Describe your issue in detail... (Markdown supported)" rows={12} bind:value={content} required />
</div>
<div class="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
Submitting...
{:else}
Submit Issue
{/if}
</Button>
</div>
{#if submissionSuccess && submittedEvent}
<div class="p-6 mb-4 text-sm bg-success-200 dark:bg-success-700 border border-success-300 dark:border-success-600 rounded-lg relative" role="alert">
<!-- Close button -->
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
on:click={closeSuccessMessage}
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-center mb-3">
<svg class="w-5 h-5 mr-2 text-success-700 dark:text-success-300" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium text-success-800 dark:text-success-200">Issue submitted successfully!</span>
</div>
<div class="mb-3 p-3 bg-white dark:bg-gray-800 rounded border border-success-200 dark:border-success-600">
<div class="mb-2">
<span class="font-semibold">Subject:</span>
<span>{submittedEvent.tags.find(t => t[0] === 'subject')?.[1] || 'No subject'}</span>
</div>
<div>
<span class="font-semibold">Description:</span>
<div class="mt-1 note-leather">
{#await parseMarkdown(submittedEvent.content)}
<p>Loading...</p>
{:then html}
{@html html}
{:catch error}
<p class="text-red-500">Error rendering markdown: {error.message}</p>
{/await}
</div>
</div>
</div>
<div class="mb-3">
<span class="font-semibold">View your issue:</span>
<div class="mt-1">
<A href={issueLink} target="_blank" class="text-blue-600 hover:underline break-all">
{issueLink}
</A>
</div>
</div>
<!-- Display successful relays -->
<div class="text-sm">
<span class="font-semibold">Successfully published to relays:</span>
<ul class="list-disc list-inside mt-1">
{#each successfulRelays as relay}
<li class="text-success-700 dark:text-success-300">{relay}</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if submissionError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{submissionError}
</div>
{/if}
</form>
</main>
</div>
<!-- Login Modal -->
<LoginModal
show={showLoginModal}
onClose={() => showLoginModal = false}
/>
<style>
:global(.footnote-ref) {
text-decoration: none;
color: var(--color-primary);
}
:global(.footnotes) {
margin-top: 2rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
:global(.footnotes hr) {
margin: 1rem 0;
border-top: 1px solid var(--color-border);
}
:global(.footnotes ol) {
padding-left: 1rem;
}
:global(.footnotes li) {
margin-bottom: 0.5rem;
}
:global(.footnote-backref) {
text-decoration: none;
margin-left: 0.5rem;
color: var(--color-primary);
}
:global(.note-leather) :global(.footnote-ref),
:global(.note-leather) :global(.footnote-backref) {
color: var(--color-leather-primary);
}
</style>
Loading…
Cancel
Save