Browse Source

fix the menus and implement the patch page

Nostr-Signature: b4e946a2acfc7c71b7c3d3a533186dc500edcd4e3f277aa5f83fa08fe5d2ffa7 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 226f5ae08cd5dd27baf8cca64889d27bcd40aa4655a274ba19ef068e394be99c916bdf86569169800e4dfdfe89e34f834bb95a4a404bda7712cbbf537633a6f5
main
Silberengel 3 weeks ago
parent
commit
f7f316b003
  1. 1
      nostr/commit-signatures.jsonl
  2. 12
      src/lib/components/RepoHeaderEnhanced.svelte
  3. 3
      src/lib/styles/components.css
  4. 271
      src/lib/styles/repo.css
  5. 70
      src/routes/api/repos/[npub]/[repo]/patches/+server.ts
  6. 414
      src/routes/repos/[npub]/[repo]/+page.svelte
  7. 4
      static/icons/plus.svg

1
nostr/commit-signatures.jsonl

@ -47,3 +47,4 @@ @@ -47,3 +47,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771691277,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix docs"]],"content":"Signed commit: fix docs","id":"4671648712f19537cbf0fd00cf19e254eae4a1ac9c1274ea396e62dac193b88c","sig":"49a3e89e312ec4caebfeacdaade3e4cc6d027ab9c50d8e6aa1998f120a81d8d51235ae397df6e42b9efca4147497b8881731dda6d58fee7d28d2ac07cec295ec"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771705699,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"59d0c409196dccb8109a29829002df69dbca43c5e95c1fdc1e7baa0b88ee5927","sig":"af8726a86e30c64b098ad13946d5bc84cb08d5ea8b75f08641c03fbdd8b9c91683e8091b206159dde2239ea8964cb3589bcb4ec2892541d2980f186a0fb09af9"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771708933,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"6d8832125b76095b2e7ed57b71e26a6c05d9b19a14dfa76724c71f392147fe95","sig":"6ddebfa995b5b3f469db5f3cdbd7d13fa2307d7988c2667479015d6bc2ff442be357ee97e51340a944eb34fed73522db3930016d343810927486bdbcabddae5c"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771742489,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"6fd5146b5f5980987adc5e6b93a1bcb31cfbb6a2e4fce6f1dbe5c7fdf54b717a","sig":"41422b07b63fc494c0aba22b85f1b56a6a5527f9df48d49816f7be417ad74608f8d1dff8302f562b2f47690720c089aab76db948f5bf1ecdda68741b256d7a2b"}

12
src/lib/components/RepoHeaderEnhanced.svelte

@ -42,6 +42,7 @@ @@ -42,6 +42,7 @@
needsClone?: boolean;
allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>;
onCopyEventId?: () => void;
topics?: string[];
}
let {
@ -82,7 +83,8 @@ @@ -82,7 +83,8 @@
hasUnlimitedAccess = false,
needsClone = false,
allMaintainers = [],
onCopyEventId
onCopyEventId,
topics = []
}: Props = $props();
let showCloneMenu = $state(false);
@ -211,6 +213,14 @@ @@ -211,6 +213,14 @@
<p class="repo-description">{repoDescription}</p>
{/if}
{#if topics && topics.length > 0}
<div class="repo-topics">
{#each topics as topic}
<span class="topic-tag">{topic}</span>
{/each}
</div>
{/if}
<div class="repo-meta">
<div class="repo-owner">
<button

3
src/lib/styles/components.css

@ -764,6 +764,9 @@ @@ -764,6 +764,9 @@
max-width: min(calc(100vw - 1rem), 320px);
width: auto;
position: absolute;
max-height: calc(100vh - 8rem);
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 768px) {

271
src/lib/styles/repo.css

@ -304,6 +304,8 @@ @@ -304,6 +304,8 @@
padding: 2rem;
text-align: center;
color: var(--text-muted);
flex: 1;
overflow-y: auto;
}
.error {
@ -537,13 +539,14 @@ @@ -537,13 +539,14 @@
color: var(--text-secondary);
}
.repo-topics {
.repo-header .repo-topics {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.topic-tag {
.repo-header .topic-tag {
padding: 0.25rem 0.5rem;
background: var(--accent);
color: var(--accent-text, #ffffff);
@ -554,11 +557,78 @@ @@ -554,11 +557,78 @@
.repo-clone-urls {
margin-top: 0.5rem;
font-size: 0.75rem;
}
.clone-label-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
width: 100%;
text-align: left;
margin-bottom: 0.5rem;
}
.clone-label-button:hover {
opacity: 0.8;
}
.clone-label {
font-weight: 500;
color: var(--text-primary);
}
.clone-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.2s;
flex-shrink: 0;
}
.clone-toggle-icon.expanded {
transform: rotate(180deg);
}
.clone-url-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
transition: max-height 0.3s ease, opacity 0.3s ease;
overflow: hidden;
}
.clone-url-list.collapsed {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
}
@media (min-width: 768px) {
.clone-label-button {
pointer-events: none;
}
.clone-toggle-icon {
display: none;
}
.clone-url-list.collapsed {
max-height: none;
opacity: 1;
margin: 0;
padding: 0;
}
}
.clone-url-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.clone-url {
@ -569,6 +639,13 @@ @@ -569,6 +639,13 @@
font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
word-break: break-all;
flex: 1;
}
.clone-more {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.icon-inline {
@ -596,8 +673,12 @@ @@ -596,8 +673,12 @@
.create-file-button,
.create-tag-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
padding: 0.375rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--button-primary);
color: white;
border: none;
@ -611,6 +692,12 @@ @@ -611,6 +692,12 @@
background: var(--button-primary-hover);
}
.create-file-button .icon,
.create-tag-button .icon {
width: 18px;
height: 18px;
}
.delete-file-button {
padding: 0.25rem;
background: none;
@ -648,31 +735,135 @@ @@ -648,31 +735,135 @@
.history-sidebar,
.tags-sidebar,
.issues-sidebar,
.prs-sidebar {
.prs-sidebar,
.patches-sidebar,
.discussions-sidebar,
.docs-sidebar {
width: 300px;
border-right: 1px solid var(--border-color);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.patches-sidebar .patches-header,
.discussions-sidebar .discussions-header,
.docs-sidebar .docs-header {
overflow: visible;
}
.history-header,
.tags-header,
.issues-header,
.prs-header {
.prs-header,
.docs-header,
.patches-header,
.discussions-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: 0.75rem;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
overflow: visible;
z-index: 1;
}
.create-patch-button,
.create-discussion-button,
.create-issue-button,
.create-pr-button {
margin-left: auto;
}
.patches-header h2,
.discussions-header h2,
.docs-header h2 {
margin: 0;
white-space: nowrap;
flex-shrink: 0;
}
.patches-content,
.discussions-content {
padding: 1rem;
flex: 1;
overflow-y: auto;
}
.patch-subject,
.discussion-title {
font-weight: 600;
color: var(--text-primary);
}
.patch-meta,
.discussion-meta {
display: flex;
gap: 0.75rem;
align-items: center;
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.patch-detail,
.discussion-detail {
padding: 1rem;
}
.patch-header-detail,
.discussion-header-detail {
margin-bottom: 1rem;
}
.patch-header-detail h3,
.discussion-header-detail h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.patch-meta-detail,
.discussion-meta-detail {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.875rem;
color: var(--text-secondary);
}
.patch-body {
margin-top: 1rem;
}
.patch-content {
margin: 0;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--text-primary);
}
.commit-list,
.tag-list,
.issue-list,
.pr-list {
.pr-list,
.patch-list,
.discussion-list {
list-style: none;
padding: 0;
margin: 0;
@ -683,10 +874,35 @@ @@ -683,10 +874,35 @@
.commit-item,
.tag-item,
.issue-item,
.pr-item {
.pr-item,
.patch-item,
.discussion-item {
border-bottom: 1px solid var(--border-color);
}
.patch-item-button,
.discussion-item-button {
width: 100%;
padding: 0.75rem 1rem;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
}
.patch-item.selected .patch-item-button,
.discussion-item.selected .discussion-item-button {
background: var(--accent);
color: var(--text-on-accent);
}
.issue-item.selected,
.pr-item.selected,
.patch-item.selected,
.discussion-item.selected {
background: var(--accent);
color: var(--text-on-accent);
}
.commit-button {
@ -729,14 +945,39 @@ @@ -729,14 +945,39 @@
}
.create-issue-button,
.create-pr-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
.create-pr-button,
.create-patch-button,
.create-discussion-button,
.create-reply-button {
padding: 0.375rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--button-primary);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.2s ease;
}
.create-issue-button:hover,
.create-pr-button:hover,
.create-patch-button:hover,
.create-discussion-button:hover,
.create-reply-button:hover {
background: var(--button-primary-hover);
}
.create-issue-button .icon,
.create-pr-button .icon,
.create-patch-button .icon,
.create-discussion-button .icon,
.create-reply-button .icon {
width: 18px;
height: 18px;
}
.refresh-button {
@ -769,6 +1010,8 @@ @@ -769,6 +1010,8 @@
padding: 2rem;
text-align: center;
color: var(--text-muted);
flex: 1;
overflow-y: auto;
}
.empty-state {

70
src/routes/api/repos/[npub]/[repo]/patches/+server.ts

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
/**
* API endpoint for Patches (NIP-34 kind 1617)
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { nostrClient } from '$lib/services/service-registry.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
import logger from '$lib/services/logger.js';
import { KIND } from '$lib/types/nostr.js';
function getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`;
}
export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
const repoAddress = getRepoAddress(context.repoOwnerPubkey, context.repo);
const patches = await nostrClient.fetchEvents([
{
kinds: [KIND.PATCH],
'#a': [repoAddress],
limit: 100
}
]);
return json(patches);
},
{ operation: 'getPatches', requireRepoExists: false, requireRepoAccess: false } // Patches are stored in Nostr, don't require local repo
);
export const POST: RequestHandler = withRepoValidation(
async ({ repoContext, requestContext, event }) => {
const body = await event.request.json();
const { event: patchEvent } = body;
if (!patchEvent) {
throw handleValidationError('Missing event in request body', { operation: 'createPatch', npub: repoContext.npub, repo: repoContext.repo });
}
// Verify the event is properly signed
if (!patchEvent.sig || !patchEvent.id) {
throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createPatch', npub: repoContext.npub, repo: repoContext.repo });
}
// Publish the event to relays
const result = await nostrClient.publishEvent(patchEvent, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) {
throw handleApiError(new Error('Failed to publish patch to all relays'), { operation: 'createPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish patch to all relays');
}
// Forward to messaging platforms if user has unlimited access and preferences configured
if (requestContext.userPubkeyHex && result.success.length > 0) {
forwardEventIfEnabled(patchEvent, requestContext.userPubkeyHex)
.catch(err => {
// Log but don't fail the request - forwarding is optional
logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to forward event to messaging platforms');
});
}
return json({ success: true, event: patchEvent, published: result });
},
{ operation: 'createPatch', requireRepoAccess: false } // Patches can be created by anyone with access
);

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

@ -332,16 +332,19 @@ @@ -332,16 +332,19 @@
// Order: Files, Issues, PRs, Patches, Discussion, History, Tags, Docs
const tabs = $derived([
{ id: 'files', label: 'Files', icon: '/icons/file-text.svg' },
{ id: 'issues', label: 'Issues', icon: '/icons/alert-circle.svg', count: issues.length },
{ id: 'prs', label: 'Pull Requests', icon: '/icons/git-pull-request.svg', count: prs.length },
{ id: 'issues', label: 'Issues', icon: '/icons/alert-circle.svg' },
{ id: 'prs', label: 'Pull Requests', icon: '/icons/git-pull-request.svg' },
{ id: 'patches', label: 'Patches', icon: '/icons/clipboard-list.svg' },
{ id: 'discussions', label: 'Discussions', icon: '/icons/message-circle.svg' },
{ id: 'history', label: 'History', icon: '/icons/git-commit.svg' },
{ id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg' },
{ id: 'tags', label: 'Tags', icon: '/icons/tag.svg' },
{ id: 'docs', label: 'Docs', icon: '/icons/book.svg' }
]);
// Patches
let patches = $state<Array<{ id: string; subject: string; content: string; author: string; created_at: number; kind: number }>>([]);
let loadingPatches = $state(false);
let selectedPatch = $state<string | null>(null);
let showCreatePatchDialog = $state(false);
let newPatchContent = $state('');
let newPatchSubject = $state('');
@ -350,6 +353,7 @@ @@ -350,6 +353,7 @@
// Documentation
let documentationContent = $state<string | null>(null);
let documentationHtml = $state<string | null>(null);
let documentationKind = $state<number | null>(null);
let loadingDocs = $state(false);
// Discussion threads
@ -367,6 +371,7 @@ @@ -367,6 +371,7 @@
let creatingReply = $state(false);
// Discussions
let selectedDiscussion = $state<string | null>(null);
let discussions = $state<Array<{
type: 'thread' | 'comments';
id: string;
@ -726,6 +731,8 @@ @@ -726,6 +731,8 @@
// Mobile view toggle for file list/file viewer
let showFileListOnMobile = $state(true);
// Mobile collapse for clone URLs
let cloneUrlsExpanded = $state(false);
// Guard to prevent README auto-load loop
let readmeAutoLoadAttempted = $state(false);
@ -1570,8 +1577,10 @@ @@ -1570,8 +1577,10 @@
async function loadDocumentation() {
if (loadingDocs) return;
// Only skip if we already have rendered HTML (successful load)
if (documentationHtml !== null) return;
// Reset documentation when reloading
documentationHtml = null;
documentationContent = null;
documentationKind = null;
loadingDocs = true;
try {
@ -1621,7 +1630,7 @@ @@ -1621,7 +1630,7 @@
// Look for documentation tag in the announcement
const documentationTag = announcement.tags.find(t => t[0] === 'documentation');
let docKind: number | null = null;
documentationKind = null;
if (documentationTag && documentationTag[1]) {
// Parse the a-tag format: kind:pubkey:identifier
@ -1629,14 +1638,14 @@ @@ -1629,14 +1638,14 @@
const parts = docAddress.split(':');
if (parts.length >= 3) {
docKind = parseInt(parts[0]);
documentationKind = parseInt(parts[0]);
const docPubkey = parts[1];
const docIdentifier = parts.slice(2).join(':'); // In case identifier contains ':'
// Fetch the documentation event
const docEvents = await client.fetchEvents([
{
kinds: [docKind],
kinds: [documentationKind],
authors: [docPubkey],
'#d': [docIdentifier],
limit: 1
@ -1656,12 +1665,13 @@ @@ -1656,12 +1665,13 @@
} else {
// No documentation tag, try to use announcement content as fallback
documentationContent = announcement.content || null;
// Announcement is kind 30617, not a doc kind, so keep documentationKind as null
}
// Render content based on kind: AsciiDoc for 30041 or 30818, Markdown otherwise
if (documentationContent) {
// Check if we should use AsciiDoc parser (kinds 30041 or 30818)
const useAsciiDoc = docKind === 30041 || docKind === 30818;
const useAsciiDoc = documentationKind === 30041 || documentationKind === 30818;
if (useAsciiDoc) {
// Use AsciiDoc parser
@ -3020,8 +3030,10 @@ @@ -3020,8 +3030,10 @@
// Reload documentation if docs tab is active (might be branch-specific)
if (activeTab === 'docs') {
// Reset documentation HTML to force reload
// Reset documentation to force reload
documentationHtml = null;
documentationContent = null;
documentationKind = null;
reloadPromises.push(loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err)));
}
@ -3663,6 +3675,8 @@ @@ -3663,6 +3675,8 @@
newPatchContent = '';
newPatchSubject = '';
alert('Patch created successfully!');
// Reload patches
await loadPatches();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create patch';
console.error('Error creating patch:', err);
@ -3671,6 +3685,33 @@ @@ -3671,6 +3685,33 @@
}
}
async function loadPatches() {
if (repoNotFound) return;
loadingPatches = true;
error = null;
try {
const response = await fetch(`/api/repos/${npub}/${repo}/patches`, {
headers: buildApiHeaders()
});
if (response.ok) {
const data = await response.json();
patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number }) => ({
id: patch.id,
subject: patch.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled',
content: patch.content,
author: patch.pubkey,
created_at: patch.created_at,
kind: patch.kind || KIND.PATCH
}));
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load patches';
console.error('Error loading patches:', err);
} finally {
loadingPatches = false;
}
}
// Only load tab content when tab actually changes, not on every render
let lastTab = $state<string | null>(null);
$effect(() => {
@ -3702,7 +3743,7 @@ @@ -3702,7 +3743,7 @@
} else if (activeTab === 'discussions') {
loadDiscussions();
} else if (activeTab === 'patches') {
// Patches tab - patches are loaded on demand when creating/viewing
loadPatches();
}
}
});
@ -3733,6 +3774,8 @@ @@ -3733,6 +3774,8 @@
// Reload documentation if docs tab is active (reset to force reload)
if (activeTab === 'docs') {
documentationHtml = null;
documentationContent = null;
documentationKind = null;
loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err));
}
}
@ -3790,6 +3833,7 @@ @@ -3790,6 +3833,7 @@
cloneUrls={pageData.repoCloneUrls || []}
branches={branches}
currentBranch={currentBranch}
topics={pageData.repoTopics || []}
defaultBranch={defaultBranch}
isRepoCloned={isRepoCloned}
copyingCloneUrl={copyingCloneUrl}
@ -3849,19 +3893,22 @@ @@ -3849,19 +3893,22 @@
{pageData.repoLanguage}
</span>
{/if}
{#if pageData.repoTopics && pageData.repoTopics.length > 0}
<div class="repo-topics">
{#each pageData.repoTopics as topic}
<span class="topic-tag">{topic}</span>
{/each}
</div>
{/if}
{#if forkInfo?.isFork && forkInfo.originalRepo}
<span class="fork-badge">Forked from <a href={`/repos/${forkInfo.originalRepo.npub}/${forkInfo.originalRepo.repo}`}>{forkInfo.originalRepo.repo}</a></span>
{/if}
{#if pageData.repoCloneUrls && pageData.repoCloneUrls.length > 0}
<div class="repo-clone-urls">
<button
class="clone-label-button"
onclick={() => cloneUrlsExpanded = !cloneUrlsExpanded}
aria-expanded={cloneUrlsExpanded}
>
<span class="clone-label">Clone URLs:</span>
<svg class="clone-toggle-icon" class:expanded={cloneUrlsExpanded} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<div class="clone-url-list" class:collapsed={!cloneUrlsExpanded}>
{#each pageData.repoCloneUrls.slice(0, 3) as cloneUrl}
{@const cloneVerification = verificationStatus?.cloneVerifications?.find(cv => {
const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase().replace(/^https?:\/\//, '');
@ -3905,6 +3952,7 @@ @@ -3905,6 +3952,7 @@
<span class="clone-more">+{pageData.repoCloneUrls.length - 3} more</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
@ -3950,7 +3998,9 @@ @@ -3950,7 +3998,9 @@
class="create-file-button"
disabled={needsClone}
title={needsClone ? cloneTooltip : 'Create a new file'}
>+ New File</button>
>
<img src="/icons/plus.svg" alt="New File" class="icon" />
</button>
{/if}
<button
onclick={() => showFileListOnMobile = !showFileListOnMobile}
@ -4011,11 +4061,7 @@ @@ -4011,11 +4061,7 @@
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Commit History</h2>
<button onclick={loadCommitHistory} class="refresh-button">
<img src="/icons/refresh-cw.svg" alt="" class="icon-inline" />
Refresh
</button>
<h2>Commits</h2>
</div>
{#if loadingCommits}
<div class="loading">Loading commits...</div>
@ -4062,7 +4108,9 @@ @@ -4062,7 +4108,9 @@
class="create-tag-button"
disabled={needsClone}
title={needsClone ? cloneTooltip : 'Create a new tag'}
>+ New Tag</button>
>
<img src="/icons/plus.svg" alt="New Tag" class="icon" />
</button>
{/if}
</div>
{#if tags.length === 0}
@ -4100,7 +4148,9 @@ @@ -4100,7 +4148,9 @@
<button onclick={() => {
if (!userPubkey) return;
showCreateIssueDialog = true;
}} class="create-issue-button">+ New Issue</button>
}} class="create-issue-button" title="Create a new issue">
<img src="/icons/plus.svg" alt="New Issue" class="icon" />
</button>
{/if}
</div>
{#if loadingIssues}
@ -4159,7 +4209,9 @@ @@ -4159,7 +4209,9 @@
<button onclick={() => {
if (!userPubkey) return;
showCreatePRDialog = true;
}} class="create-pr-button">+ New PR</button>
}} class="create-pr-button" title="Create a new pull request">
<img src="/icons/plus.svg" alt="New PR" class="icon" />
</button>
{/if}
</div>
{#if loadingPRs}
@ -4191,6 +4243,127 @@ @@ -4191,6 +4243,127 @@
</aside>
{/if}
<!-- Patches View -->
{#if activeTab === 'patches'}
<aside class="patches-sidebar">
<div class="patches-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Patches</h2>
{#if userPubkey}
<button
onclick={() => showCreatePatchDialog = true}
class="create-patch-button"
title="Create a new patch"
>
<img src="/icons/plus.svg" alt="New Patch" class="icon" />
</button>
{/if}
</div>
{#if loadingPatches}
<div class="loading">Loading patches...</div>
{:else if patches.length === 0}
<div class="empty">No patches found</div>
{:else}
<ul class="patch-list">
{#each patches as patch}
<li class="patch-item" class:selected={selectedPatch === patch.id}>
<button
onclick={() => selectedPatch = patch.id}
class="patch-item-button"
>
<div class="patch-header">
<span class="patch-subject">{patch.subject}</span>
</div>
<div class="patch-meta">
<span>#{patch.id.slice(0, 7)}</span>
<span>{new Date(patch.created_at * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
{/if}
<!-- Discussions View -->
{#if activeTab === 'discussions'}
<aside class="discussions-sidebar">
<div class="discussions-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Discussions</h2>
{#if userPubkey}
<button
onclick={() => showCreateThreadDialog = true}
class="create-discussion-button"
disabled={creatingThread}
title={creatingThread ? 'Creating...' : 'New Discussion Thread'}
>
<img src="/icons/plus.svg" alt="New Discussion" class="icon" />
</button>
{/if}
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length === 0}
<div class="empty">No discussions found</div>
{:else}
<ul class="discussion-list">
{#each discussions as discussion}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
{@const totalReplies = hasComments ? countAllReplies(discussion.comments) : 0}
<li class="discussion-item" class:selected={selectedDiscussion === discussion.id}>
<button
onclick={() => selectedDiscussion = discussion.id}
class="discussion-item-button"
>
<div class="discussion-header">
<span class="discussion-title">{discussion.title}</span>
</div>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>{new Date(discussion.createdAt * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
{/if}
<!-- Docs View -->
{#if activeTab === 'docs'}
<aside class="docs-sidebar">
<div class="docs-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Docs</h2>
</div>
<div class="empty">Documentation</div>
</aside>
{/if}
<!-- Editor Area / Diff View / README -->
<div class="editor-area" class:hide-on-mobile={showFileListOnMobile && activeTab === 'files'}>
{#if activeTab === 'files' && readmeContent && !currentFile}
@ -4403,29 +4576,29 @@ @@ -4403,29 +4576,29 @@
</div>
{/if}
{#if activeTab === 'docs'}
<div class="docs-content">
<div class="docs-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Docs</h2>
{#if activeTab === 'patches'}
<div class="patches-content">
{#if patches.length === 0}
<div class="empty-state">
<p>No patches found. Create one to get started!</p>
</div>
{#if loadingDocs}
<div class="loading">Loading documentation...</div>
{:else if documentationHtml}
<div class="documentation-body">
{@html documentationHtml}
{:else if selectedPatch}
{#each patches.filter(p => p.id === selectedPatch) as patch}
<div class="patch-detail">
<h3>{patch.subject}</h3>
<div class="patch-meta-detail">
<span>#{patch.id.slice(0, 7)}</span>
<span>Created {new Date(patch.created_at * 1000).toLocaleString()}</span>
<EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} />
</div>
<div class="patch-body">
<pre class="patch-content">{patch.content}</pre>
</div>
{:else if documentationContent === null}
<div class="empty-state">
<p>No documentation found for this repository.</p>
</div>
{/each}
{:else}
<div class="empty-state">
<p>Documentation content is empty.</p>
<p>Select a patch from the sidebar to view it</p>
</div>
{/if}
</div>
@ -4433,62 +4606,18 @@ @@ -4433,62 +4606,18 @@
{#if activeTab === 'discussions'}
<div class="discussions-content">
<div class="discussions-header">
<TabsMenu
activeTab={activeTab}
{tabs}
onTabChange={(tab) => activeTab = tab as typeof activeTab}
/>
<h2>Discussions</h2>
<div class="discussions-actions">
<button
class="btn btn-secondary icon-button"
onclick={() => loadDiscussions()}
disabled={loadingDiscussions}
title={loadingDiscussions ? 'Refreshing...' : 'Refresh discussions'}
aria-label={loadingDiscussions ? 'Refreshing...' : 'Refresh discussions'}
>
<img src="/icons/refresh-cw.svg" alt="" class="icon" />
</button>
{#if userPubkey}
<button
class="btn btn-primary icon-button"
onclick={() => showCreateThreadDialog = true}
disabled={creatingThread}
title={creatingThread ? 'Creating...' : 'New Discussion Thread'}
aria-label={creatingThread ? 'Creating...' : 'New Discussion Thread'}
>
<img src="/icons/message-circle.svg" alt="" class="icon" />
</button>
{/if}
</div>
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length === 0}
{#if discussions.length === 0}
<div class="empty-state">
<p>No discussions found. {#if userPubkey}Create a new discussion thread to get started!{:else}Log in to create a discussion thread.{/if}</p>
</div>
{:else}
{#each discussions as discussion}
{:else if selectedDiscussion}
{#each discussions.filter(d => d.id === selectedDiscussion) as discussion}
{@const isExpanded = discussion.type === 'thread' && expandedThreads.has(discussion.id)}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
<div class="discussion-item">
<div class="discussion-header">
<div class="discussion-title-row">
{#if discussion.type === 'thread'}
<button
class="expand-button"
onclick={() => toggleThread(discussion.id)}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Collapse thread' : 'Expand thread'}
>
{isExpanded ? '▼' : '▶'}
</button>
{/if}
<div class="discussion-detail">
<div class="discussion-header-detail">
<h3>{discussion.title}</h3>
</div>
<div class="discussion-meta">
<div class="discussion-meta-detail">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
@ -4502,14 +4631,15 @@ @@ -4502,14 +4631,15 @@
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
{#if discussion.type === 'thread' && userPubkey}
<button
class="btn btn-small"
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = null;
showReplyDialog = true;
}}
title="Reply to thread"
>
Reply
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
@ -4519,7 +4649,7 @@ @@ -4519,7 +4649,7 @@
<p>{discussion.content}</p>
</div>
{/if}
{#if discussion.type === 'thread' && isExpanded && hasComments}
{#if discussion.type === 'thread' && hasComments}
{@const totalReplies = countAllReplies(discussion.comments)}
<div class="comments-section">
<h4>Replies ({totalReplies})</h4>
@ -4531,14 +4661,15 @@ @@ -4531,14 +4661,15 @@
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
Reply
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
@ -4587,14 +4718,15 @@ @@ -4587,14 +4718,15 @@
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
Reply
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
@ -4611,14 +4743,15 @@ @@ -4611,14 +4743,15 @@
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
Reply
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
@ -4648,14 +4781,15 @@ @@ -4648,14 +4781,15 @@
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
class="create-reply-button"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
}}
title="Reply to comment"
>
Reply
<img src="/icons/plus.svg" alt="Reply" class="icon" />
</button>
{/if}
</div>
@ -4694,68 +4828,36 @@ @@ -4694,68 +4828,36 @@
</div>
</div>
{/if}
{#if comment.replies && comment.replies.length > 0}
<div class="nested-replies">
{#each comment.replies as reply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author };
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{reply.content}</p>
{/each}
</div>
{#if reply.replies && reply.replies.length > 0}
<div class="nested-replies">
{#each reply.replies as nestedReply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} />
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author };
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{nestedReply.content}</p>
</div>
</div>
{/each}
{:else}
<div class="empty-state">
<p>Select a discussion from the sidebar to view it</p>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{#if activeTab === 'docs'}
<div class="docs-content">
{#if loadingDocs}
<div class="loading">Loading documentation...</div>
{:else if documentationHtml}
<div class="documentation-body">
{@html documentationHtml}
</div>
{/each}
{:else if documentationContent === null}
<div class="empty-state">
<p>No documentation found for this repository.</p>
</div>
{/if}
{:else}
<div class="empty-state">
<p>Documentation content is empty.</p>
</div>
{/each}
{/if}
</div>
{/if}

4
static/icons/plus.svg

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

Loading…
Cancel
Save