diff --git a/src/app.css b/src/app.css index 6fd92df..ddc4b87 100644 --- a/src/app.css +++ b/src/app.css @@ -390,6 +390,83 @@ input:disabled, textarea:disabled, select:disabled { flex: 1; } +/* URL Preview Styles */ +.url-preview-container { + position: relative; +} + +.url-preview { + position: absolute; + top: 100%; + left: 0; + margin-top: 0.5rem; + z-index: 1000; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + width: 600px; + max-width: 90vw; + max-height: 500px; + display: flex; + flex-direction: column; + overflow: hidden; + pointer-events: auto; +} + +/* Position preview above if near bottom of viewport */ +@media (min-width: 769px) { + .url-preview-container:last-child .url-preview { + bottom: 100%; + top: auto; + margin-top: 0; + margin-bottom: 0.5rem; + } +} + +.preview-loading, +.preview-error { + padding: 1rem; + text-align: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.preview-error { + background: var(--warning-bg); + color: var(--warning-text); + font-size: 0.9rem; +} + +.preview-iframe { + width: 100%; + height: 400px; + border: none; + flex: 1; + background: white; +} + +.preview-url-display { + padding: 0.5rem 1rem; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + font-size: 0.85rem; + color: var(--text-secondary); + font-family: 'IBM Plex Mono', monospace; + word-break: break-all; +} + +@media (max-width: 768px) { + .url-preview { + width: calc(100vw - 2rem); + max-height: 400px; + } + + .preview-iframe { + height: 300px; + } +} + .search-input { width: 100%; padding: 0.75rem; diff --git a/src/lib/config.ts b/src/lib/config.ts index b392686..c7cfcb8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -22,7 +22,22 @@ export const DEFAULT_NOSTR_RELAYS = : [ 'wss://theforest.nostr1.com', 'wss://nostr.land', - 'wss://relay.damus.io' + ]; + +/** + * Nostr relays to use for searching for repositories, profiles, or other events + * Can be overridden by NOSTR_RELAYS env var (comma-separated list) + */ +export const DEFAULT_NOSTR_SEARCH_RELAYS = + typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS + ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) + : [ + 'wss://relay.damus.io', + 'wss://thecitadel.nostr1.com', + 'wss://nostr21.com', + 'wss://profiles.nostr1.com', + "wss://relay.primal.net", + ...DEFAULT_NOSTR_RELAYS, ]; /** diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index c5fcc0b..823e16a 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -28,12 +28,18 @@ let earliestCommit = $state(''); let isPrivate = $state(false); let isFork = $state(false); - let forkRepoAddress = $state(''); // a tag: 30617:owner:repo - let forkOwnerPubkey = $state(''); // p tag: original owner pubkey + let forkOriginalRepo = $state(''); // Original repo identifier: npub/repo, naddr, or 30617:owner:repo format let addClientTag = $state(true); // Add ["client", "gitrepublic-web"] tag let existingRepoRef = $state(''); // hex, nevent, or naddr let loadingExisting = $state(false); + // URL preview state + let previewingUrlIndex = $state(null); + let previewUrl = $state(null); + let previewError = $state(null); + let previewLoading = $state(false); + let previewTimeout: ReturnType | null = null; + import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../../lib/config.js'; const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -112,6 +118,73 @@ documentation = newDocs; } + async function handleWebUrlHover(index: number, url: string) { + // Clear any existing timeout + if (previewTimeout) { + clearTimeout(previewTimeout); + } + + // Only preview if URL looks valid + if (!url.trim() || !isValidUrl(url.trim())) { + return; + } + + // Delay preview to avoid showing on quick mouse movements + previewTimeout = setTimeout(async () => { + previewingUrlIndex = index; + previewUrl = url.trim(); + previewError = null; + previewLoading = true; + + // Try to verify the URL exists by attempting to fetch it + // Note: CORS may prevent this, but we'll still show the iframe preview + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout + + const response = await fetch(url.trim(), { + method: 'HEAD', + mode: 'no-cors', + cache: 'no-cache', + signal: controller.signal + }); + + clearTimeout(timeoutId); + // With no-cors mode, we can't read the status, but if it doesn't throw, proceed + previewError = null; + } catch (err) { + // If fetch fails, it might be CORS, network error, or 404 + // The iframe will show the actual error to the user + if (err instanceof Error && err.name === 'AbortError') { + previewError = 'Request timed out - URL may be slow or unreachable'; + } else { + previewError = 'Unable to verify URL - preview may show an error if URL is invalid'; + } + } finally { + previewLoading = false; + } + }, 500); // 500ms delay before showing preview + } + + function handleWebUrlLeave() { + if (previewTimeout) { + clearTimeout(previewTimeout); + } + previewingUrlIndex = null; + previewUrl = null; + previewError = null; + previewLoading = false; + } + + function isValidUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'; + } catch { + return false; + } + } + async function loadExistingRepo() { if (!existingRepoRef.trim()) return; @@ -246,10 +319,21 @@ // Extract fork information const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); - forkRepoAddress = aTag?.[1] || ''; - const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey); - forkOwnerPubkey = pTag?.[1] || ''; - isFork = !!(forkRepoAddress || forkOwnerPubkey || event.tags.some(t => t[0] === 't' && t[1] === 'fork')); + if (aTag?.[1]) { + forkOriginalRepo = aTag[1]; + isFork = true; + } else { + // Check if marked as fork via tag + isFork = event.tags.some(t => t[0] === 't' && t[1] === 'fork'); + if (isFork) { + // Try to construct from p tag if available + const pTag = event.tags.find(t => t[0] === 'p' && t[1] && t[1] !== event.pubkey); + if (pTag?.[1] && dTag) { + // Construct a tag format: 30617:owner:repo + forkOriginalRepo = `${KIND.REPO_ANNOUNCEMENT}:${pTag[1]}:${dTag}`; + } + } + } // Extract earliest unique commit const rTag = event.tags.find(t => t[0] === 'r' && t[2] === 'euc'); @@ -333,13 +417,98 @@ ]; // Add fork tags if this is a fork - if (isFork) { - if (forkRepoAddress.trim()) { - eventTags.push(['a', forkRepoAddress.trim()]); + if (isFork && forkOriginalRepo.trim()) { + let forkAddress = forkOriginalRepo.trim(); + let forkOwnerPubkey: string | null = null; + let isValidFormat = false; + + // Parse the fork identifier - could be: + // 1. naddr format (decode to get pubkey and repo) + // 2. npub/repo format (need to construct a tag) + // 3. Already in 30617:owner:repo format + if (forkAddress.startsWith('naddr')) { + try { + const decoded = nip19.decode(forkAddress); + if (decoded.type === 'naddr') { + const data = decoded.data as { pubkey: string; kind: number; identifier: string }; + if (data.pubkey && data.identifier) { + forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${data.pubkey}:${data.identifier}`; + forkOwnerPubkey = data.pubkey; + isValidFormat = true; + } + } + } catch { + // Invalid naddr, will be caught by validation below + } + } else if (forkAddress.includes('/') && !forkAddress.startsWith('30617:')) { + // Assume npub/repo format + const parts = forkAddress.split('/'); + if (parts.length === 2 && parts[1].trim()) { + try { + const decoded = nip19.decode(parts[0]); + if (decoded.type === 'npub') { + forkOwnerPubkey = decoded.data as string; + forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${forkOwnerPubkey}:${parts[1].trim()}`; + isValidFormat = true; + } + } catch { + // Invalid npub, try as hex pubkey + if (parts[0].length === 64 && /^[0-9a-f]+$/i.test(parts[0])) { + forkOwnerPubkey = parts[0]; + forkAddress = `${KIND.REPO_ANNOUNCEMENT}:${forkOwnerPubkey}:${parts[1].trim()}`; + isValidFormat = true; + } + } + } + } else if (forkAddress.startsWith('30617:')) { + // Already in correct format, validate and extract owner pubkey + const parts = forkAddress.split(':'); + if (parts.length >= 3 && parts[1] && parts[2]) { + forkOwnerPubkey = parts[1]; + isValidFormat = true; + } + } + + // Validate the final format: must be 30617:pubkey:repo + // Always validate regardless of parsing success to catch any edge cases + const parts = forkAddress.split(':'); + if (parts.length >= 3) { + const kind = parts[0]; + const pubkey = parts[1]; + const repo = parts[2]; + + // Validate format + if (kind !== String(KIND.REPO_ANNOUNCEMENT)) { + isValidFormat = false; + } else if (!pubkey || pubkey.length !== 64 || !/^[0-9a-f]+$/i.test(pubkey)) { + isValidFormat = false; + } else if (!repo || !repo.trim()) { + isValidFormat = false; + } else { + // Format is valid, ensure isValidFormat is true + isValidFormat = true; + } + } else { + isValidFormat = false; + } + + if (!isValidFormat) { + error = 'Invalid fork repository format. Please use one of:\n' + + '• naddr format: naddr1...\n' + + '• npub/repo format: npub1abc.../repo-name\n' + + '• Repository address: 30617:owner-pubkey:repo-name'; + loading = false; + return; } - if (forkOwnerPubkey.trim()) { - eventTags.push(['p', forkOwnerPubkey.trim()]); + + // Add a tag (required for fork identification) + eventTags.push(['a', forkAddress]); + + // Add p tag if we have the owner pubkey + if (forkOwnerPubkey) { + eventTags.push(['p', forkOwnerPubkey]); } + // Add 'fork' tag if not already in tags if (!allTags.includes('fork')) { eventTags.push(['t', 'fork']); @@ -521,14 +690,16 @@
Web URLs (optional) - Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface) + Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface). Hover over a URL to preview it.
{#each webUrls as url, index} -
+
updateWebUrl(index, e.currentTarget.value)} + onmouseenter={() => handleWebUrlHover(index, url)} + onmouseleave={handleWebUrlLeave} placeholder="https://github.com/user/repo" disabled={loading} /> @@ -541,6 +712,24 @@ Remove {/if} + {#if previewingUrlIndex === index && previewUrl} + + {/if}
{/each}
- -
-
@@ -767,7 +946,7 @@ />
Private Repository - Mark this repository as private (will be hidden from public listings) + Private repositories are hidden from public listings and can only be accessed by the owner and maintainers. Git clone/fetch operations require authentication.