Browse Source

repo page refactor

Nostr-Signature: 9ad7610ff7aa61d62d3772d6ae7c0589cda8ff95cd7a60b81c84ba879e0f9d8a 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 8918f36d426d352a6787543daaa044cf51855632e2257f29cc18bb87db31d61c877b525113e21045d3bc135376e1c0574454e28bd409d3135bcb80079bc11947
main
Silberengel 3 weeks ago
parent
commit
ccdb96cdeb
  1. 1
      nostr/commit-signatures.jsonl
  2. 267
      src/lib/components/NostrLinkRenderer.svelte
  3. 312
      src/lib/components/RepoHeader.svelte
  4. 569
      src/lib/components/RepoHeaderEnhanced.svelte
  5. 219
      src/lib/components/RepoTabs.svelte
  6. 854
      src/routes/repos/[npub]/[repo]/+page.svelte

1
nostr/commit-signatures.jsonl

@ -40,3 +40,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771668002,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","finish profile page"]],"content":"Signed commit: finish profile page","id":"8a5aed2f8ac370f781dca9db96ade991c18b7cc3b0d27149d9e2741e8276f16f","sig":"16e9a9242f7c22dab8e37fd9d618419b4d51d7c0156f52c1289e275d2528312f4006696473c6836b5a661425fe0412fe54127291fb9b0d14777f93c8228cffb0"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771669826,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","user badge is a universal hyperlink to the profile page"]],"content":"Signed commit: user badge is a universal hyperlink to the profile page","id":"973a406714e586037d81cca323024ff5e2cc1fbaeda8846f6f2994c3829c4fe0","sig":"e7a58526a3786fc1b9ab1f957c87c13a42d3c2cc95effcf4ce4f4710e01ecc45fcff3ca542c5fa223961d7b99fe336a2851c133aebe3bfc1a591ffe1c34b221a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771669826,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","user badge is a universal hyperlink to the profile page"]],"content":"Signed commit: user badge is a universal hyperlink to the profile page","id":"973a406714e586037d81cca323024ff5e2cc1fbaeda8846f6f2994c3829c4fe0","sig":"e7a58526a3786fc1b9ab1f957c87c13a42d3c2cc95effcf4ce4f4710e01ecc45fcff3ca542c5fa223961d7b99fe336a2851c133aebe3bfc1a591ffe1c34b221a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771680916,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix profile feeds"]],"content":"Signed commit: fix profile feeds","id":"33f33d76f6c79e68fdab72c8fdfc7e1f0ecc53a879a7f5aef02481f17384a06f","sig":"8f9056eab081d66edb693eb35a2e400368aa897746b97ca40a216604dc14ee877eb7f4f16dd2eeac257025b3adfe82e23734c7c106b6cec5e8a1ca661c872cc5"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771680916,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix profile feeds"]],"content":"Signed commit: fix profile feeds","id":"33f33d76f6c79e68fdab72c8fdfc7e1f0ecc53a879a7f5aef02481f17384a06f","sig":"8f9056eab081d66edb693eb35a2e400368aa897746b97ca40a216604dc14ee877eb7f4f16dd2eeac257025b3adfe82e23734c7c106b6cec5e8a1ca661c872cc5"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771681068,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove landing page search bar"]],"content":"Signed commit: remove landing page search bar","id":"71087b100ce14a1f2eb975be23450c62143ee11a8fd0429ec7440bfea1751741","sig":"695c704503ed1397f6871770ad55822a17a503bcfb71a0db7b3f2477cacb0e767b9f122075b0216f884e41e175c0ac9f9e3d743086a2aa34db4aa1207c900703"}

267
src/lib/components/NostrLinkRenderer.svelte

@ -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>

312
src/lib/components/RepoHeader.svelte

@ -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>

569
src/lib/components/RepoHeaderEnhanced.svelte

@ -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>

219
src/lib/components/RepoTabs.svelte

@ -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>

854
src/routes/repos/[npub]/[repo]/+page.svelte

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save