Browse Source
Nostr-Signature: 9ad7610ff7aa61d62d3772d6ae7c0589cda8ff95cd7a60b81c84ba879e0f9d8a 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 8918f36d426d352a6787543daaa044cf51855632e2257f29cc18bb87db31d61c877b525113e21045d3bc135376e1c0574454e28bd409d3135bcb80079bc11947main
6 changed files with 1552 additions and 788 deletions
@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import UserBadge from './UserBadge.svelte'; |
||||
|
||||
interface Props { |
||||
content: string; |
||||
} |
||||
|
||||
let { content }: Props = $props(); |
||||
|
||||
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map()); |
||||
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // npub link -> pubkey hex |
||||
|
||||
// Parse nostr: links from content |
||||
function parseNostrLinks(text: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { |
||||
const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; |
||||
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; |
||||
let match; |
||||
|
||||
while ((match = nostrLinkRegex.exec(text)) !== null) { |
||||
const fullMatch = match[0]; |
||||
const prefix = match[1]; |
||||
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; |
||||
|
||||
if (prefix === 'nevent1') type = 'nevent'; |
||||
else if (prefix === 'naddr1') type = 'naddr'; |
||||
else if (prefix === 'note1') type = 'note1'; |
||||
else if (prefix === 'npub1') type = 'npub'; |
||||
else if (prefix === 'profile1') type = 'profile'; |
||||
else continue; |
||||
|
||||
links.push({ |
||||
type, |
||||
value: fullMatch, |
||||
start: match.index, |
||||
end: match.index + fullMatch.length |
||||
}); |
||||
} |
||||
|
||||
return links; |
||||
} |
||||
|
||||
// Load events/profiles from nostr: links |
||||
async function loadNostrLinks(text: string) { |
||||
const links = parseNostrLinks(text); |
||||
if (links.length === 0) return; |
||||
|
||||
const eventIds: string[] = []; |
||||
const aTags: string[] = []; |
||||
const npubs: string[] = []; |
||||
|
||||
for (const link of links) { |
||||
try { |
||||
if (link.type === 'nevent' || link.type === 'note1') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'nevent') { |
||||
eventIds.push(decoded.data.id); |
||||
} else if (decoded.type === 'note') { |
||||
eventIds.push(decoded.data as string); |
||||
} |
||||
} else if (link.type === 'naddr') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'naddr') { |
||||
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; |
||||
aTags.push(aTag); |
||||
} |
||||
} else if (link.type === 'npub' || link.type === 'profile') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'npub') { |
||||
npubs.push(link.value); |
||||
nostrLinkProfiles.set(link.value, decoded.data as string); |
||||
} |
||||
} |
||||
} catch { |
||||
// Invalid nostr link, skip |
||||
} |
||||
} |
||||
|
||||
// Fetch events |
||||
if (eventIds.length > 0) { |
||||
try { |
||||
const events = await Promise.race([ |
||||
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), |
||||
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
||||
]); |
||||
|
||||
for (const event of events) { |
||||
nostrLinkEvents.set(event.id, event); |
||||
} |
||||
} catch { |
||||
// Ignore fetch errors |
||||
} |
||||
} |
||||
|
||||
// Fetch a-tag events |
||||
if (aTags.length > 0) { |
||||
for (const aTag of aTags) { |
||||
const parts = aTag.split(':'); |
||||
if (parts.length === 3) { |
||||
try { |
||||
const kind = parseInt(parts[0]); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
const events = await Promise.race([ |
||||
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), |
||||
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
||||
]); |
||||
|
||||
if (events.length > 0) { |
||||
nostrLinkEvents.set(events[0].id, events[0]); |
||||
} |
||||
} catch { |
||||
// Ignore fetch errors |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Get event from nostr: link |
||||
function getEventFromNostrLink(link: string): NostrEvent | undefined { |
||||
try { |
||||
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { |
||||
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||
if (decoded.type === 'nevent') { |
||||
return nostrLinkEvents.get(decoded.data.id); |
||||
} else if (decoded.type === 'note') { |
||||
return nostrLinkEvents.get(decoded.data as string); |
||||
} |
||||
} else if (link.startsWith('nostr:naddr1')) { |
||||
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||
if (decoded.type === 'naddr') { |
||||
return Array.from(nostrLinkEvents.values()).find(e => { |
||||
const dTag = e.tags.find(t => t[0] === 'd')?.[1]; |
||||
return e.kind === decoded.data.kind && |
||||
e.pubkey === decoded.data.pubkey && |
||||
dTag === decoded.data.identifier; |
||||
}); |
||||
} |
||||
} |
||||
} catch { |
||||
// Invalid link |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
// Get pubkey from nostr: npub/profile link |
||||
function getPubkeyFromNostrLink(link: string): string | undefined { |
||||
return nostrLinkProfiles.get(link); |
||||
} |
||||
|
||||
// Process content with nostr links into parts for rendering |
||||
function processContent(): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { |
||||
const links = parseNostrLinks(content); |
||||
if (links.length === 0) { |
||||
return [{ type: 'text', value: content }]; |
||||
} |
||||
|
||||
const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; |
||||
let lastIndex = 0; |
||||
|
||||
for (const link of links) { |
||||
// Add text before link |
||||
if (link.start > lastIndex) { |
||||
const textPart = content.slice(lastIndex, link.start); |
||||
if (textPart) { |
||||
parts.push({ type: 'text', value: textPart }); |
||||
} |
||||
} |
||||
|
||||
// Add link |
||||
const event = getEventFromNostrLink(link.value); |
||||
const pubkey = getPubkeyFromNostrLink(link.value); |
||||
if (event) { |
||||
parts.push({ type: 'event', value: link.value, event }); |
||||
} else if (pubkey) { |
||||
parts.push({ type: 'profile', value: link.value, pubkey }); |
||||
} else { |
||||
parts.push({ type: 'placeholder', value: link.value }); |
||||
} |
||||
|
||||
lastIndex = link.end; |
||||
} |
||||
|
||||
// Add remaining text |
||||
if (lastIndex < content.length) { |
||||
const textPart = content.slice(lastIndex); |
||||
if (textPart) { |
||||
parts.push({ type: 'text', value: textPart }); |
||||
} |
||||
} |
||||
|
||||
return parts; |
||||
} |
||||
|
||||
const parts = $derived(processContent()); |
||||
|
||||
onMount(() => { |
||||
loadNostrLinks(content); |
||||
}); |
||||
|
||||
$effect(() => { |
||||
if (content) { |
||||
loadNostrLinks(content); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#each parts as part} |
||||
{#if part.type === 'text'} |
||||
{part.value} |
||||
{:else if part.type === 'event' && part.event} |
||||
<div class="nostr-link-event"> |
||||
<div class="nostr-link-event-header"> |
||||
<UserBadge pubkey={part.event.pubkey} /> |
||||
<span class="nostr-link-event-time"> |
||||
{new Date(part.event.created_at * 1000).toLocaleString()} |
||||
</span> |
||||
</div> |
||||
<div class="nostr-link-event-content"> |
||||
{part.event.content} |
||||
</div> |
||||
</div> |
||||
{:else if part.type === 'profile' && part.pubkey} |
||||
<UserBadge pubkey={part.pubkey} /> |
||||
{:else if part.type === 'placeholder'} |
||||
<span class="nostr-link-placeholder">{part.value}</span> |
||||
{/if} |
||||
{/each} |
||||
|
||||
<style> |
||||
.nostr-link-event { |
||||
margin: 0.5rem 0; |
||||
padding: 0.75rem; |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
} |
||||
|
||||
.nostr-link-event-header { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.nostr-link-event-time { |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.nostr-link-event-content { |
||||
color: var(--text-primary, #1a1a1a); |
||||
white-space: pre-wrap; |
||||
word-break: break-word; |
||||
} |
||||
|
||||
.nostr-link-placeholder { |
||||
color: var(--text-secondary, #666); |
||||
font-style: italic; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
|
||||
<script lang="ts"> |
||||
import UserBadge from './UserBadge.svelte'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
interface Props { |
||||
repoName: string; |
||||
repoDescription?: string; |
||||
ownerNpub: string; |
||||
ownerPubkey: string; |
||||
isMaintainer: boolean; |
||||
isPrivate?: boolean; |
||||
cloneUrls?: string[]; |
||||
onMenuToggle?: () => void; |
||||
showMenu?: boolean; |
||||
} |
||||
|
||||
let { |
||||
repoName, |
||||
repoDescription, |
||||
ownerNpub, |
||||
ownerPubkey, |
||||
isMaintainer, |
||||
isPrivate = false, |
||||
cloneUrls = [], |
||||
onMenuToggle, |
||||
showMenu = false |
||||
}: Props = $props(); |
||||
|
||||
let showCloneMenu = $state(false); |
||||
let showMoreMenu = $state(false); |
||||
</script> |
||||
|
||||
<header class="repo-header"> |
||||
<div class="repo-header-top"> |
||||
<div class="repo-title-section"> |
||||
<h1 class="repo-name">{repoName}</h1> |
||||
{#if isPrivate} |
||||
<span class="repo-badge private">Private</span> |
||||
{/if} |
||||
</div> |
||||
<div class="repo-header-actions"> |
||||
<button |
||||
class="menu-button" |
||||
onclick={() => onMenuToggle?.()} |
||||
aria-label="Menu" |
||||
> |
||||
<img src="/icons/menu.svg" alt="" class="icon" /> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
{#if repoDescription} |
||||
<p class="repo-description">{repoDescription}</p> |
||||
{/if} |
||||
|
||||
<div class="repo-meta"> |
||||
<div class="repo-owner"> |
||||
<span class="meta-label">Owner:</span> |
||||
<UserBadge pubkey={ownerPubkey} /> |
||||
</div> |
||||
|
||||
{#if cloneUrls.length > 0} |
||||
<div class="repo-clone"> |
||||
<button |
||||
class="clone-button" |
||||
onclick={() => showCloneMenu = !showCloneMenu} |
||||
aria-expanded={showCloneMenu} |
||||
> |
||||
<img src="/icons/git-branch.svg" alt="" class="icon" /> |
||||
Clone |
||||
</button> |
||||
{#if showCloneMenu} |
||||
<div class="clone-menu"> |
||||
{#each cloneUrls as url} |
||||
<button |
||||
class="clone-url-item" |
||||
onclick={() => { |
||||
navigator.clipboard.writeText(url); |
||||
showCloneMenu = false; |
||||
}} |
||||
> |
||||
{url} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if showMenu} |
||||
<div class="repo-menu"> |
||||
<button |
||||
class="more-button" |
||||
onclick={() => showMoreMenu = !showMoreMenu} |
||||
aria-expanded={showMoreMenu} |
||||
> |
||||
<img src="/icons/more-vertical.svg" alt="" class="icon" /> |
||||
</button> |
||||
{#if showMoreMenu} |
||||
<div class="more-menu"> |
||||
{#if isMaintainer} |
||||
<button class="menu-item">Settings</button> |
||||
<button class="menu-item">Transfer</button> |
||||
{/if} |
||||
<button class="menu-item">Bookmark</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</header> |
||||
|
||||
<style> |
||||
.repo-header { |
||||
padding: 0.75rem 1rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
position: sticky; |
||||
top: 0; |
||||
z-index: 100; |
||||
} |
||||
|
||||
.repo-header-top { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: flex-start; |
||||
gap: 1rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.repo-title-section { |
||||
flex: 1; |
||||
min-width: 0; |
||||
} |
||||
|
||||
.repo-name { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--text-primary, #1a1a1a); |
||||
word-break: break-word; |
||||
} |
||||
|
||||
.repo-badge { |
||||
display: inline-block; |
||||
padding: 0.125rem 0.5rem; |
||||
margin-left: 0.5rem; |
||||
font-size: 0.75rem; |
||||
border-radius: 0.25rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.repo-badge.private { |
||||
background: var(--error-bg, #fee); |
||||
color: var(--error-text, #c00); |
||||
} |
||||
|
||||
.repo-header-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.menu-button, |
||||
.clone-button, |
||||
.more-button { |
||||
padding: 0.5rem; |
||||
background: transparent; |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.25rem; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
transition: all 0.2s ease; |
||||
} |
||||
|
||||
.menu-button:hover, |
||||
.clone-button:hover, |
||||
.more-button:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
border-color: var(--accent, #007bff); |
||||
} |
||||
|
||||
.icon { |
||||
width: 18px; |
||||
height: 18px; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.repo-description { |
||||
margin: 0.5rem 0; |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
line-height: 1.5; |
||||
} |
||||
|
||||
.repo-meta { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
margin-top: 0.75rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.repo-owner { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.meta-label { |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.repo-clone { |
||||
position: relative; |
||||
} |
||||
|
||||
.clone-menu { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
z-index: 10; |
||||
min-width: 200px; |
||||
max-width: 90vw; |
||||
} |
||||
|
||||
.clone-url-item { |
||||
display: block; |
||||
width: 100%; |
||||
padding: 0.5rem 0.75rem; |
||||
text-align: left; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.75rem; |
||||
font-family: 'IBM Plex Mono', monospace; |
||||
color: var(--text-primary, #1a1a1a); |
||||
word-break: break-all; |
||||
} |
||||
|
||||
.clone-url-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.clone-url-item:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
.repo-menu { |
||||
position: relative; |
||||
margin-left: auto; |
||||
} |
||||
|
||||
.more-menu { |
||||
position: absolute; |
||||
top: 100%; |
||||
right: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
z-index: 10; |
||||
min-width: 150px; |
||||
} |
||||
|
||||
.menu-item { |
||||
display: block; |
||||
width: 100%; |
||||
padding: 0.5rem 0.75rem; |
||||
text-align: left; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
} |
||||
|
||||
.menu-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.menu-item:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
@media (min-width: 768px) { |
||||
.repo-header { |
||||
padding: 1rem 1.5rem; |
||||
} |
||||
|
||||
.repo-name { |
||||
font-size: 1.5rem; |
||||
} |
||||
|
||||
.repo-description { |
||||
font-size: 1rem; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,569 @@
@@ -0,0 +1,569 @@
|
||||
<script lang="ts"> |
||||
import UserBadge from './UserBadge.svelte'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
interface Props { |
||||
repoName: string; |
||||
repoDescription?: string; |
||||
ownerNpub: string; |
||||
ownerPubkey: string; |
||||
isMaintainer: boolean; |
||||
isPrivate?: boolean; |
||||
cloneUrls?: string[]; |
||||
branches?: Array<string | { name: string }>; |
||||
currentBranch?: string | null; |
||||
defaultBranch?: string | null; |
||||
isRepoCloned?: boolean | null; |
||||
copyingCloneUrl?: boolean; |
||||
onBranchChange?: (branch: string) => void; |
||||
onCopyCloneUrl?: () => void; |
||||
onDeleteBranch?: (branch: string) => void; |
||||
onMenuToggle?: () => void; |
||||
showMenu?: boolean; |
||||
userPubkey?: string | null; |
||||
isBookmarked?: boolean; |
||||
loadingBookmark?: boolean; |
||||
onToggleBookmark?: () => void; |
||||
onFork?: () => void; |
||||
forking?: boolean; |
||||
onCloneToServer?: () => void; |
||||
cloning?: boolean; |
||||
checkingCloneStatus?: boolean; |
||||
onCreateIssue?: () => void; |
||||
onCreatePR?: () => void; |
||||
onCreatePatch?: () => void; |
||||
onCreateBranch?: () => void; |
||||
onSettings?: () => void; |
||||
onGenerateVerification?: () => void; |
||||
onDeleteAnnouncement?: () => void; |
||||
deletingAnnouncement?: boolean; |
||||
hasUnlimitedAccess?: boolean; |
||||
needsClone?: boolean; |
||||
} |
||||
|
||||
let { |
||||
repoName, |
||||
repoDescription, |
||||
ownerNpub, |
||||
ownerPubkey, |
||||
isMaintainer, |
||||
isPrivate = false, |
||||
cloneUrls = [], |
||||
branches = [], |
||||
currentBranch = null, |
||||
defaultBranch = null, |
||||
isRepoCloned = null, |
||||
copyingCloneUrl = false, |
||||
onBranchChange, |
||||
onCopyCloneUrl, |
||||
onDeleteBranch, |
||||
onMenuToggle, |
||||
showMenu = false, |
||||
userPubkey = null, |
||||
isBookmarked = false, |
||||
loadingBookmark = false, |
||||
onToggleBookmark, |
||||
onFork, |
||||
forking = false, |
||||
onCloneToServer, |
||||
cloning = false, |
||||
checkingCloneStatus = false, |
||||
onCreateIssue, |
||||
onCreatePR, |
||||
onCreatePatch, |
||||
onCreateBranch, |
||||
onSettings, |
||||
onGenerateVerification, |
||||
onDeleteAnnouncement, |
||||
deletingAnnouncement = false, |
||||
hasUnlimitedAccess = false, |
||||
needsClone = false |
||||
}: Props = $props(); |
||||
|
||||
let showCloneMenu = $state(false); |
||||
let showMoreMenu = $state(false); |
||||
let showBranchMenu = $state(false); |
||||
</script> |
||||
|
||||
<header class="repo-header"> |
||||
<div class="repo-header-top"> |
||||
<div class="repo-title-section"> |
||||
<h1 class="repo-name">{repoName}</h1> |
||||
{#if isPrivate} |
||||
<span class="repo-badge private">Private</span> |
||||
{/if} |
||||
{#if userPubkey && onToggleBookmark} |
||||
<button |
||||
class="bookmark-button" |
||||
class:bookmarked={isBookmarked} |
||||
onclick={() => onToggleBookmark?.()} |
||||
disabled={loadingBookmark} |
||||
title={isBookmarked ? 'Remove bookmark' : 'Add bookmark'} |
||||
aria-label={isBookmarked ? 'Remove bookmark' : 'Add bookmark'} |
||||
> |
||||
<img src="/icons/star.svg" alt="" class="icon" /> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
<div class="repo-header-actions"> |
||||
{#if userPubkey} |
||||
<button |
||||
class="menu-button" |
||||
onclick={() => { |
||||
onMenuToggle?.(); |
||||
showMoreMenu = !showMoreMenu; |
||||
}} |
||||
aria-label="Menu" |
||||
> |
||||
<img src="/icons/more-vertical.svg" alt="" class="icon" /> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
{#if repoDescription} |
||||
<p class="repo-description">{repoDescription}</p> |
||||
{/if} |
||||
|
||||
<div class="repo-meta"> |
||||
<div class="repo-owner"> |
||||
<span class="meta-label">Owner:</span> |
||||
<UserBadge pubkey={ownerPubkey} /> |
||||
</div> |
||||
|
||||
{#if cloneUrls.length > 0} |
||||
<div class="repo-clone"> |
||||
<button |
||||
class="clone-button" |
||||
onclick={() => showCloneMenu = !showCloneMenu} |
||||
aria-expanded={showCloneMenu} |
||||
> |
||||
<img src="/icons/git-branch.svg" alt="" class="icon" /> |
||||
Clone |
||||
</button> |
||||
{#if showCloneMenu} |
||||
<div class="clone-menu"> |
||||
{#each cloneUrls as url} |
||||
<button |
||||
class="clone-url-item" |
||||
onclick={() => { |
||||
navigator.clipboard.writeText(url); |
||||
showCloneMenu = false; |
||||
}} |
||||
> |
||||
{url} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if branches.length > 0 && currentBranch} |
||||
<div class="repo-branch"> |
||||
<button |
||||
class="branch-button" |
||||
onclick={() => showBranchMenu = !showBranchMenu} |
||||
aria-expanded={showBranchMenu} |
||||
> |
||||
<img src="/icons/git-branch.svg" alt="" class="icon" /> |
||||
{currentBranch} |
||||
</button> |
||||
{#if showBranchMenu} |
||||
<div class="branch-menu"> |
||||
{#each branches as branch} |
||||
{@const branchName = typeof branch === 'string' ? branch : branch.name} |
||||
<button |
||||
class="branch-item" |
||||
class:active={branchName === currentBranch} |
||||
onclick={() => { |
||||
onBranchChange?.(branchName); |
||||
showBranchMenu = false; |
||||
}} |
||||
> |
||||
{branchName} |
||||
{#if branchName === defaultBranch} |
||||
<span class="branch-badge">default</span> |
||||
{/if} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
{#if isMaintainer && currentBranch && currentBranch !== defaultBranch && onDeleteBranch} |
||||
<button |
||||
class="delete-branch-button" |
||||
onclick={() => currentBranch && onDeleteBranch(currentBranch)} |
||||
title="Delete branch" |
||||
> |
||||
× |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if isRepoCloned === true && onCopyCloneUrl} |
||||
<button |
||||
class="copy-clone-button" |
||||
onclick={() => onCopyCloneUrl()} |
||||
disabled={copyingCloneUrl} |
||||
title="Copy clone URL" |
||||
> |
||||
<img src="/icons/copy.svg" alt="" class="icon" /> |
||||
{copyingCloneUrl ? 'Copying...' : 'Copy Clone URL'} |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if showMoreMenu && userPubkey} |
||||
<div |
||||
class="more-menu-overlay" |
||||
onclick={() => showMoreMenu = false} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') { |
||||
showMoreMenu = false; |
||||
} |
||||
}} |
||||
role="button" |
||||
tabindex="0" |
||||
aria-label="Close menu" |
||||
></div> |
||||
<div class="more-menu"> |
||||
{#if onFork} |
||||
<button class="menu-item" onclick={() => { onFork(); showMoreMenu = false; }} disabled={forking}> |
||||
{forking ? 'Forking...' : 'Fork'} |
||||
</button> |
||||
{/if} |
||||
{#if onCreateIssue} |
||||
<button class="menu-item" onclick={() => { onCreateIssue(); showMoreMenu = false; }}> |
||||
Create Issue |
||||
</button> |
||||
{/if} |
||||
{#if onCreatePR} |
||||
<button class="menu-item" onclick={() => { onCreatePR(); showMoreMenu = false; }}> |
||||
Create Pull Request |
||||
</button> |
||||
{/if} |
||||
{#if onCreatePatch} |
||||
<button class="menu-item" onclick={() => { onCreatePatch(); showMoreMenu = false; }}> |
||||
Create Patch |
||||
</button> |
||||
{/if} |
||||
{#if hasUnlimitedAccess && (isRepoCloned === false || isRepoCloned === null) && onCloneToServer} |
||||
<button |
||||
class="menu-item" |
||||
onclick={() => { onCloneToServer(); showMoreMenu = false; }} |
||||
disabled={cloning || checkingCloneStatus} |
||||
> |
||||
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')} |
||||
</button> |
||||
{/if} |
||||
{#if isMaintainer && onSettings} |
||||
<button class="menu-item" onclick={() => { onSettings(); showMoreMenu = false; }}> |
||||
Settings |
||||
</button> |
||||
{/if} |
||||
{#if onGenerateVerification} |
||||
<button class="menu-item" onclick={() => { onGenerateVerification(); showMoreMenu = false; }}> |
||||
Generate Verification File |
||||
</button> |
||||
{/if} |
||||
{#if onDeleteAnnouncement} |
||||
<button |
||||
class="menu-item menu-item-danger" |
||||
onclick={() => { onDeleteAnnouncement(); showMoreMenu = false; }} |
||||
disabled={deletingAnnouncement} |
||||
> |
||||
{deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'} |
||||
</button> |
||||
{/if} |
||||
{#if isMaintainer && onCreateBranch} |
||||
<button |
||||
class="menu-item" |
||||
onclick={() => { onCreateBranch(); showMoreMenu = false; }} |
||||
disabled={needsClone} |
||||
> |
||||
Create New Branch |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</header> |
||||
|
||||
<style> |
||||
.repo-header { |
||||
padding: 0.75rem 1rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
position: sticky; |
||||
top: 0; |
||||
z-index: 100; |
||||
} |
||||
|
||||
.repo-header-top { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: flex-start; |
||||
gap: 1rem; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.repo-title-section { |
||||
flex: 1; |
||||
min-width: 0; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.repo-name { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--text-primary, #1a1a1a); |
||||
word-break: break-word; |
||||
} |
||||
|
||||
.repo-badge { |
||||
display: inline-block; |
||||
padding: 0.125rem 0.5rem; |
||||
font-size: 0.75rem; |
||||
border-radius: 0.25rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.repo-badge.private { |
||||
background: var(--error-bg, #fee); |
||||
color: var(--error-text, #c00); |
||||
} |
||||
|
||||
.bookmark-button { |
||||
padding: 0.25rem; |
||||
background: transparent; |
||||
border: none; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.bookmark-button.bookmarked img { |
||||
filter: brightness(0) saturate(100%) invert(67%) sepia(93%) saturate(1352%) hue-rotate(358deg) brightness(102%) contrast(106%); |
||||
} |
||||
|
||||
.repo-header-actions { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.menu-button, |
||||
.clone-button, |
||||
.branch-button, |
||||
.copy-clone-button { |
||||
padding: 0.5rem; |
||||
background: transparent; |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.25rem; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
transition: all 0.2s ease; |
||||
} |
||||
|
||||
.menu-button:hover, |
||||
.clone-button:hover, |
||||
.branch-button:hover, |
||||
.copy-clone-button:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
border-color: var(--accent, #007bff); |
||||
} |
||||
|
||||
.icon { |
||||
width: 18px; |
||||
height: 18px; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.repo-description { |
||||
margin: 0.5rem 0; |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
line-height: 1.5; |
||||
} |
||||
|
||||
.repo-meta { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
margin-top: 0.75rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.repo-owner { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.meta-label { |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.repo-clone, |
||||
.repo-branch { |
||||
position: relative; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.clone-menu, |
||||
.branch-menu { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
z-index: 10; |
||||
min-width: 200px; |
||||
max-width: 90vw; |
||||
max-height: 300px; |
||||
overflow-y: auto; |
||||
} |
||||
|
||||
.clone-url-item, |
||||
.branch-item { |
||||
display: block; |
||||
width: 100%; |
||||
padding: 0.5rem 0.75rem; |
||||
text-align: left; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
word-break: break-all; |
||||
} |
||||
|
||||
.branch-item { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
word-break: normal; |
||||
} |
||||
|
||||
.branch-item.active { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.branch-badge { |
||||
font-size: 0.75rem; |
||||
padding: 0.125rem 0.375rem; |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
border-radius: 0.25rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.clone-url-item:last-child, |
||||
.branch-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.clone-url-item:hover, |
||||
.branch-item:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
.delete-branch-button { |
||||
padding: 0.25rem 0.5rem; |
||||
background: var(--error-text, #dc2626); |
||||
color: #ffffff; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.delete-branch-button:hover { |
||||
background: var(--error-hover, #c82333); |
||||
} |
||||
|
||||
.more-menu-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
z-index: 99; |
||||
} |
||||
|
||||
.more-menu { |
||||
position: absolute; |
||||
top: 100%; |
||||
right: 0; |
||||
margin-top: 0.25rem; |
||||
background: var(--card-bg, #ffffff); |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
z-index: 100; |
||||
min-width: 200px; |
||||
} |
||||
|
||||
.menu-item { |
||||
display: block; |
||||
width: 100%; |
||||
padding: 0.5rem 0.75rem; |
||||
text-align: left; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
} |
||||
|
||||
.menu-item:last-child { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.menu-item:hover:not(:disabled) { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
.menu-item:disabled { |
||||
opacity: 0.6; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.menu-item-danger { |
||||
color: var(--error-text, #dc2626); |
||||
} |
||||
|
||||
.menu-item-danger:hover:not(:disabled) { |
||||
background: var(--error-bg, #fee); |
||||
} |
||||
|
||||
@media (min-width: 768px) { |
||||
.repo-header { |
||||
padding: 1rem 1.5rem; |
||||
} |
||||
|
||||
.repo-name { |
||||
font-size: 1.5rem; |
||||
} |
||||
|
||||
.repo-description { |
||||
font-size: 1rem; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
|
||||
<script lang="ts"> |
||||
interface Props { |
||||
activeTab: string; |
||||
tabs: Array<{ id: string; label: string; icon?: string; count?: number }>; |
||||
onTabChange: (tab: string) => void; |
||||
} |
||||
|
||||
let { activeTab, tabs, onTabChange }: Props = $props(); |
||||
let showMobileMenu = $state(false); |
||||
</script> |
||||
|
||||
<nav class="repo-tabs"> |
||||
<div class="tabs-container"> |
||||
{#each tabs as tab} |
||||
<button |
||||
class="tab-button" |
||||
class:active={activeTab === tab.id} |
||||
onclick={() => { |
||||
onTabChange(tab.id); |
||||
showMobileMenu = false; |
||||
}} |
||||
aria-current={activeTab === tab.id ? 'page' : undefined} |
||||
> |
||||
{#if tab.icon} |
||||
<img src={tab.icon} alt="" class="tab-icon" /> |
||||
{/if} |
||||
<span class="tab-label">{tab.label}</span> |
||||
{#if tab.count !== undefined} |
||||
<span class="tab-count">{tab.count}</span> |
||||
{/if} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
|
||||
<!-- Mobile menu button --> |
||||
<button |
||||
class="mobile-menu-button" |
||||
onclick={() => showMobileMenu = !showMobileMenu} |
||||
aria-expanded={showMobileMenu} |
||||
aria-label="Tab menu" |
||||
> |
||||
<img src="/icons/menu.svg" alt="" class="icon" /> |
||||
<span class="current-tab-label"> |
||||
{tabs.find(t => t.id === activeTab)?.label || 'Menu'} |
||||
</span> |
||||
</button> |
||||
|
||||
{#if showMobileMenu} |
||||
<div class="mobile-tabs-menu"> |
||||
{#each tabs as tab} |
||||
<button |
||||
class="mobile-tab-item" |
||||
class:active={activeTab === tab.id} |
||||
onclick={() => { |
||||
onTabChange(tab.id); |
||||
showMobileMenu = false; |
||||
}} |
||||
> |
||||
{#if tab.icon} |
||||
<img src={tab.icon} alt="" class="tab-icon" /> |
||||
{/if} |
||||
<span>{tab.label}</span> |
||||
{#if tab.count !== undefined} |
||||
<span class="tab-count">{tab.count}</span> |
||||
{/if} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</nav> |
||||
|
||||
<style> |
||||
.repo-tabs { |
||||
position: relative; |
||||
background: var(--card-bg, #ffffff); |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
overflow-x: auto; |
||||
-webkit-overflow-scrolling: touch; |
||||
} |
||||
|
||||
.tabs-container { |
||||
display: none; |
||||
gap: 0; |
||||
} |
||||
|
||||
.tab-button { |
||||
padding: 0.75rem 1rem; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 2px solid transparent; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
white-space: nowrap; |
||||
transition: all 0.2s ease; |
||||
position: relative; |
||||
} |
||||
|
||||
.tab-button:hover { |
||||
color: var(--text-primary, #1a1a1a); |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
.tab-button.active { |
||||
color: var(--accent, #007bff); |
||||
border-bottom-color: var(--accent, #007bff); |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.tab-icon { |
||||
width: 16px; |
||||
height: 16px; |
||||
flex-shrink: 0; |
||||
} |
||||
|
||||
.tab-label { |
||||
display: none; |
||||
} |
||||
|
||||
.tab-count { |
||||
padding: 0.125rem 0.375rem; |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
border-radius: 0.75rem; |
||||
font-size: 0.75rem; |
||||
font-weight: 500; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.tab-button.active .tab-count { |
||||
background: var(--accent, #007bff); |
||||
color: var(--accent-text, #ffffff); |
||||
} |
||||
|
||||
.mobile-menu-button { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
padding: 0.75rem 1rem; |
||||
width: 100%; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.mobile-menu-button .icon { |
||||
width: 18px; |
||||
height: 18px; |
||||
} |
||||
|
||||
.current-tab-label { |
||||
flex: 1; |
||||
text-align: left; |
||||
} |
||||
|
||||
.mobile-tabs-menu { |
||||
position: absolute; |
||||
top: 100%; |
||||
left: 0; |
||||
right: 0; |
||||
background: var(--card-bg, #ffffff); |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); |
||||
z-index: 50; |
||||
max-height: 70vh; |
||||
overflow-y: auto; |
||||
} |
||||
|
||||
.mobile-tab-item { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.75rem; |
||||
padding: 0.875rem 1rem; |
||||
width: 100%; |
||||
background: transparent; |
||||
border: none; |
||||
border-bottom: 1px solid var(--border-color, #e0e0e0); |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
color: var(--text-primary, #1a1a1a); |
||||
text-align: left; |
||||
transition: background 0.2s ease; |
||||
} |
||||
|
||||
.mobile-tab-item:hover { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
} |
||||
|
||||
.mobile-tab-item.active { |
||||
background: var(--bg-secondary, #f5f5f5); |
||||
color: var(--accent, #007bff); |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.mobile-tab-item .tab-count { |
||||
margin-left: auto; |
||||
} |
||||
|
||||
@media (min-width: 768px) { |
||||
.tabs-container { |
||||
display: flex; |
||||
} |
||||
|
||||
.mobile-menu-button, |
||||
.mobile-tabs-menu { |
||||
display: none; |
||||
} |
||||
|
||||
.tab-label { |
||||
display: inline; |
||||
} |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue