Browse Source

bug-fixes

master
Silberengel 4 weeks ago
parent
commit
b7d344363d
  1. 20
      src/lib/components/modals/EventJsonModal.svelte
  2. 416
      src/lib/components/write/CreateEventForm.svelte
  3. 19
      src/lib/services/content/git-repo-fetcher.ts
  4. 10
      src/routes/repos/[naddr]/+page.svelte
  5. 106
      src/routes/write/+page.svelte
  6. 4
      static/healthz.json

20
src/lib/components/modals/EventJsonModal.svelte

@ -140,6 +140,7 @@ @@ -140,6 +140,7 @@
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
margin: 0 auto; /* Center the modal */
}
@media (max-width: 768px) {
@ -278,6 +279,8 @@ @@ -278,6 +279,8 @@
overflow-x: auto;
max-height: 60vh;
white-space: pre;
text-align: left; /* Ensure text aligns left */
position: relative; /* Ensure proper stacking context */
}
.json-preview.word-wrap {
@ -291,12 +294,13 @@ @@ -291,12 +294,13 @@
.json-preview code {
display: block;
padding: 0;
background: transparent !important;
background: #000000 !important; /* Match parent background to prevent any bleed-through */
/* Colors are defined by highlight.js vs2015 theme */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre;
position: relative; /* Ensure proper stacking */
}
.json-preview.word-wrap code {
@ -316,6 +320,20 @@ @@ -316,6 +320,20 @@
overflow-wrap: break-word !important;
}
/* Ensure highlight.js doesn't override the background */
.json-preview :global(code.hljs),
.json-preview :global(.hljs) {
background: #000000 !important;
padding: 0 !important;
margin: 0 !important;
}
/* Override any highlight.js theme backgrounds that might be causing the offset */
.json-preview :global(.hljs *),
.json-preview :global(code.hljs *) {
background: transparent !important;
}
@media (max-width: 768px) {
.json-preview {
padding: 0.75rem;

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

@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
import AdvancedEditor from './AdvancedEditor.svelte';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { goto } from '$app/navigation';
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js';
import { KIND_LOOKUP } from '../../types/kind-lookup.js';
import { getKindMetadata, getWritableKinds } from '../../types/kind-metadata.js';
import type { NostrEvent } from '../../types/nostr.js';
import { autoExtractTags, ensureDTagForParameterizedReplaceable } from '../../services/auto-tagging.js';
@ -26,27 +26,84 @@ @@ -26,27 +26,84 @@
const SUPPORTED_KINDS = getWritableKinds();
interface Props {
initialKind?: number | null;
initialContent?: string | null;
initialTags?: string[][] | null;
initialEvent?: NostrEvent | null;
}
let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props();
let { initialEvent = null }: Props = $props();
const DRAFT_ID = 'write';
let selectedKind = $state<number>(1);
let customKindId = $state<string>('');
let content = $state('');
let tags = $state<string[][]>([]);
// Extract initial values from event
const getInitialKind = (): number => {
if (initialEvent?.kind !== undefined) {
const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind);
return isSupported ? initialEvent.kind : -1; // -1 means "unknown kind"
}
return 1; // Default to kind 1
};
const getInitialCustomKindId = (): string => {
if (initialEvent?.kind !== undefined) {
const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind);
return isSupported ? '' : String(initialEvent.kind);
}
return '';
};
const getInitialContent = (): string => {
return initialEvent?.content || '';
};
const getInitialTags = (): string[][] => {
return initialEvent?.tags ? [...initialEvent.tags] : [];
};
// Initialize state
let selectedKind = $state<number>(getInitialKind());
let customKindId = $state<string>(getInitialCustomKindId());
let content = $state<string>(getInitialContent());
let tags = $state<string[][]>(getInitialTags());
let publishing = $state(false);
let showAdvancedEditor = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
// Modal states
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let showExampleModal = $state(false);
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
// Restore draft from IndexedDB on mount (only if no initial props)
// Preview/JSON refs
let jsonPreviewRef: HTMLElement | null = $state(null);
let exampleJsonPreviewRef: HTMLElement | null = $state(null);
let eventJson = $state('{}');
let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null);
// Sync when initialEvent changes
$effect(() => {
if (typeof window === 'undefined') return;
if (initialEvent) {
const isSupported = SUPPORTED_KINDS.some(k => k.value === initialEvent.kind);
if (isSupported) {
selectedKind = initialEvent.kind;
customKindId = '';
} else {
selectedKind = -1;
customKindId = String(initialEvent.kind);
}
content = initialEvent.content || '';
tags = initialEvent.tags ? [...initialEvent.tags] : [];
}
});
// Restore draft from IndexedDB if no initial event
$effect(() => {
if (typeof window === 'undefined' || initialEvent) return;
// Only restore if no initial content/tags were provided (from highlight feature)
if (propInitialContent === null && propInitialTags === null) {
(async () => {
try {
const draft = await getDraft(DRAFT_ID);
@ -57,7 +114,7 @@ @@ -57,7 +114,7 @@
if (draft.tags && draft.tags.length > 0 && tags.length === 0) {
tags = draft.tags;
}
if (draft.selectedKind !== undefined && initialKind === null) {
if (draft.selectedKind !== undefined) {
selectedKind = draft.selectedKind;
}
}
@ -65,18 +122,15 @@ @@ -65,18 +122,15 @@
console.error('Error restoring draft:', error);
}
})();
}
});
// Save draft to IndexedDB when content or tags change
// Save draft to IndexedDB
$effect(() => {
if (typeof window === 'undefined') return;
if (publishing) return; // Don't save while publishing
if (publishing || initialEvent) return; // Don't save while publishing or when editing an event
// Debounce saves to avoid excessive IndexedDB writes
const timeoutId = setTimeout(async () => {
try {
// Only save if there's actual content
if (content.trim() || tags.length > 0) {
await saveDraft(DRAFT_ID, {
content,
@ -84,7 +138,6 @@ @@ -84,7 +138,6 @@
selectedKind
});
} else {
// Clear if empty
await deleteDraft(DRAFT_ID);
}
} catch (error) {
@ -94,35 +147,6 @@ @@ -94,35 +147,6 @@
return () => clearTimeout(timeoutId);
});
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let showJsonModal = $state(false);
let showPreviewModal = $state(false);
let exampleJsonPreviewRef: HTMLElement | null = $state(null);
let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null);
let showExampleModal = $state(false);
// Media viewer state for preview
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
let showAdvancedEditor = $state(false);
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
let jsonPreviewRef: HTMLElement | null = $state(null);
// Highlight JSON when it changes
$effect(() => {
@ -132,7 +156,6 @@ @@ -132,7 +156,6 @@
jsonPreviewRef.innerHTML = highlighted;
jsonPreviewRef.className = 'hljs language-json';
} catch (err) {
// Fallback to plain text if highlighting fails
jsonPreviewRef.textContent = eventJson;
jsonPreviewRef.className = 'language-json';
}
@ -141,45 +164,20 @@ @@ -141,45 +164,20 @@
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
// Determine editor mode based on selected kind
const editorMode = $derived(
effectiveKind === 30818 || effectiveKind === 30041 ? 'asciidoc' : 'markdown'
);
// Show advanced editor button when editing (has initial content) or for AsciiDoc kinds
const showAdvancedEditorButton = $derived(
(propInitialContent !== null && propInitialContent !== undefined) ||
initialEvent !== null ||
effectiveKind === 30818 ||
effectiveKind === 30041 ||
effectiveKind === 30023 || // Long-form note (markdown)
effectiveKind === 1 // Short text note (markdown)
effectiveKind === 30023 ||
effectiveKind === 1
);
// Sync selectedKind when initialKind prop changes
$effect(() => {
if (initialKind !== null && initialKind !== undefined) {
selectedKind = initialKind;
}
});
// Track if we've already applied initial props to prevent re-applying after clear
let initialPropsApplied = $state(false);
let formCleared = $state(false); // Track if form was explicitly cleared
// Sync content and tags when initial props change (only if form is empty and not yet applied)
$effect(() => {
if (initialPropsApplied || formCleared) return; // Don't re-apply after they've been used or after clear
if (propInitialContent !== null && propInitialContent !== undefined && content === '') {
content = propInitialContent;
initialPropsApplied = true;
}
if (propInitialTags !== null && propInitialTags !== undefined && propInitialTags.length > 0 && tags.length === 0) {
tags = [...propInitialTags];
initialPropsApplied = true;
}
});
const kindMetadata = $derived(getKindMetadata(effectiveKind));
const helpText = $derived(kindMetadata.helpText);
const isKind30040 = $derived(selectedKind === 30040);
const isKind10895 = $derived(selectedKind === 10895);
// Clear content for metadata-only kinds
$effect(() => {
@ -189,9 +187,6 @@ @@ -189,9 +187,6 @@
}
});
const kindMetadata = $derived(getKindMetadata(effectiveKind));
const helpText = $derived(kindMetadata.helpText);
function getExampleJSON(): string {
const examplePubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
const exampleEventId = '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446';
@ -202,7 +197,7 @@ @@ -202,7 +197,7 @@
const exampleJSON = $derived(getExampleJSON());
// Highlight example JSON when it changes
// Highlight example JSON
$effect(() => {
if (exampleJsonPreviewRef && exampleJSON && exampleJsonPreviewRef instanceof HTMLElement) {
try {
@ -210,16 +205,12 @@ @@ -210,16 +205,12 @@
exampleJsonPreviewRef.innerHTML = highlighted;
exampleJsonPreviewRef.className = 'hljs language-json';
} catch (err) {
// Fallback to plain text if highlighting fails
exampleJsonPreviewRef.textContent = exampleJSON;
exampleJsonPreviewRef.className = 'language-json';
}
}
});
const isKind30040 = $derived(selectedKind === KIND.PUBLICATION_INDEX);
const isKind10895 = $derived(selectedKind === KIND.RSS_FEED);
function addTag() {
tags = [...tags, ['', '']];
}
@ -245,16 +236,11 @@ @@ -245,16 +236,11 @@
const session = sessionManager.getSession();
if (!session) return '{}';
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
const allTags = [...tags.filter(t => t[0] && t[1])];
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
allTags.push(file.imetaTag);
// Add URL to content field only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
@ -263,7 +249,6 @@ @@ -263,7 +249,6 @@
}
}
// Auto-extract tags from content (hashtags, mentions, nostr: links)
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: allTags,
@ -271,7 +256,6 @@ @@ -271,7 +256,6 @@
});
allTags.push(...autoTagsResult.tags);
// For parameterized replaceable events, ensure d-tag exists (for preview)
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind);
if (dTagResult) {
@ -283,7 +267,6 @@ @@ -283,7 +267,6 @@
allTags.push(['client', 'aitherboard']);
}
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(contentWithUrls.trim());
@ -298,12 +281,10 @@ @@ -298,12 +281,10 @@
return JSON.stringify(event, null, 2);
}
function handleFilesUploaded(files: Array<{ url: string; imetaTag: string[] }>) {
uploadedFiles = files;
}
async function publish() {
const session = sessionManager.getSession();
if (!session) {
@ -319,21 +300,15 @@ @@ -319,21 +300,15 @@
publishing = true;
try {
// Add file attachments as imeta tags (like jumble)
let contentWithUrls = content.trim();
// 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
.map(tag => [...tag]);
for (const file of uploadedFiles) {
// Use imeta tag from upload response (like jumble)
// 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 only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
@ -342,7 +317,6 @@ @@ -342,7 +317,6 @@
}
}
// Auto-extract tags from content (hashtags, mentions, nostr: links)
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: allTags,
@ -350,16 +324,13 @@ @@ -350,16 +324,13 @@
});
allTags.push(...autoTagsResult.tags);
// For parameterized replaceable events (30000-39999), ensure d-tag exists
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(allTags, effectiveKind);
if (dTagResult) {
allTags.push(['d', dTagResult.dTag]);
} else {
// Check if d-tag exists (it should after ensureDTagForParameterizedReplaceable if title exists)
const existingDTag = allTags.find(t => t[0] === 'd' && t[1]);
if (!existingDTag) {
// No d-tag and no title tag - alert user
alert(`Parameterized replaceable events (kind ${effectiveKind}) require a d-tag. Please add a d-tag or a title tag that can be normalized to a d-tag.`);
publishing = false;
return;
@ -371,11 +342,9 @@ @@ -371,11 +342,9 @@
allTags.push(['client', 'aitherboard']);
}
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(contentWithUrls.trim());
// Create a plain object (not a Proxy) to avoid cloning issues
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: effectiveKind,
pubkey: session.pubkey,
@ -399,11 +368,10 @@ @@ -399,11 +368,10 @@
if (results.success.length > 0) {
content = '';
tags = [];
uploadedFiles = []; // Clear uploaded files after successful publish
uploadedFiles = [];
if (richTextEditorRef) {
richTextEditorRef.clearUploadedFiles();
}
// Clear draft from IndexedDB after successful publish
await deleteDraft(DRAFT_ID);
setTimeout(() => {
goto(`/event/${signedEvent.id}`);
@ -424,27 +392,15 @@ @@ -424,27 +392,15 @@
async function clearForm() {
if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) {
try {
// Mark form as cleared to prevent initial props from re-applying
formCleared = true;
// Clear state synchronously
content = '';
tags = [];
uploadedFiles = [];
customKindId = '';
selectedKind = 1; // Reset to default kind
// Reset the initial props applied flag
initialPropsApplied = false;
// Clear draft from IndexedDB after clearing state
// This prevents the save effect from running with old data
selectedKind = 1;
if (richTextEditorRef) {
richTextEditorRef.clearUploadedFiles();
}
await deleteDraft(DRAFT_ID);
// Reset formCleared flag after a brief delay to allow effects to settle
setTimeout(() => {
formCleared = false;
}, 100);
} catch (error) {
console.error('Error clearing form:', error);
alert('Failed to clear form. Please try again.');
@ -465,12 +421,10 @@ @@ -465,12 +421,10 @@
const session = sessionManager.getSession();
if (!session) return;
// 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
.map(tag => [...tag]);
// Process content to add "nostr:" prefix to valid Nostr addresses
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
const processedContent = processNostrLinks(content);
@ -490,6 +444,73 @@ @@ -490,6 +444,73 @@
publishing = false;
}
}
function handleMediaUrlClick(url: string, e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
async function showPreview() {
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
}
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
previewContent = processNostrLinks(contentWithUrls.trim());
const previewTags: string[][] = [];
for (const tag of tags) {
if (tag[0] && tag[1]) {
previewTags.push([...tag]);
}
}
for (const file of uploadedFiles) {
previewTags.push(file.imetaTag);
}
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: previewTags,
kind: effectiveKind
});
previewTags.push(...autoTagsResult.tags);
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(previewTags, effectiveKind);
if (dTagResult) {
previewTags.push(['d', dTagResult.dTag]);
}
}
if (shouldIncludeClientTag()) {
previewTags.push(['client', 'aitherboard']);
}
previewEvent = {
kind: effectiveKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags: previewTags,
content: previewContent,
id: '',
sig: ''
} as NostrEvent;
showPreviewModal = true;
}
</script>
<div class="create-form-container">
@ -527,11 +548,12 @@ @@ -527,11 +548,12 @@
{#each SUPPORTED_KINDS as kind}
<option value={kind.value}>{kind.label}</option>
{/each}
{#if selectedKind !== -1 && !SUPPORTED_KINDS.find(k => k.value === selectedKind)}
{#if selectedKind !== -1 && selectedKind !== 1 && !SUPPORTED_KINDS.find(k => k.value === selectedKind)}
{@const kindInfo = getKindMetadata(selectedKind)}
{@const kindDescription = kindInfo?.description || KIND_LOOKUP[selectedKind]?.description || 'Unknown'}
<option value={selectedKind}>{selectedKind} - {kindDescription}</option>
{/if}
<option value={-1}>Unknown Kind</option>
</select>
{#if isUnknownKind}
<div class="custom-kind-input">
@ -592,66 +614,7 @@ @@ -592,66 +614,7 @@
</button>
<button
type="button"
onclick={async () => {
// Generate preview content with all processing applied
let contentWithUrls = content.trim();
for (const file of uploadedFiles) {
if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n';
}
contentWithUrls += `${file.url}\n`;
}
}
// Process content to add "nostr:" prefix
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js');
previewContent = processNostrLinks(contentWithUrls.trim());
// Build preview event with all tags
const previewTags: string[][] = [];
for (const tag of tags) {
if (tag[0] && tag[1]) {
previewTags.push([...tag]);
}
}
for (const file of uploadedFiles) {
previewTags.push(file.imetaTag);
}
// Auto-extract tags
const autoTagsResult = await autoExtractTags({
content: contentWithUrls,
existingTags: previewTags,
kind: effectiveKind
});
previewTags.push(...autoTagsResult.tags);
// For parameterized replaceable events, ensure d-tag exists
if (isParameterizedReplaceableKind(effectiveKind)) {
const dTagResult = ensureDTagForParameterizedReplaceable(previewTags, effectiveKind);
if (dTagResult) {
previewTags.push(['d', dTagResult.dTag]);
}
}
// Include client tag if selected
if (shouldIncludeClientTag()) {
previewTags.push(['client', 'aitherboard']);
}
previewEvent = {
kind: effectiveKind,
pubkey: sessionManager.getCurrentPubkey() || '',
created_at: Math.floor(Date.now() / 1000),
tags: previewTags,
content: previewContent,
id: '',
sig: ''
} as NostrEvent;
showPreviewModal = true;
}}
onclick={showPreview}
class="content-button"
disabled={publishing}
title="Preview"
@ -733,8 +696,8 @@ @@ -733,8 +696,8 @@
/>
{/if}
<!-- JSON View Modal -->
{#if showJsonModal}
<!-- JSON View Modal -->
{#if showJsonModal}
<div
class="modal-overlay"
onclick={() => showJsonModal = false}
@ -778,10 +741,10 @@ @@ -778,10 +741,10 @@
</div>
</div>
</div>
{/if}
{/if}
<!-- Preview Modal -->
{#if showPreviewModal}
<!-- Preview Modal -->
{#if showPreviewModal}
<div
class="modal-overlay"
onclick={() => showPreviewModal = false}
@ -809,7 +772,6 @@ @@ -809,7 +772,6 @@
</div>
<div class="modal-body preview-body">
{#if previewEvent && previewContent}
<!-- Essential Metadata Display -->
{@const titleTag = previewEvent.tags.find(t => (t[0] === 'title' || t[0] === 'T') && t[1])}
{@const authorTag = previewEvent.tags.find(t => t[0] === 'author' && t[1])}
{@const summaryTag = previewEvent.tags.find(t => t[0] === 'summary' && t[1])}
@ -875,14 +837,14 @@ @@ -875,14 +837,14 @@
</div>
</div>
</div>
{/if}
{/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<!-- Example JSON Modal -->
{#if showExampleModal}
<!-- Example JSON Modal -->
{#if showExampleModal}
<div
class="modal-overlay"
onclick={() => showExampleModal = false}
@ -926,7 +888,7 @@ @@ -926,7 +888,7 @@
</div>
</div>
</div>
{/if}
{/if}
{#if publicationResults && publicationResults.success.length === 0 && publicationResults.failed.length > 0}
<div class="republish-section">
@ -1059,13 +1021,13 @@ @@ -1059,13 +1021,13 @@
.example-button:hover {
background: var(--fog-accent, #64748b);
color: #ffffff; /* White text on accent background for good contrast */
color: #ffffff;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .example-button:hover {
background: var(--fog-dark-accent, #94a3b8);
color: #ffffff; /* White text on dark accent for good contrast */
color: #ffffff;
border-color: var(--fog-dark-accent, #94a3b8);
}
@ -1087,7 +1049,7 @@ @@ -1087,7 +1049,7 @@
}
.example-modal .json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */
background: #1e1e1e !important;
border: 1px solid #3e3e3e;
border-radius: 4px;
padding: 1rem;
@ -1101,7 +1063,7 @@ @@ -1101,7 +1063,7 @@
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4; /* VS Code text color */
color: #d4d4d4;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
@ -1192,13 +1154,13 @@ @@ -1192,13 +1154,13 @@
.advanced-editor-button:hover:not(:disabled) {
background: var(--fog-accent, #64748b);
color: #ffffff; /* White text on accent background for good contrast */
color: #ffffff;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .advanced-editor-button:hover:not(:disabled) {
background: var(--fog-dark-accent, #94a3b8);
color: #ffffff; /* White text on dark accent for good contrast */
color: #ffffff;
border-color: var(--fog-dark-accent, #94a3b8);
}
@ -1265,7 +1227,6 @@ @@ -1265,7 +1227,6 @@
color: var(--fog-dark-text, #cbd5e1);
}
.tags-list {
display: flex;
flex-direction: column;
@ -1384,7 +1345,7 @@ @@ -1384,7 +1345,7 @@
.publish-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: #ffffff; /* White text on accent background for good contrast */
color: #ffffff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
@ -1395,7 +1356,7 @@ @@ -1395,7 +1356,7 @@
:global(.dark) .publish-button {
background: var(--fog-dark-accent, #94a3b8);
color: #ffffff; /* White text on dark accent for good contrast */
color: #ffffff;
}
.publish-button:hover:not(:disabled) {
@ -1433,7 +1394,7 @@ @@ -1433,7 +1394,7 @@
.republish-button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: #ffffff; /* White text on accent background for good contrast */
color: #ffffff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
@ -1444,7 +1405,7 @@ @@ -1444,7 +1405,7 @@
:global(.dark) .republish-button {
background: var(--fog-dark-accent, #94a3b8);
color: #ffffff; /* White text on dark accent for good contrast */
color: #ffffff;
}
.republish-button:hover:not(:disabled) {
@ -1456,7 +1417,6 @@ @@ -1456,7 +1417,6 @@
cursor: not-allowed;
}
.content-buttons {
display: flex;
gap: 0.5rem;
@ -1500,7 +1460,7 @@ @@ -1500,7 +1460,7 @@
:global(.dark) .content-button:hover:not(:disabled) {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #64748b);
border-color: var(--fog-dark-accent, #94a3b8);
}
.content-button:disabled {
@ -1508,7 +1468,6 @@ @@ -1508,7 +1468,6 @@
cursor: not-allowed;
}
/* Modal styles */
.modal-overlay {
position: fixed;
@ -1651,7 +1610,7 @@ @@ -1651,7 +1610,7 @@
}
.json-preview {
background: #1e1e1e !important; /* VS Code dark background, same as code blocks */
background: #1e1e1e !important;
border: 1px solid #3e3e3e;
border-radius: 0.5rem;
padding: 1rem;
@ -1664,7 +1623,7 @@ @@ -1664,7 +1623,7 @@
overflow-x: auto;
padding: 0;
background: transparent !important;
color: #d4d4d4; /* VS Code text color */
color: #d4d4d4;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
font-size: 0.875rem;
line-height: 1.5;
@ -1797,19 +1756,6 @@ @@ -1797,19 +1756,6 @@
color: var(--fog-dark-text-light, #94a3b8);
}
.d-tag-preview code {
background: #e2e8f0;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.8125rem;
}
:global(.dark) .d-tag-preview code {
background: #334155;
color: #f1f5f9;
}
.modal-footer {
display: flex;
justify-content: flex-end;
@ -1863,6 +1809,6 @@ @@ -1863,6 +1809,6 @@
:global(.dark) .modal-footer button:hover {
background: var(--fog-dark-border, #475569);
border-color: var(--fog-dark-accent, #64748b);
border-color: var(--fog-dark-accent, #94a3b8);
}
</style>

19
src/lib/services/content/git-repo-fetcher.ts

@ -45,10 +45,24 @@ export interface GitFile { @@ -45,10 +45,24 @@ export interface GitFile {
size?: number;
}
/**
* Check if a URL is a GRASP (Git Repository Access via Secure Protocol) URL
* GRASP URLs contain npub (Nostr public key) in the path: https://host/npub.../repo.git
*/
function isGraspUrl(url: string): boolean {
// GRASP URLs have npub (starts with npub1) in the path
return /\/npub1[a-z0-9]+/i.test(url);
}
/**
* Parse git URL to extract platform, owner, and repo
*/
function parseGitUrl(url: string): { platform: string; owner: string; repo: string; baseUrl: string } | null {
// Skip GRASP URLs - they don't use standard git hosting APIs
if (isGraspUrl(url)) {
return null;
}
// GitHub
const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (githubMatch) {
@ -89,8 +103,13 @@ function parseGitUrl(url: string): { platform: string; owner: string; repo: stri @@ -89,8 +103,13 @@ function parseGitUrl(url: string): { platform: string; owner: string; repo: stri
// Gitea and other Git hosting services (generic pattern) - matches https://host/owner/repo.git or https://host/owner/repo
// This is a catch-all for services that use Gitea-compatible API (like Gitea, Forgejo, etc.)
// But skip if it looks like a GRASP URL (has npub in path)
const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (giteaMatch) {
// Double-check it's not a GRASP URL (npub in owner position)
if (giteaMatch[2].startsWith('npub1')) {
return null;
}
return {
platform: 'gitea',
owner: giteaMatch[2],

10
src/routes/repos/[naddr]/+page.svelte

@ -1234,6 +1234,11 @@ @@ -1234,6 +1234,11 @@
<div class="readme-container">
{@html renderReadme(gitRepo.readme.content, gitRepo.readme.format)}
</div>
{:else if gitRepoFetchAttempted && !loadingGitRepo && extractGitUrls(repoEvent).some(url => /\/npub1[a-z0-9]+/i.test(url))}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">GRASP (Git Repository Access via Secure Protocol) repositories are not yet supported for fetching README files.</p>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">The repository description is shown in the header above.</p>
</div>
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No README found.</p>
@ -1295,6 +1300,11 @@ @@ -1295,6 +1300,11 @@
</div>
{/if}
</div>
{:else if gitRepoFetchAttempted && !loadingGitRepo && extractGitUrls(repoEvent).some(url => /\/npub1[a-z0-9]+/i.test(url))}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">GRASP (Git Repository Access via Secure Protocol) repositories are not yet supported for fetching repository data.</p>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">GRASP uses a different protocol than standard git hosting services and cannot be accessed via their APIs.</p>
</div>
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">Git repository data not available.</p>

106
src/routes/write/+page.svelte

@ -6,22 +6,80 @@ @@ -6,22 +6,80 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../lib/types/nostr.js';
// 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 initialEvent = $state<NostrEvent | null>(null);
let isCloneMode = $state(false);
// Read from sessionStorage synchronously (runs immediately, not in onMount)
// This ensures initialEvent is set before CreateEventForm renders
if (typeof window !== 'undefined') {
// Check for clone/edit event data in sessionStorage (takes priority)
const cloneDataStr = sessionStorage.getItem('aitherboard_cloneEvent');
if (cloneDataStr) {
try {
const cloneData = JSON.parse(cloneDataStr);
// Construct event from clone data
// Use nullish coalescing to properly handle kind 0
initialEvent = {
id: '',
pubkey: '',
created_at: Math.floor(Date.now() / 1000),
kind: cloneData.kind !== undefined && cloneData.kind !== null ? cloneData.kind : 1,
content: cloneData.content || '',
tags: cloneData.tags || [],
sig: ''
};
isCloneMode = cloneData.isClone === true;
// Clear sessionStorage after reading
sessionStorage.removeItem('aitherboard_cloneEvent');
} catch (error) {
console.error('Error parsing clone event data:', error);
}
} else {
// Check for highlight data in sessionStorage (fallback)
const highlightDataStr = sessionStorage.getItem('aitherboard_highlightData');
if (highlightDataStr) {
try {
const highlightData = JSON.parse(highlightDataStr);
// Construct event from highlight data (default to kind 1)
initialEvent = {
id: '',
pubkey: '',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
content: highlightData.content || '',
tags: highlightData.tags || [],
sig: ''
};
// Clear sessionStorage after reading
sessionStorage.removeItem('aitherboard_highlightData');
} catch (error) {
console.error('Error parsing highlight data:', error);
}
}
}
}
// Set initial kind from URL if available
// Set initial kind from URL if available (only if no event from sessionStorage)
$effect(() => {
if (kindParam) {
if (kindParam && !initialEvent) {
const kind = parseInt(kindParam, 10);
if (!isNaN(kind)) {
initialKind = kind;
initialEvent = {
id: '',
pubkey: '',
created_at: Math.floor(Date.now() / 1000),
kind: kind,
content: '',
tags: [],
sig: ''
};
}
}
});
let initialContent = $state<string | null>(null);
let initialTags = $state<string[][] | null>(null);
let isCloneMode = $state(false);
// Subscribe to session changes to reactively update login status
let currentSession = $state(sessionManager.session.value);
@ -46,36 +104,6 @@ @@ -46,36 +104,6 @@
console.error('Failed to restore session in write page:', error);
}
}
// Check for clone/edit event data in sessionStorage (takes priority)
const cloneDataStr = sessionStorage.getItem('aitherboard_cloneEvent');
if (cloneDataStr) {
try {
const cloneData = JSON.parse(cloneDataStr);
initialKind = cloneData.kind || null;
initialContent = cloneData.content || null;
initialTags = cloneData.tags || null;
isCloneMode = cloneData.isClone === true;
// Clear sessionStorage after reading
sessionStorage.removeItem('aitherboard_cloneEvent');
} catch (error) {
console.error('Error parsing clone event data:', error);
}
} else {
// Check for highlight data in sessionStorage (fallback)
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>
@ -95,9 +123,7 @@ @@ -95,9 +123,7 @@
{:else}
<div class="form-container">
<CreateEventForm
initialKind={initialKind}
initialContent={initialContent}
initialTags={initialTags}
initialEvent={initialEvent}
/>
</div>
{/if}

4
static/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.3.2",
"buildTime": "2026-02-14T09:16:17.577Z",
"buildTime": "2026-02-14T16:25:59.663Z",
"gitCommit": "unknown",
"timestamp": 1771060577577
"timestamp": 1771086359663
}
Loading…
Cancel
Save