Browse Source
Nostr-Signature: 9ad7610ff7aa61d62d3772d6ae7c0589cda8ff95cd7a60b81c84ba879e0f9d8a 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 8918f36d426d352a6787543daaa044cf51855632e2257f29cc18bb87db31d61c877b525113e21045d3bc135376e1c0574454e28bd409d3135bcb80079bc11947main
6 changed files with 1552 additions and 788 deletions
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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