4 changed files with 767 additions and 0 deletions
@ -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} |
||||
@ -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, '&') |
||||
.replace(/</g, '<') |
||||
.replace(/>/g, '>') |
||||
.replace(/"/g, '"') |
||||
.replace(/'/g, '''); |
||||
} |
||||
|
||||
/** |
||||
* Escape special characters in a string for use in a regular expression |
||||
*/ |
||||
function escapeRegExp(string: string): string { |
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
||||
} |
||||
@ -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…
Reference in new issue