You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2023 lines
71 KiB
2023 lines
71 KiB
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { goto } from '$app/navigation'; |
|
import { page } from '$app/stores'; |
|
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '../../lib/services/nostr/nip07-signer.js'; |
|
import { decodeNostrAddress } from '../../lib/services/nostr/nip19-utils.js'; |
|
import { NostrClient } from '../../lib/services/nostr/nostr-client.js'; |
|
import { KIND } from '../../lib/types/nostr.js'; |
|
import type { NostrEvent } from '../../lib/types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
|
|
let nip07Available = $state(false); |
|
let loading = $state(false); |
|
let error = $state<string | null>(null); |
|
let success = $state(false); |
|
|
|
// Form fields |
|
let repoName = $state(''); |
|
let description = $state(''); |
|
let cloneUrls = $state<string[]>(['']); |
|
let webUrls = $state<string[]>(['']); |
|
let maintainers = $state<string[]>(['']); |
|
let relays = $state<string[]>(['']); |
|
let blossoms = $state<string[]>(['']); |
|
let tags = $state<string[]>(['']); |
|
let documentation = $state<string[]>(['']); |
|
let alt = $state(''); |
|
let imageUrl = $state(''); |
|
let bannerUrl = $state(''); |
|
let earliestCommit = $state(''); |
|
let isPrivate = $state(false); |
|
let isFork = $state(false); |
|
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<number | null>(null); |
|
let previewUrl = $state<string | null>(null); |
|
let previewError = $state<string | null>(null); |
|
let previewLoading = $state(false); |
|
let previewTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// Lookup state |
|
let lookupLoading = $state<{ [key: string]: boolean }>({}); |
|
let lookupError = $state<{ [key: string]: string | null }>({}); |
|
type ProfileData = { pubkey: string; npub: string; name?: string; about?: string; picture?: string }; |
|
let lookupResults = $state<{ [key: string]: Array<ProfileData | NostrEvent> | null }>({}); |
|
|
|
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '../../lib/config.js'; |
|
|
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); |
|
|
|
onMount(async () => { |
|
nip07Available = isNIP07Available(); |
|
|
|
// Check for query params to pre-fill form (for registering local clones) |
|
const urlParams = $page.url.searchParams; |
|
const npubParam = urlParams.get('npub'); |
|
const repoParam = urlParams.get('repo'); |
|
|
|
if (npubParam && repoParam) { |
|
// Pre-fill repo name |
|
repoName = repoParam; |
|
|
|
// Try to fetch existing announcement to pre-fill other fields |
|
try { |
|
const decoded = nip19.decode(npubParam); |
|
if (decoded.type === 'npub') { |
|
const pubkey = decoded.data as string; |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [pubkey], |
|
'#d': [repoParam], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
const event = events[0]; |
|
|
|
// Pre-fill description |
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1]; |
|
if (descTag) description = descTag; |
|
|
|
// Pre-fill clone URLs (add current domain URL) |
|
const existingCloneUrls = event.tags |
|
.filter(t => t[0] === 'clone') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string'); |
|
|
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
|
const currentDomainUrl = `${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`; |
|
|
|
// Check if current domain URL already exists |
|
const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); |
|
|
|
if (!hasCurrentDomain) { |
|
cloneUrls = [...existingCloneUrls, currentDomainUrl]; |
|
} else { |
|
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; |
|
} |
|
|
|
// Pre-fill other fields |
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1]; |
|
if (nameTag && !repoName) repoName = nameTag; |
|
|
|
const imageTag = event.tags.find(t => t[0] === 'image')?.[1]; |
|
if (imageTag) imageUrl = imageTag; |
|
|
|
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1]; |
|
if (bannerTag) bannerUrl = bannerTag; |
|
|
|
const webTags = event.tags.filter(t => t[0] === 'web'); |
|
if (webTags.length > 0) { |
|
webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string'); |
|
} |
|
|
|
const maintainerTags = event.tags.filter(t => t[0] === 'maintainers'); |
|
if (maintainerTags.length > 0) { |
|
maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string'); |
|
} |
|
|
|
const relayTags = event.tags.filter(t => t[0] === 'relays'); |
|
if (relayTags.length > 0) { |
|
relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); |
|
} |
|
|
|
const isPrivateTag = event.tags.find(t => |
|
(t[0] === 'private' && t[1] === 'true') || |
|
(t[0] === 't' && t[1] === 'private') |
|
); |
|
if (isPrivateTag) isPrivate = true; |
|
|
|
// Set existing repo ref for updating |
|
existingRepoRef = event.id; |
|
} else { |
|
// No announcement found, just set the clone URL with current domain |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
|
cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; |
|
} |
|
} |
|
} catch (err) { |
|
console.warn('Failed to pre-fill form from query params:', err); |
|
// Still set basic clone URL |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; |
|
cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; |
|
} |
|
} |
|
}); |
|
|
|
function addCloneUrl() { |
|
cloneUrls = [...cloneUrls, '']; |
|
} |
|
|
|
function removeCloneUrl(index: number) { |
|
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateCloneUrl(index: number, value: string) { |
|
const newUrls = [...cloneUrls]; |
|
newUrls[index] = value; |
|
cloneUrls = newUrls; |
|
} |
|
|
|
function addWebUrl() { |
|
webUrls = [...webUrls, '']; |
|
} |
|
|
|
function removeWebUrl(index: number) { |
|
webUrls = webUrls.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateWebUrl(index: number, value: string) { |
|
const newUrls = [...webUrls]; |
|
newUrls[index] = value; |
|
webUrls = newUrls; |
|
} |
|
|
|
function addMaintainer() { |
|
maintainers = [...maintainers, '']; |
|
} |
|
|
|
function removeMaintainer(index: number) { |
|
maintainers = maintainers.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateMaintainer(index: number, value: string) { |
|
const newMaintainers = [...maintainers]; |
|
newMaintainers[index] = value; |
|
maintainers = newMaintainers; |
|
} |
|
|
|
function addRelay() { |
|
relays = [...relays, '']; |
|
} |
|
|
|
function removeRelay(index: number) { |
|
relays = relays.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateRelay(index: number, value: string) { |
|
const newRelays = [...relays]; |
|
newRelays[index] = value; |
|
relays = newRelays; |
|
} |
|
|
|
function addBlossom() { |
|
blossoms = [...blossoms, '']; |
|
} |
|
|
|
function removeBlossom(index: number) { |
|
blossoms = blossoms.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateBlossom(index: number, value: string) { |
|
const newBlossoms = [...blossoms]; |
|
newBlossoms[index] = value; |
|
blossoms = newBlossoms; |
|
} |
|
|
|
function addTag() { |
|
tags = [...tags, '']; |
|
} |
|
|
|
function removeTag(index: number) { |
|
tags = tags.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateTag(index: number, value: string) { |
|
const newTags = [...tags]; |
|
newTags[index] = value; |
|
tags = newTags; |
|
} |
|
|
|
function addDocumentation() { |
|
documentation = [...documentation, '']; |
|
} |
|
|
|
function removeDocumentation(index: number) { |
|
documentation = documentation.filter((_, i) => i !== index); |
|
} |
|
|
|
function updateDocumentation(index: number, value: string) { |
|
const newDocs = [...documentation]; |
|
newDocs[index] = value; |
|
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; |
|
} |
|
} |
|
|
|
// Validation functions |
|
function validateCloneUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
if (!isValidUrl(url.trim())) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
if (!url.trim().endsWith('.git') && !url.trim().includes('/')) { |
|
return 'Clone URL should end with .git or be a valid repository URL'; |
|
} |
|
return null; |
|
} |
|
|
|
function validateWebUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
if (!isValidUrl(url.trim())) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
return null; |
|
} |
|
|
|
function validateMaintainer(maintainer: string): string | null { |
|
if (!maintainer.trim()) return null; // Empty is OK |
|
// Check if it's a valid npub or hex pubkey |
|
try { |
|
if (maintainer.startsWith('npub')) { |
|
nip19.decode(maintainer); |
|
return null; |
|
} else if (maintainer.length === 64 && /^[0-9a-f]+$/i.test(maintainer)) { |
|
return null; // Valid hex pubkey |
|
} else { |
|
return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; |
|
} |
|
} catch { |
|
return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; |
|
} |
|
} |
|
|
|
function validateDocumentation(doc: string): string | null { |
|
if (!doc.trim()) return null; // Empty is OK |
|
// Check if it's in naddr format or 30618:pubkey:identifier format |
|
if (doc.startsWith('naddr')) { |
|
try { |
|
const decoded = nip19.decode(doc); |
|
if (decoded.type === 'naddr') return null; |
|
} catch { |
|
return 'Invalid naddr format'; |
|
} |
|
} else if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(doc)) { |
|
return null; // Valid kind:pubkey:identifier format |
|
} |
|
return 'Invalid documentation format. Use naddr1... or kind:pubkey:identifier'; |
|
} |
|
|
|
function validateImageUrl(url: string): string | null { |
|
if (!url.trim()) return null; // Empty is OK |
|
if (!isValidUrl(url.trim())) { |
|
return 'Invalid URL format. Must start with http:// or https://'; |
|
} |
|
// Check if it's likely an image URL |
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']; |
|
const lowerUrl = url.toLowerCase(); |
|
if (!imageExtensions.some(ext => lowerUrl.includes(ext)) && !lowerUrl.includes('image') && !lowerUrl.includes('img')) { |
|
return 'Warning: URL does not appear to be an image'; |
|
} |
|
return null; |
|
} |
|
|
|
// Lookup functions |
|
// Use only default search relays for lookups to avoid connecting to random/unreachable user relays |
|
function getSearchRelays(): string[] { |
|
return DEFAULT_NOSTR_SEARCH_RELAYS; |
|
} |
|
|
|
async function lookupRepoAnnouncement(query: string, fieldName: string) { |
|
const lookupKey = `repo-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
const relays = await getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
// Try to decode as naddr, nevent, or hex |
|
const decoded = decodeNostrAddress(query.trim()); |
|
let events: NostrEvent[] = []; |
|
|
|
if (decoded) { |
|
if (decoded.type === 'note' && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (decoded.type === 'nevent' && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
|
events = await client.fetchEvents([ |
|
{ |
|
kinds: [decoded.kind], |
|
authors: [decoded.pubkey], |
|
'#d': [decoded.identifier], |
|
limit: 10 |
|
} |
|
]); |
|
} |
|
} |
|
|
|
// Also search by name or d-tag if query doesn't look like an address |
|
if (events.length === 0 && !query.startsWith('naddr') && !query.startsWith('nevent') && !/^[0-9a-f]{64}$/i.test(query)) { |
|
const repoEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
limit: 20 |
|
} |
|
]); |
|
|
|
const searchLower = query.toLowerCase(); |
|
let filteredEvents = repoEvents.filter(event => { |
|
const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
return name.toLowerCase().includes(searchLower) || dTag.toLowerCase().includes(searchLower); |
|
}); |
|
|
|
// Filter out private repos unless user is authenticated and has access |
|
// For signup page, we'll only show public repos or repos the user owns |
|
let userPubkeyHex: string | null = null; |
|
if (nip07Available) { |
|
try { |
|
const userPubkey = await getPublicKeyWithNIP07(); |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} else { |
|
userPubkeyHex = userPubkey; |
|
} |
|
} catch { |
|
userPubkeyHex = userPubkey; |
|
} |
|
} catch { |
|
// User not authenticated, continue with filtering |
|
} |
|
} |
|
|
|
// Filter private repos |
|
events = await Promise.all( |
|
filteredEvents.map(async (event) => { |
|
const isPrivate = event.tags.some(t => |
|
(t[0] === 'private' && t[1] === 'true') || |
|
(t[0] === 't' && t[1] === 'private') |
|
); |
|
|
|
// Public repos are always visible |
|
if (!isPrivate) return event; |
|
|
|
// Private repos: only show if user is owner |
|
if (userPubkeyHex && event.pubkey === userPubkeyHex) { |
|
return event; |
|
} |
|
|
|
// For other private repos, check access via API |
|
if (userPubkeyHex) { |
|
try { |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
|
if (!dTag) return null; |
|
|
|
// Get npub from pubkey |
|
const ownerNpub = nip19.npubEncode(event.pubkey); |
|
const accessResponse = await fetch(`/api/repos/${ownerNpub}/${dTag}/access`, { |
|
headers: { 'X-User-Pubkey': userPubkeyHex } |
|
}); |
|
|
|
if (accessResponse.ok) { |
|
const accessData = await accessResponse.json(); |
|
if (accessData.canView) { |
|
return event; |
|
} |
|
} |
|
} catch { |
|
// Access check failed, don't show |
|
} |
|
} |
|
|
|
return null; |
|
}) |
|
); |
|
events = events.filter(e => e !== null) as NostrEvent[]; |
|
} |
|
|
|
if (events.length === 0) { |
|
lookupError[lookupKey] = 'No repository announcements found'; |
|
} else { |
|
lookupResults[lookupKey] = events; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
async function lookupNpub(query: string, fieldName: string, index?: number) { |
|
const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
// Try to decode as npub |
|
let pubkey: string | null = null; |
|
try { |
|
if (query.startsWith('npub')) { |
|
const decoded = nip19.decode(query); |
|
if (decoded.type === 'npub') { |
|
pubkey = decoded.data as string; |
|
} |
|
} else if (query.length === 64 && /^[0-9a-f]+$/i.test(query)) { |
|
pubkey = query; |
|
} |
|
} catch { |
|
// Invalid format |
|
} |
|
|
|
if (!pubkey) { |
|
// Search for npubs by name (requires kind 0 metadata) |
|
const relays = getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
// Search for profiles |
|
const profileEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [0], // Metadata events |
|
limit: 20 |
|
} |
|
]); |
|
|
|
const searchLower = query.toLowerCase(); |
|
const matches = profileEvents.filter(event => { |
|
try { |
|
const content = JSON.parse(event.content); |
|
const name = content.name || content.display_name || ''; |
|
return name.toLowerCase().includes(searchLower); |
|
} catch { |
|
return false; |
|
} |
|
}); |
|
|
|
if (matches.length > 0) { |
|
lookupResults[lookupKey] = matches.map(e => { |
|
try { |
|
const content = JSON.parse(e.content); |
|
return { |
|
pubkey: e.pubkey, |
|
npub: nip19.npubEncode(e.pubkey), |
|
name: content.name || content.display_name || 'Unknown', |
|
about: content.about || '', |
|
picture: content.picture || '' |
|
}; |
|
} catch { |
|
return { |
|
pubkey: e.pubkey, |
|
npub: nip19.npubEncode(e.pubkey), |
|
name: 'Unknown' |
|
}; |
|
} |
|
}); |
|
} else { |
|
lookupError[lookupKey] = 'No profiles found matching the query'; |
|
} |
|
} else { |
|
// Valid pubkey, try to fetch profile |
|
const relays = getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
const profileEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [0], |
|
authors: [pubkey], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
let profileData: { |
|
pubkey: string; |
|
npub: string; |
|
name?: string; |
|
about?: string; |
|
picture?: string; |
|
} = { |
|
pubkey, |
|
npub: query.startsWith('npub') ? query : nip19.npubEncode(pubkey) |
|
}; |
|
|
|
if (profileEvents.length > 0) { |
|
try { |
|
const content = JSON.parse(profileEvents[0].content); |
|
profileData.name = content.name || content.display_name || ''; |
|
profileData.about = content.about || ''; |
|
profileData.picture = content.picture || ''; |
|
} catch { |
|
// Invalid JSON, use defaults |
|
} |
|
} |
|
|
|
lookupResults[lookupKey] = [profileData]; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
async function lookupNevent(query: string, fieldName: string) { |
|
const lookupKey = `nevent-${fieldName}`; |
|
lookupLoading[lookupKey] = true; |
|
lookupError[lookupKey] = null; |
|
lookupResults[lookupKey] = null; |
|
|
|
try { |
|
const relays = await getSearchRelays(); |
|
const client = new NostrClient(relays); |
|
|
|
let events: NostrEvent[] = []; |
|
const decoded = decodeNostrAddress(query.trim()); |
|
|
|
if (decoded && decoded.id) { |
|
events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); |
|
} else if (/^[0-9a-f]{64}$/i.test(query.trim())) { |
|
// Hex event ID |
|
events = await client.fetchEvents([{ ids: [query.trim()], limit: 10 }]); |
|
} |
|
|
|
if (events.length === 0) { |
|
lookupError[lookupKey] = 'No events found'; |
|
} else { |
|
lookupResults[lookupKey] = events; |
|
} |
|
} catch (err) { |
|
lookupError[lookupKey] = `Lookup failed: ${String(err)}`; |
|
} finally { |
|
lookupLoading[lookupKey] = false; |
|
} |
|
} |
|
|
|
function selectRepoResult(result: NostrEvent, fieldName: string) { |
|
if (fieldName === 'existingRepoRef') { |
|
existingRepoRef = result.id; |
|
loadExistingRepo(); |
|
} else if (fieldName === 'forkOriginalRepo') { |
|
// Convert to naddr format if possible |
|
const dTag = result.tags.find(t => t[0] === 'd')?.[1]; |
|
if (dTag) { |
|
try { |
|
const naddr = nip19.naddrEncode({ |
|
pubkey: result.pubkey, |
|
kind: result.kind, |
|
identifier: dTag, |
|
relays: [] |
|
}); |
|
forkOriginalRepo = naddr; |
|
} catch { |
|
forkOriginalRepo = `${result.kind}:${result.pubkey}:${dTag}`; |
|
} |
|
} |
|
} |
|
lookupResults[`repo-${fieldName}`] = null; |
|
} |
|
|
|
function selectNpubResult(result: { pubkey: string; npub: string; name?: string; about?: string; picture?: string }, fieldName: string, index?: number) { |
|
if (fieldName === 'maintainers' && index !== undefined) { |
|
updateMaintainer(index, result.npub); |
|
} |
|
const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; |
|
lookupResults[lookupKey] = null; |
|
} |
|
|
|
function clearLookupResults(key: string) { |
|
lookupResults[key] = null; |
|
lookupError[key] = null; |
|
} |
|
|
|
async function loadExistingRepo() { |
|
if (!existingRepoRef.trim()) return; |
|
|
|
loadingExisting = true; |
|
error = null; |
|
|
|
try { |
|
const decoded = decodeNostrAddress(existingRepoRef.trim()); |
|
if (!decoded) { |
|
error = 'Invalid format. Please provide a hex event ID, nevent, or naddr.'; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
let event: NostrEvent | null = null; |
|
|
|
if (decoded.type === 'note' && decoded.id) { |
|
// Fetch by event ID |
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
|
event = events[0] || null; |
|
} else if (decoded.type === 'nevent' && decoded.id) { |
|
// Fetch by event ID |
|
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]); |
|
event = events[0] || null; |
|
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { |
|
// Fetch parameterized replaceable event |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [decoded.kind], |
|
authors: [decoded.pubkey], |
|
'#d': [decoded.identifier], |
|
limit: 1 |
|
} |
|
]); |
|
event = events[0] || null; |
|
} |
|
|
|
if (!event) { |
|
error = 'Repository announcement not found. Make sure it exists on the relays.'; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
if (event.kind !== KIND.REPO_ANNOUNCEMENT) { |
|
error = `The provided event is not a repository announcement (kind ${KIND.REPO_ANNOUNCEMENT}).`; |
|
loadingExisting = false; |
|
return; |
|
} |
|
|
|
// Populate form with existing data |
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
const nameTag = event.tags.find(t => t[0] === 'name')?.[1] || ''; |
|
const descTag = event.tags.find(t => t[0] === 'description')?.[1] || ''; |
|
const imageTag = event.tags.find(t => t[0] === 'image')?.[1] || ''; |
|
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1] || ''; |
|
const privateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private')); |
|
|
|
repoName = nameTag || dTag; |
|
description = descTag; |
|
imageUrl = imageTag; |
|
bannerUrl = bannerTag; |
|
isPrivate = !!privateTag; |
|
|
|
// Extract clone URLs - handle both formats: separate tags and multiple values in one tag |
|
const urls: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'clone') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string' && url.trim()) { |
|
urls.push(url.trim()); |
|
} |
|
} |
|
} |
|
} |
|
cloneUrls = urls.length > 0 ? urls : ['']; |
|
|
|
// Extract web URLs - handle both formats |
|
const webUrlsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'web') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const url = tag[i]; |
|
if (url && typeof url === 'string' && url.trim()) { |
|
webUrlsList.push(url.trim()); |
|
} |
|
} |
|
} |
|
} |
|
webUrls = webUrlsList.length > 0 ? webUrlsList : ['']; |
|
|
|
// Extract maintainers - handle both formats |
|
const maintainersList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'maintainers') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const maintainer = tag[i]; |
|
if (maintainer && typeof maintainer === 'string' && maintainer.trim()) { |
|
maintainersList.push(maintainer.trim()); |
|
} |
|
} |
|
} |
|
} |
|
maintainers = maintainersList.length > 0 ? maintainersList : ['']; |
|
|
|
// Extract relays |
|
const relaysList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'relays') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const relay = tag[i]; |
|
if (relay && typeof relay === 'string' && relay.trim()) { |
|
relaysList.push(relay.trim()); |
|
} |
|
} |
|
} |
|
} |
|
relays = relaysList.length > 0 ? relaysList : ['']; |
|
|
|
// Extract blossoms |
|
const blossomsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'blossoms') { |
|
for (let i = 1; i < tag.length; i++) { |
|
const blossom = tag[i]; |
|
if (blossom && typeof blossom === 'string' && blossom.trim()) { |
|
blossomsList.push(blossom.trim()); |
|
} |
|
} |
|
} |
|
} |
|
blossoms = blossomsList.length > 0 ? blossomsList : ['']; |
|
|
|
// Extract tags/labels |
|
const tagsList: string[] = []; |
|
for (const tag of event.tags) { |
|
if (tag[0] === 't' && tag[1] && tag[1] !== 'private' && tag[1] !== 'fork') { |
|
tagsList.push(tag[1]); |
|
} |
|
} |
|
tags = tagsList.length > 0 ? tagsList : ['']; |
|
|
|
// Extract documentation - handle relay hints correctly |
|
// Only treat values as multiple entries if they are in the same format |
|
// If a value looks like a relay URL (wss:// or ws://), it's a relay hint for the previous value |
|
const docsList: string[] = []; |
|
const isRelayUrl = (value: string): boolean => { |
|
return typeof value === 'string' && (value.startsWith('wss://') || value.startsWith('ws://')); |
|
}; |
|
|
|
const getDocFormat = (value: string): string | null => { |
|
// Check if it's naddr format (starts with naddr1) |
|
if (value.startsWith('naddr1')) return 'naddr'; |
|
// Check if it's kind:pubkey:identifier format |
|
if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(value)) return 'kind:pubkey:identifier'; |
|
return null; |
|
}; |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'documentation') { |
|
let i = 1; |
|
|
|
while (i < tag.length) { |
|
const value = tag[i]; |
|
if (!value || typeof value !== 'string' || !value.trim()) { |
|
i++; |
|
continue; |
|
} |
|
|
|
const trimmed = value.trim(); |
|
|
|
// Skip relay URLs (they're hints, not entries) |
|
if (isRelayUrl(trimmed)) { |
|
i++; |
|
continue; |
|
} |
|
|
|
// Check if this is a documentation reference |
|
const format = getDocFormat(trimmed); |
|
if (!format) { |
|
i++; |
|
continue; // Skip invalid formats |
|
} |
|
|
|
// Check if next value is a relay URL (hint for this entry) |
|
const nextValue = i + 1 < tag.length ? tag[i + 1] : null; |
|
if (nextValue && typeof nextValue === 'string' && isRelayUrl(nextValue.trim())) { |
|
// Current value has a relay hint - store just the doc reference, skip the relay |
|
docsList.push(trimmed); |
|
i += 2; // Skip both the doc and the relay hint |
|
continue; |
|
} |
|
|
|
// Check if we have multiple entries in the same format |
|
// Collect all consecutive entries of the same format |
|
const sameFormatEntries: string[] = [trimmed]; |
|
let j = i + 1; |
|
while (j < tag.length) { |
|
const nextVal = tag[j]; |
|
if (!nextVal || typeof nextVal !== 'string' || !nextVal.trim()) { |
|
j++; |
|
continue; |
|
} |
|
|
|
const nextTrimmed = nextVal.trim(); |
|
|
|
// Stop if we hit a relay URL (it's a hint for the previous entry) |
|
if (isRelayUrl(nextTrimmed)) { |
|
break; |
|
} |
|
|
|
// Check if it's the same format |
|
const nextFormat = getDocFormat(nextTrimmed); |
|
if (nextFormat === format) { |
|
sameFormatEntries.push(nextTrimmed); |
|
j++; |
|
} else { |
|
// Different format - stop collecting |
|
break; |
|
} |
|
} |
|
|
|
// If we have multiple entries in the same format, add them all |
|
// Otherwise, just add the single entry |
|
docsList.push(...sameFormatEntries); |
|
i = j; // Move to the next unprocessed value |
|
} |
|
} |
|
} |
|
documentation = docsList.length > 0 ? docsList : ['']; |
|
|
|
// Extract alt tag |
|
const altTag = event.tags.find(t => t[0] === 'alt'); |
|
alt = altTag?.[1] || ''; |
|
|
|
// Extract fork information |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); |
|
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'); |
|
earliestCommit = rTag?.[1] || ''; |
|
|
|
// Check if client tag exists |
|
addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); |
|
|
|
} catch (e) { |
|
error = `Failed to load repository: ${String(e)}`; |
|
} finally { |
|
loadingExisting = false; |
|
} |
|
} |
|
|
|
async function submit() { |
|
if (!nip07Available) { |
|
error = 'NIP-07 extension is required. Please install a Nostr browser extension.'; |
|
return; |
|
} |
|
|
|
if (!repoName.trim()) { |
|
error = 'Repository name is required.'; |
|
return; |
|
} |
|
|
|
// Validate all fields |
|
const validationErrors: string[] = []; |
|
|
|
// Validate clone URLs |
|
for (let i = 0; i < cloneUrls.length; i++) { |
|
const urlError = validateCloneUrl(cloneUrls[i]); |
|
if (urlError) { |
|
validationErrors.push(`Clone URL ${i + 1}: ${urlError}`); |
|
} |
|
} |
|
|
|
// Validate web URLs |
|
for (let i = 0; i < webUrls.length; i++) { |
|
const urlError = validateWebUrl(webUrls[i]); |
|
if (urlError) { |
|
validationErrors.push(`Web URL ${i + 1}: ${urlError}`); |
|
} |
|
} |
|
|
|
// Validate maintainers |
|
for (let i = 0; i < maintainers.length; i++) { |
|
const maintainerError = validateMaintainer(maintainers[i]); |
|
if (maintainerError) { |
|
validationErrors.push(`Maintainer ${i + 1}: ${maintainerError}`); |
|
} |
|
} |
|
|
|
// Validate documentation |
|
for (let i = 0; i < documentation.length; i++) { |
|
const docError = validateDocumentation(documentation[i]); |
|
if (docError) { |
|
validationErrors.push(`Documentation ${i + 1}: ${docError}`); |
|
} |
|
} |
|
|
|
// Validate image URLs |
|
if (imageUrl.trim()) { |
|
const imageError = validateImageUrl(imageUrl); |
|
if (imageError) { |
|
validationErrors.push(`Image URL: ${imageError}`); |
|
} |
|
} |
|
|
|
if (bannerUrl.trim()) { |
|
const bannerError = validateImageUrl(bannerUrl); |
|
if (bannerError) { |
|
validationErrors.push(`Banner URL: ${bannerError}`); |
|
} |
|
} |
|
|
|
if (validationErrors.length > 0) { |
|
error = 'Validation errors:\n' + validationErrors.join('\n'); |
|
return; |
|
} |
|
|
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
const npub = nip19.npubEncode(pubkey); |
|
|
|
// Normalize repo name to d-tag format |
|
const dTag = repoName |
|
.toLowerCase() |
|
.trim() |
|
.replace(/[^\w\s-]/g, '') |
|
.replace(/\s+/g, '-') |
|
.replace(/-+/g, '-') |
|
.replace(/^-+|-+$/g, ''); |
|
|
|
// Get git domain from layout data |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); |
|
const protocol = isLocalhost ? 'http' : 'https'; |
|
const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`; |
|
|
|
// Try to get Tor .onion address and add it to clone URLs |
|
let torOnionUrl: string | null = null; |
|
try { |
|
const torResponse = await fetch('/api/tor/onion'); |
|
if (torResponse.ok) { |
|
const torData = await torResponse.json(); |
|
if (torData.available && torData.onion) { |
|
torOnionUrl = `http://${torData.onion}/${npub}/${dTag}.git`; |
|
} |
|
} |
|
} catch { |
|
// Tor not available, continue without it |
|
} |
|
|
|
// Filter user-provided clone URLs (exclude localhost and .onion duplicates) |
|
const userCloneUrls = cloneUrls.filter(url => { |
|
const trimmed = url.trim(); |
|
if (!trimmed) return false; |
|
// Exclude if it's our domain or already a .onion |
|
if (trimmed.includes(gitDomain)) return false; |
|
if (trimmed.includes('.onion')) return false; |
|
return true; |
|
}); |
|
|
|
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL |
|
if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) { |
|
error = 'Cannot publish with only localhost. You need either:\n' + |
|
'• A Tor .onion address (configure Tor hidden service and set TOR_ONION_ADDRESS)\n' + |
|
'• At least one other clone URL (e.g., GitHub, GitLab, or another GitRepublic instance)'; |
|
loading = false; |
|
return; |
|
} |
|
|
|
// Build clone URLs - NEVER include localhost, only include public domain or Tor .onion |
|
const allCloneUrls: string[] = []; |
|
|
|
// Add our domain URL only if it's NOT localhost (explicitly check the URL) |
|
if (!isLocalhost && !gitUrl.includes('localhost') && !gitUrl.includes('127.0.0.1')) { |
|
allCloneUrls.push(gitUrl); |
|
} |
|
|
|
// Add Tor .onion URL if available (always useful, even with localhost) |
|
if (torOnionUrl) { |
|
allCloneUrls.push(torOnionUrl); |
|
} |
|
|
|
// Add user-provided clone URLs |
|
allCloneUrls.push(...userCloneUrls); |
|
|
|
// Build web URLs |
|
const allWebUrls = webUrls.filter(url => url.trim()); |
|
|
|
// Build maintainers list |
|
const allMaintainers = maintainers.filter(m => m.trim()); |
|
|
|
// Build relays list - combine user relays with default relays |
|
const allRelays = [ |
|
...relays.filter(r => r.trim()), |
|
...DEFAULT_NOSTR_RELAYS.filter(r => !relays.includes(r)) |
|
]; |
|
|
|
// Build blossoms list |
|
const allBlossoms = blossoms.filter(b => b.trim()); |
|
|
|
// Build documentation list |
|
const allDocumentation = documentation.filter(d => d.trim()); |
|
|
|
// Build tags/labels (excluding 'private' and 'fork' which are handled separately) |
|
const allTags = tags.filter(t => t.trim() && t !== 'private' && t !== 'fork'); |
|
|
|
// Build event tags - use single tag with multiple values (NIP-34 format) |
|
const eventTags: string[][] = [ |
|
['d', dTag], |
|
['name', repoName], |
|
...(description ? [['description', description]] : []), |
|
...(allCloneUrls.length > 0 ? [['clone', ...allCloneUrls]] : []), // Single tag with all clone URLs |
|
...(allWebUrls.length > 0 ? [['web', ...allWebUrls]] : []), // Single tag with all web URLs |
|
...(allMaintainers.length > 0 ? [['maintainers', ...allMaintainers]] : []), // Single tag with all maintainers |
|
...(allRelays.length > 0 ? [['relays', ...allRelays]] : []), // Single tag with all relays |
|
...(allBlossoms.length > 0 ? [['blossoms', ...allBlossoms]] : []), // Single tag with all blossoms |
|
...allDocumentation.map(d => ['documentation', d]), // Documentation can have relay hints, so keep separate |
|
...allTags.map(t => ['t', t]), |
|
...(imageUrl.trim() ? [['image', imageUrl.trim()]] : []), |
|
...(bannerUrl.trim() ? [['banner', bannerUrl.trim()]] : []), |
|
...(alt.trim() ? [['alt', alt.trim()]] : []), |
|
...(earliestCommit.trim() ? [['r', earliestCommit.trim(), 'euc']] : []) |
|
]; |
|
|
|
// Add fork tags if this is a fork |
|
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; |
|
} |
|
|
|
// 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']); |
|
} |
|
} |
|
|
|
// Add private tag if enabled |
|
if (isPrivate) { |
|
eventTags.push(['private', 'true']); |
|
} |
|
|
|
// Add client tag if enabled |
|
if (addClientTag) { |
|
eventTags.push(['client', 'gitrepublic-web']); |
|
} |
|
|
|
// Build event |
|
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.REPO_ANNOUNCEMENT, |
|
pubkey, |
|
created_at: Math.floor(Date.now() / 1000), |
|
content: '', // Empty per NIP-34 |
|
tags: eventTags |
|
}; |
|
|
|
// Sign with NIP-07 |
|
const signedEvent = await signEventWithNIP07(eventTemplate); |
|
|
|
// Get user's inbox/outbox relays (from kind 10002) using full relay set to find newest |
|
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); |
|
// Use comprehensive relay set to ensure we get the newest kind 10002 event |
|
const fullRelaySet = combineRelays([], [...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS]); |
|
const fullRelayClient = new NostrClient(fullRelaySet); |
|
const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient); |
|
|
|
// Combine user's outbox with default relays |
|
const userRelays = combineRelays(outbox); |
|
|
|
// Publish repository announcement |
|
const result = await nostrClient.publishEvent(signedEvent, userRelays); |
|
|
|
if (result.success.length > 0) { |
|
// Create and publish initial ownership proof (self-transfer event) |
|
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js'); |
|
const ownershipService = new OwnershipTransferService(userRelays); |
|
|
|
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(pubkey, dTag); |
|
const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); |
|
|
|
// Publish initial ownership event (don't fail if this fails, announcement is already published) |
|
await nostrClient.publishEvent(signedOwnershipEvent, userRelays).catch(err => { |
|
console.warn('Failed to publish initial ownership event:', err); |
|
}); |
|
|
|
success = true; |
|
setTimeout(() => { |
|
goto('/'); |
|
}, 2000); |
|
} else { |
|
error = 'Failed to publish to any relays.'; |
|
} |
|
|
|
} catch (e) { |
|
error = `Failed to create repository announcement: ${String(e)}`; |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
</script> |
|
|
|
<div class="container"> |
|
<header> |
|
<h1>Create or Update Repository Announcement</h1> |
|
</header> |
|
|
|
<main> |
|
|
|
{#if !nip07Available} |
|
<div class="warning"> |
|
<p>NIP-07 browser extension is required to sign repository announcements.</p> |
|
<p>Please install a Nostr browser extension (like Alby, nos2x, or similar).</p> |
|
</div> |
|
{/if} |
|
|
|
{#if error} |
|
<div class="error">{error}</div> |
|
{/if} |
|
|
|
{#if success} |
|
<div class="success"> |
|
Repository announcement published successfully! Redirecting... |
|
</div> |
|
{/if} |
|
|
|
<form onsubmit={(e) => { e.preventDefault(); submit(); }}> |
|
<div class="form-group"> |
|
<label for="existing-repo-ref"> |
|
Load Existing Repository (optional) |
|
<small>Enter hex event ID, nevent, or naddr to update an existing announcement</small> |
|
</label> |
|
<div class="input-group"> |
|
<input |
|
id="existing-repo-ref" |
|
type="text" |
|
bind:value={existingRepoRef} |
|
placeholder="hex event ID, nevent1..., or naddr1..." |
|
disabled={loading || loadingExisting} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupRepoAnnouncement(existingRepoRef || '', 'existingRepoRef')} |
|
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
|
class="lookup-button" |
|
title="Search for repository announcements (supports hex ID, nevent, naddr, or search by name)" |
|
> |
|
{#if lookupLoading['repo-existingRepoRef']} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<svg class="icon-small" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
|
<circle cx="11" cy="11" r="8"/> |
|
<path d="m21 21-4.35-4.35"/> |
|
</svg> |
|
{/if} |
|
</button> |
|
<button |
|
type="button" |
|
onclick={loadExistingRepo} |
|
disabled={loading || loadingExisting || !existingRepoRef.trim()} |
|
> |
|
{loadingExisting ? 'Loading...' : 'Load'} |
|
</button> |
|
</div> |
|
{#if lookupError['repo-existingRepoRef']} |
|
<div class="lookup-error">{lookupError['repo-existingRepoRef']}</div> |
|
{/if} |
|
{#if lookupResults['repo-existingRepoRef']} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {lookupResults['repo-existingRepoRef'].length} repository announcement(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults('repo-existingRepoRef')} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults['repo-existingRepoRef'] || []) as result} |
|
{#if 'tags' in result} |
|
{@const event = result as NostrEvent} |
|
{@const nameTag = event.tags.find((t: string[]) => t[0] === 'name')?.[1]} |
|
{@const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]} |
|
{@const descTag = event.tags.find((t: string[]) => t[0] === 'description')?.[1]} |
|
{@const imageTag = event.tags.find((t: string[]) => t[0] === 'image')?.[1]} |
|
{@const ownerNpub = nip19.npubEncode(event.pubkey)} |
|
{@const tags = event.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} |
|
<div |
|
class="lookup-result-item repo-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectRepoResult(event, 'existingRepoRef')} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectRepoResult(event, 'existingRepoRef'); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if imageTag} |
|
<img src={imageTag} alt="" class="result-image" /> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{nameTag || dTag || 'Unnamed'}</strong> |
|
{#if dTag} |
|
<small class="d-tag">d-tag: {dTag}</small> |
|
{/if} |
|
{#if descTag} |
|
<p class="result-description">{descTag}</p> |
|
{/if} |
|
<div class="result-meta"> |
|
<small>Owner: {ownerNpub.slice(0, 16)}...</small> |
|
<small>Event: {event.id.slice(0, 16)}...</small> |
|
</div> |
|
{#if tags.length > 0} |
|
<div class="result-tags"> |
|
{#each tags as tag} |
|
<span class="tag-badge">#{tag}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="repo-name"> |
|
Repository Name * |
|
<small>Enter a normal name (e.g., "My Awesome Repo"). It will be automatically converted to a d-tag format (lowercase with hyphens, such as my-awesome-repo).</small> |
|
</label> |
|
<input |
|
id="repo-name" |
|
type="text" |
|
bind:value={repoName} |
|
placeholder="My Awesome Repo" |
|
required |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="description"> |
|
Description |
|
</label> |
|
<textarea |
|
id="description" |
|
bind:value={description} |
|
placeholder="Repository description" |
|
rows={3} |
|
disabled={loading} |
|
></textarea> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Clone URLs |
|
<small> |
|
{#if ($page.data.gitDomain || 'localhost:6543').startsWith('localhost') || ($page.data.gitDomain || 'localhost:6543').startsWith('127.0.0.1')} |
|
<strong>Note:</strong> Your server is using localhost, which won't work for others. You must add at least one public clone URL (e.g., GitHub, GitLab) or configure a Tor .onion address. |
|
{:else} |
|
{$page.data.gitDomain || 'localhost:6543'} will be added automatically, but you can add any existing ones here. |
|
{/if} |
|
</small> |
|
</div> |
|
{#each cloneUrls as url, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={url} |
|
oninput={(e) => updateCloneUrl(index, e.currentTarget.value)} |
|
placeholder="https://github.com/user/repo.git" |
|
disabled={loading} |
|
/> |
|
{#if cloneUrls.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeCloneUrl(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addCloneUrl} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Clone URL |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Web URLs (optional) |
|
<small>Webpage URLs for browsing the repository (e.g., GitHub/GitLab web interface). Hover over a URL to preview it.</small> |
|
</div> |
|
{#each webUrls as url, index} |
|
<div class="input-group url-preview-container"> |
|
<input |
|
type="text" |
|
value={url} |
|
oninput={(e) => updateWebUrl(index, e.currentTarget.value)} |
|
onmouseenter={() => handleWebUrlHover(index, url)} |
|
onmouseleave={handleWebUrlLeave} |
|
placeholder="https://github.com/user/repo" |
|
disabled={loading} |
|
/> |
|
{#if webUrls.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeWebUrl(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
{#if previewingUrlIndex === index && previewUrl} |
|
<div class="url-preview" role="tooltip"> |
|
{#if previewLoading} |
|
<div class="preview-loading">Loading preview...</div> |
|
{:else if previewError} |
|
<div class="preview-error"> |
|
<strong> |
|
<img src="/icons/alert-triangle.svg" alt="Warning" class="icon-inline" /> |
|
Warning: |
|
</strong> {previewError} |
|
</div> |
|
{/if} |
|
<iframe |
|
src={previewUrl} |
|
title="URL Preview" |
|
class="preview-iframe" |
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups" |
|
></iframe> |
|
<div class="preview-url-display">{previewUrl}</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addWebUrl} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Web URL |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Maintainers (optional) |
|
<small>Other maintainer pubkeys (npub or hex format). Example: npub1abc... or hex pubkey</small> |
|
</div> |
|
{#each maintainers as maintainer, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={maintainer} |
|
oninput={(e) => updateMaintainer(index, e.currentTarget.value)} |
|
placeholder="npub1abc... or hex pubkey" |
|
disabled={loading} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupNpub(maintainer || '', 'maintainers', index)} |
|
disabled={loading || !maintainer.trim()} |
|
class="lookup-button" |
|
title="Lookup npub or search by name" |
|
> |
|
{#if lookupLoading[`npub-maintainers-${index}`]} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<svg class="icon-small" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
|
<circle cx="11" cy="11" r="8"/> |
|
<path d="m21 21-4.35-4.35"/> |
|
</svg> |
|
{/if} |
|
</button> |
|
{#if maintainers.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeMaintainer(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{#if lookupError[`npub-maintainers-${index}`]} |
|
<div class="lookup-error">{lookupError[`npub-maintainers-${index}`]}</div> |
|
{/if} |
|
{#if lookupResults[`npub-maintainers-${index}`]} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {(lookupResults[`npub-maintainers-${index}`] || []).length} profile(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults(`npub-maintainers-${index}`)} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults[`npub-maintainers-${index}`] || []) as result} |
|
{#if 'npub' in result} |
|
{@const profile = result as ProfileData} |
|
<div |
|
class="lookup-result-item profile-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectNpubResult(profile, 'maintainers', index)} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectNpubResult(profile, 'maintainers', index); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if profile.picture} |
|
<img src={profile.picture} alt="" class="result-avatar" /> |
|
{:else} |
|
<div class="result-avatar-placeholder"> |
|
{(profile.name || profile.npub).slice(0, 2).toUpperCase()} |
|
</div> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{profile.name || 'Unknown'}</strong> |
|
{#if profile.about} |
|
<p class="result-description">{profile.about}</p> |
|
{/if} |
|
<small class="npub-display">{profile.npub}</small> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addMaintainer} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Maintainer |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Relays (optional) |
|
<small>Nostr relays that this repository will monitor for patches and issues. Default relays will be added automatically.</small> |
|
</div> |
|
{#each relays as relay, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={relay} |
|
oninput={(e) => updateRelay(index, e.currentTarget.value)} |
|
placeholder="wss://relay.example.com" |
|
disabled={loading} |
|
/> |
|
{#if relays.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeRelay(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addRelay} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Relay |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Blossoms (optional) |
|
<small>Blossom URLs for this repository. These are preserved but not actively used by GitRepublic.</small> |
|
</div> |
|
{#each blossoms as blossom, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={blossom} |
|
oninput={(e) => updateBlossom(index, e.currentTarget.value)} |
|
placeholder="https://example.com" |
|
disabled={loading} |
|
/> |
|
{#if blossoms.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeBlossom(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addBlossom} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Blossom |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="image-url"> |
|
Repository Image URL (optional) |
|
<small>URL to a repository image/logo. Example: https://example.com/repo-logo.png</small> |
|
</label> |
|
<input |
|
id="image-url" |
|
type="url" |
|
bind:value={imageUrl} |
|
placeholder="https://example.com/repo-logo.png" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="banner-url"> |
|
Repository Banner URL (optional) |
|
<small>URL to a repository banner image. Example: https://example.com/repo-banner.png</small> |
|
</label> |
|
<input |
|
id="banner-url" |
|
type="url" |
|
bind:value={bannerUrl} |
|
placeholder="https://example.com/repo-banner.png" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="earliest-commit"> |
|
Earliest Unique Commit ID (optional) |
|
<small>Root commit ID or first commit after a permanent fork. Used to identify forks. Example: abc123def456...</small> |
|
</label> |
|
<input |
|
id="earliest-commit" |
|
type="text" |
|
bind:value={earliestCommit} |
|
placeholder="abc123def456..." |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Tags/Labels (optional) |
|
<small>Hashtags or labels for the repository. Examples: "javascript", "web-app", "personal-fork" (indicates author isn't a maintainer)</small> |
|
</div> |
|
{#each tags as tag, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={tag} |
|
oninput={(e) => updateTag(index, e.currentTarget.value)} |
|
placeholder="javascript" |
|
disabled={loading} |
|
/> |
|
{#if tags.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeTag(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addTag} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Tag |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label for="alt"> |
|
Alt Text (optional) |
|
<small>Alternative text/description for the repository. Example: "git repository: Alexandria"</small> |
|
</label> |
|
<input |
|
id="alt" |
|
type="text" |
|
bind:value={alt} |
|
placeholder="git repository: My Awesome Repo" |
|
disabled={loading} |
|
/> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<div class="label"> |
|
Documentation (optional) |
|
<small>Documentation event addresses (naddr format). Example: 30818:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:nkbip-01</small> |
|
</div> |
|
{#each documentation as doc, index} |
|
<div class="input-group"> |
|
<input |
|
type="text" |
|
value={doc} |
|
oninput={(e) => updateDocumentation(index, e.currentTarget.value)} |
|
placeholder="30818:pubkey:d-tag" |
|
disabled={loading} |
|
/> |
|
{#if documentation.length > 1} |
|
<button |
|
type="button" |
|
onclick={() => removeDocumentation(index)} |
|
disabled={loading} |
|
> |
|
Remove |
|
</button> |
|
{/if} |
|
</div> |
|
{/each} |
|
<button |
|
type="button" |
|
onclick={addDocumentation} |
|
disabled={loading} |
|
class="add-button" |
|
> |
|
+ Add Documentation |
|
</button> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="checkbox-label"> |
|
<input |
|
type="checkbox" |
|
bind:checked={isFork} |
|
disabled={loading} |
|
/> |
|
<div> |
|
<span>This is a Fork</span> |
|
<small>Check if this repository is a fork of another repository</small> |
|
</div> |
|
</label> |
|
</div> |
|
|
|
{#if isFork} |
|
<div class="form-group"> |
|
<label for="fork-original-repo"> |
|
Original Repository * |
|
<small>Identify the repository this is forked from. You can enter:<br/> |
|
• naddr format: naddr1...<br/> |
|
• npub/repo format: npub1abc.../repo-name<br/> |
|
• Repository address: 30617:owner-pubkey:repo-name</small> |
|
</label> |
|
<div class="input-group"> |
|
<input |
|
id="fork-original-repo" |
|
type="text" |
|
bind:value={forkOriginalRepo} |
|
placeholder="npub1abc.../original-repo or naddr1..." |
|
required={isFork} |
|
disabled={loading} |
|
/> |
|
<button |
|
type="button" |
|
onclick={() => lookupRepoAnnouncement(forkOriginalRepo || '', 'forkOriginalRepo')} |
|
disabled={loading || !forkOriginalRepo.trim()} |
|
class="lookup-button" |
|
title="Search for repository announcements" |
|
> |
|
{#if lookupLoading['repo-forkOriginalRepo']} |
|
<span class="loading-text">Loading...</span> |
|
{:else} |
|
<svg class="icon-small" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
|
<circle cx="11" cy="11" r="8"/> |
|
<path d="m21 21-4.35-4.35"/> |
|
</svg> |
|
{/if} |
|
</button> |
|
</div> |
|
{#if lookupError['repo-forkOriginalRepo']} |
|
<div class="lookup-error">{lookupError['repo-forkOriginalRepo']}</div> |
|
{/if} |
|
{#if lookupResults['repo-forkOriginalRepo']} |
|
<div class="lookup-results"> |
|
<div class="lookup-results-header"> |
|
<span>Found {lookupResults['repo-forkOriginalRepo'].length} repository announcement(s):</span> |
|
<button |
|
type="button" |
|
onclick={() => clearLookupResults('repo-forkOriginalRepo')} |
|
class="clear-lookup-button" |
|
title="Clear results" |
|
> |
|
<img src="/icons/x.svg" alt="Clear" class="icon-small" /> |
|
</button> |
|
</div> |
|
{#each (lookupResults['repo-forkOriginalRepo'] || []) as result} |
|
{#if 'tags' in result} |
|
{@const event = result as NostrEvent} |
|
{@const nameTag = event.tags.find((t: string[]) => t[0] === 'name')?.[1]} |
|
{@const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]} |
|
{@const descTag = event.tags.find((t: string[]) => t[0] === 'description')?.[1]} |
|
{@const imageTag = event.tags.find((t: string[]) => t[0] === 'image')?.[1]} |
|
{@const ownerNpub = nip19.npubEncode(event.pubkey)} |
|
{@const tags = event.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} |
|
<div |
|
class="lookup-result-item repo-result" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectRepoResult(event, 'forkOriginalRepo')} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectRepoResult(event, 'forkOriginalRepo'); |
|
} |
|
}} |
|
> |
|
<div class="result-header"> |
|
{#if imageTag} |
|
<img src={imageTag} alt="" class="result-image" /> |
|
{/if} |
|
<div class="result-info"> |
|
<strong>{nameTag || dTag || 'Unnamed'}</strong> |
|
{#if dTag} |
|
<small class="d-tag">d-tag: {dTag}</small> |
|
{/if} |
|
{#if descTag} |
|
<p class="result-description">{descTag}</p> |
|
{/if} |
|
<div class="result-meta"> |
|
<small>Owner: {ownerNpub.slice(0, 16)}...</small> |
|
<small>Event: {event.id.slice(0, 16)}...</small> |
|
</div> |
|
{#if tags.length > 0} |
|
<div class="result-tags"> |
|
{#each tags as tag} |
|
<span class="tag-badge">#{tag}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<div class="form-group"> |
|
<label class="checkbox-label"> |
|
<input |
|
type="checkbox" |
|
bind:checked={isPrivate} |
|
disabled={loading} |
|
/> |
|
<div> |
|
<span>Private Repository</span> |
|
<small>Private repositories are hidden from public listings and can only be accessed by the owner and maintainers. Git clone/fetch operations require authentication.</small> |
|
</div> |
|
</label> |
|
</div> |
|
|
|
<div class="form-group"> |
|
<label class="checkbox-label"> |
|
<input |
|
type="checkbox" |
|
bind:checked={addClientTag} |
|
disabled={loading} |
|
/> |
|
<div> |
|
<span>Add Client Tag</span> |
|
<small>Add a client tag to identify this repository as created with GitRepublic (checked by default)</small> |
|
</div> |
|
</label> |
|
</div> |
|
|
|
<div class="form-actions"> |
|
<button |
|
type="submit" |
|
disabled={loading || !nip07Available} |
|
> |
|
{loading ? 'Publishing...' : 'Publish Repository Announcement'} |
|
</button> |
|
<a href="/" class="cancel-link">Cancel</a> |
|
</div> |
|
</form> |
|
</main> |
|
</div> |
|
|
|
|