Browse Source

refactor 8

Nostr-Signature: 716cfe7b5d8b788e6e24092a6ad7e92de0b3d383c43a343f3c5bec4d2bbdd4b9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc e80ed3d9d471bd6907e212edfd7cf3f6039fa80e4434c35f0591729515eaa98c7a8ac54f2ac6f7a2fefb7846de0e2f0a120543a0dbe862c47c7710a653189b0c
main
Silberengel 2 weeks ago
parent
commit
e1a9f61121
  1. 1
      nostr/commit-signatures.jsonl
  2. 1
      src/app.css
  3. 79
      src/lib/components/PRDetail.svelte
  4. 25
      src/routes/+layout.svelte
  5. 33
      src/routes/api-docs/+page.svelte
  6. 6
      src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts
  7. 21
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  8. 248
      src/routes/repos/+page.svelte
  9. 917
      src/routes/repos/[npub]/[repo]/+page.svelte
  10. 20
      src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte
  11. 139
      src/routes/repos/[npub]/[repo]/components/DocsTab.svelte
  12. 162
      src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte
  13. 230
      src/routes/repos/[npub]/[repo]/components/FilesTab.svelte
  14. 107
      src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte
  15. 116
      src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte
  16. 48
      src/routes/repos/[npub]/[repo]/components/PRsTab.svelte
  17. 78
      src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte
  18. 38
      src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte
  19. 109
      src/routes/repos/[npub]/[repo]/components/TabLayout.svelte
  20. 6
      src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte
  21. 4
      src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte
  22. 77
      src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts
  23. 122
      src/routes/repos/[npub]/[repo]/services/auth-operations.ts
  24. 42
      src/routes/repos/[npub]/[repo]/services/patch-handlers.ts
  25. 264
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts
  26. 6
      src/routes/repos/[npub]/[repo]/services/tag-operations.ts
  27. 2
      src/routes/repos/[npub]/[repo]/stores/repo-state.ts
  28. 87
      src/routes/repos/[npub]/[repo]/utils/content-renderer.ts
  29. 30
      src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts
  30. 134
      src/routes/repos/[npub]/[repo]/utils/file-handlers.ts
  31. 78
      src/routes/repos/[npub]/[repo]/utils/repo-handlers.ts

1
nostr/commit-signatures.jsonl

@ -100,3 +100,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772110337,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 4"]],"content":"Signed commit: refactor 4","id":"d330d1a096e5f3951e8b2a66160a23c5ac28aa94313ecd0948c7e50baa60bdbb","sig":"febf4088cca3f7223f55ab300ed7fdb7b333c03d2534b05721dfaf9d9284f4599b385ba54379890fa6b846aed02d656a5e45429a6dd571dddbb997be6d8159b2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772110337,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 4"]],"content":"Signed commit: refactor 4","id":"d330d1a096e5f3951e8b2a66160a23c5ac28aa94313ecd0948c7e50baa60bdbb","sig":"febf4088cca3f7223f55ab300ed7fdb7b333c03d2534b05721dfaf9d9284f4599b385ba54379890fa6b846aed02d656a5e45429a6dd571dddbb997be6d8159b2"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772111536,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 5"]],"content":"Signed commit: refactor 5","id":"47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16","sig":"2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772111536,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 5"]],"content":"Signed commit: refactor 5","id":"47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16","sig":"2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1c"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112054,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 6"]],"content":"Signed commit: refactor 6","id":"cd9b7e015ee3bd6a4c4ab7d54d90ab411a08c29f249158c4cdea2b12996b6b44","sig":"2a8fd0a3718169df1517c0b939ec9ea9793da4dc07b20d9366f09fe70b5268e94451916f975e2cea1a3741419440189d31f844e79863e84de00c0be7c449e92a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112054,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 6"]],"content":"Signed commit: refactor 6","id":"cd9b7e015ee3bd6a4c4ab7d54d90ab411a08c29f249158c4cdea2b12996b6b44","sig":"2a8fd0a3718169df1517c0b939ec9ea9793da4dc07b20d9366f09fe70b5268e94451916f975e2cea1a3741419440189d31f844e79863e84de00c0be7c449e92a"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112920,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 7"]],"content":"Signed commit: refactor 7","id":"80f54ac61390cfbc8f2496a162d7065c447033a2e085ab5886c8138e337e93f9","sig":"f64bd2c965eff3534ad68e245651c189dc925d9613dd85557d88af8c692361ade13ccdd9deb88ae07eb227aa002af99d525a7fdf6f29eca854b5a02882ef226f"}

1
src/app.css

@ -1221,7 +1221,6 @@ button.theme-option.active img.theme-icon-option,
.repo-badge:hover { .repo-badge:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-color: var(--accent); border-color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 2px 4px var(--shadow-color-light); box-shadow: 0 2px 4px var(--shadow-color-light);
font-size: 0.9rem; /* Preserve font size on hover */ font-size: 0.9rem; /* Preserve font size on hover */
color: var(--text-primary); color: var(--text-primary);

79
src/lib/components/PRDetail.svelte

@ -10,7 +10,16 @@
import { KIND } from '../types/nostr.js'; import { KIND } from '../types/nostr.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import CommentRenderer from './CommentRenderer.svelte'; import CommentRenderer from './CommentRenderer.svelte';
import type { Comment } from './CommentRenderer.svelte'; // Define Comment type locally to match CommentRenderer's export
type Comment = {
id: string;
content: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
replies?: Comment[];
};
import { loadNostrLinks } from '../utils/nostr-links.js'; import { loadNostrLinks } from '../utils/nostr-links.js';
import type { NostrEvent } from '../types/nostr.js'; import type { NostrEvent } from '../types/nostr.js';
@ -129,12 +138,14 @@
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles); await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
} }
for (const highlight of highlights) { for (const highlight of highlights) {
if (highlight.comment) { if (highlight.comment && typeof highlight.comment === 'string') {
await loadNostrLinks(highlight.comment, nostrClient, nostrLinkEvents, nostrLinkProfiles); await loadNostrLinks(highlight.comment, nostrClient, nostrLinkEvents, nostrLinkProfiles);
} }
if (highlight.comments) { if (highlight.comments) {
for (const comment of highlight.comments) { for (const comment of highlight.comments) {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles); if (comment.content && typeof comment.content === 'string') {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
} }
} }
} }
@ -734,7 +745,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.add-comment-btn, .reply-btn { .add-comment-btn {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
background: var(--button-primary); background: var(--button-primary);
color: var(--accent-text, #ffffff); color: var(--accent-text, #ffffff);
@ -746,11 +757,11 @@
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.add-comment-btn:hover, .reply-btn:hover { .add-comment-btn:hover {
background: var(--button-primary-hover); background: var(--button-primary-hover);
} }
.highlight-item, .comment-item { .highlight-item {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 1rem; padding: 1rem;
background: var(--bg-secondary); background: var(--bg-secondary);
@ -758,22 +769,7 @@
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
} }
.comment-item.nested { .highlight-header {
margin-left: 2rem;
margin-top: 0.75rem;
border-left-color: var(--success-text);
background: var(--bg-secondary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
}
.comment-item.nested .comment-content {
border-left-color: var(--success-text);
background: var(--card-bg);
margin: 0.5rem 0;
padding: 0.875rem 1rem;
}
.highlight-header, .comment-header {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@ -781,7 +777,7 @@
color: var(--text-muted); color: var(--text-muted);
} }
.highlight-author, .comment-author { .highlight-author {
font-weight: bold; font-weight: bold;
color: var(--text-primary); color: var(--text-primary);
} }
@ -844,17 +840,6 @@
filter: brightness(0) saturate(100%) invert(1); filter: brightness(0) saturate(100%) invert(1);
} }
.comment-content {
margin: 0.75rem 0;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border-radius: 6px;
border-left: 4px solid var(--accent);
color: var(--text-primary);
font-size: 1rem;
line-height: 1.6;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.highlight-comments { .highlight-comments {
margin-top: 1rem; margin-top: 1rem;
@ -1004,32 +989,6 @@
background: var(--success-hover, #218838); background: var(--success-hover, #218838);
} }
.close-btn {
background: var(--error-text, #dc3545);
color: white;
}
.close-btn:hover:not(:disabled) {
background: var(--error-hover, #c82333);
}
.reopen-btn {
background: var(--accent, #007bff);
color: white;
}
.reopen-btn:hover:not(:disabled) {
background: var(--accent-hover, #0056b3);
}
.draft-btn {
background: var(--bg-tertiary, #6c757d);
color: white;
}
.draft-btn:hover:not(:disabled) {
background: var(--bg-secondary, #5a6268);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.pr-actions { .pr-actions {

25
src/routes/+layout.svelte

@ -290,7 +290,7 @@
} }
async function checkPendingTransfers(userPubkeyHex: string) { async function checkPendingTransfers(userPubkeyHex: string) {
if (!isMounted) return; if (!isMounted || typeof window === 'undefined') return;
try { try {
// Add timeout to prevent hanging // Add timeout to prevent hanging
@ -313,14 +313,29 @@
pendingTransfers = data.pendingTransfers.filter( pendingTransfers = data.pendingTransfers.filter(
(t: { eventId: string }) => !dismissedTransfers.has(t.eventId) (t: { eventId: string }) => !dismissedTransfers.has(t.eventId)
); );
} else if (isMounted) {
// Clear transfers if response is ok but no transfers
pendingTransfers = [];
} }
} else if (response.status === 404 && isMounted) {
// Endpoint doesn't exist - silently ignore
pendingTransfers = [];
} }
} catch (err) { } catch (err) {
// Only log if it's not an abort (timeout) and component is still mounted // Only log if it's not an abort (timeout), not a network error, and component is still mounted
if (isMounted && err instanceof Error && err.name !== 'AbortError') { if (isMounted && err instanceof Error) {
console.error('Failed to check for pending transfers:', err); // Ignore expected errors
if (err.name === 'AbortError') {
// Timeout - silently ignore
return;
}
if (err.name === 'TypeError' && err.message.includes('NetworkError')) {
// Network error (server not available, CORS, etc.) - silently ignore
return;
}
// Log other unexpected errors
console.warn('Failed to check for pending transfers:', err);
} }
// Silently ignore timeouts - they're expected if the server is slow
} }
} }

33
src/routes/api-docs/+page.svelte

@ -1,28 +1,47 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores';
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
const browserExample = `// Get user's pubkey (hex format) from NIP-07 extension // Get the actual domain from page data or current URL
const getApiBaseUrl = () => {
const gitDomain = $page.data?.gitDomain;
const currentHost = $page.url?.host;
// Use gitDomain if available and not localhost, otherwise use current host
if (gitDomain && !gitDomain.startsWith('localhost') && !gitDomain.startsWith('127.0.0.1')) {
return `https://${gitDomain}`;
} else if (currentHost) {
const protocol = currentHost.startsWith('localhost') || currentHost.startsWith('127.0.0.1') ? 'http' : 'https';
return `${protocol}://${currentHost}`;
}
// Fallback
return typeof window !== 'undefined' ? window.location.origin : 'https://gitrepublic.imwald.eu';
};
const apiBaseUrl = $derived(getApiBaseUrl());
const browserExample = $derived(`// Get user's pubkey (hex format) from NIP-07 extension
const userPubkey = await window.nostr.getPublicKey(); const userPubkey = await window.nostr.getPublicKey();
// Convert npub to hex if needed // Convert npub to hex if needed
const userPubkeyHex = /* convert npub to hex */; const userPubkeyHex = /* convert npub to hex */;
// Make API request // Make API request
const response = await fetch('/api/repos/list', { const response = await fetch('${apiBaseUrl}/api/repos/list', {
headers: { headers: {
'X-User-Pubkey': userPubkeyHex 'X-User-Pubkey': userPubkeyHex
} }
}); });
const data = await response.json();`; const data = await response.json();`);
const nip98Example = `// Create NIP-98 auth event const nip98Example = $derived(`// Create NIP-98 auth event
import { finalizeEvent } from 'nostr-tools'; import { finalizeEvent } from 'nostr-tools';
const authEvent = finalizeEvent({ const authEvent = finalizeEvent({
kind: 27235, // NIP-98 auth kind kind: 27235, // NIP-98 auth kind
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [ tags: [
['u', 'https://gitrepublic.com/api/repos/list'], ['u', '${apiBaseUrl}/api/repos/list'],
['method', 'GET'] ['method', 'GET']
], ],
content: '' content: ''
@ -32,11 +51,11 @@ const authEvent = finalizeEvent({
const base64Event = btoa(JSON.stringify(authEvent)); const base64Event = btoa(JSON.stringify(authEvent));
// Make API request // Make API request
const response = await fetch('https://gitrepublic.com/api/repos/list', { const response = await fetch('${apiBaseUrl}/api/repos/list', {
headers: { headers: {
'Authorization': \`Nostr \${base64Event}\` 'Authorization': \`Nostr \${base64Event}\`
} }
});`; });`);
onMount(() => { onMount(() => {
// Load Swagger UI from local static files // Load Swagger UI from local static files

6
src/routes/api/repos/[npub]/[repo]/commits/[hash]/verify/+server.ts

@ -6,7 +6,7 @@ import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError } from '$lib/utils/error-handler.js'; import { handleApiError } from '$lib/utils/error-handler.js';
import { nostrClient, fileManager } from '$lib/services/service-registry.js'; import { nostrClient, fileManager } from '$lib/services/service-registry.js';
import { verifyCommitFromMessage } from '$lib/services/git/commit-signer.js'; import { verifyCommitFromMessage } from '$lib/services/git/commit-signer.js';
@ -18,8 +18,8 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
: '/repos'; : '/repos';
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { async (context: RepoRequestContext, event: RequestEvent) => {
const { hash } = context.params as { hash: string }; const { hash } = event.params;
if (!hash) { if (!hash) {
throw handleApiError(new Error('Missing commit hash'), { operation: 'verifyCommit', npub: context.npub, repo: context.repo }, 'Missing commit hash'); throw handleApiError(new Error('Missing commit hash'), { operation: 'verifyCommit', npub: context.npub, repo: context.repo }, 'Missing commit hash');

21
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -290,14 +290,29 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
// Log the actual error for debugging // Log the actual error for debugging
logger.error({ error: err, npub: context.npub, repo: context.repo }, '[Tree] Error listing files'); logger.error({ error: err, npub: context.npub, repo: context.repo, path: context.path }, '[Tree] Error listing files');
// Check if it's a "not found" error
if (err instanceof Error && err.message.includes('not found')) { // For optional paths (like "docs"), return empty array instead of 404
// This allows components to gracefully handle missing directories
const optionalPaths = ['docs'];
if (context.path && optionalPaths.includes(context.path.toLowerCase())) {
logger.debug({ npub: context.npub, repo: context.repo, path: context.path }, '[Tree] Optional path not found, returning empty array');
return json([]);
}
// Check if it's a "not found" error for the repo itself
if (err instanceof Error && (err.message.includes('Repository not found') || err.message.includes('not cloned'))) {
throw handleNotFoundError( throw handleNotFoundError(
err.message, err.message,
{ operation: 'listFiles', npub: context.npub, repo: context.repo } { operation: 'listFiles', npub: context.npub, repo: context.repo }
); );
} }
// For other errors with optional paths, return empty array
if (context.path && optionalPaths.includes(context.path.toLowerCase())) {
return json([]);
}
// Otherwise, it's a server error // Otherwise, it's a server error
throw handleApiError( throw handleApiError(
err, err,

248
src/routes/repos/+page.svelte

@ -637,38 +637,57 @@
<div class="container"> <div class="container">
<main> <main>
{#if userPubkey && myRepos.length > 0} {#if userPubkey && myRepos.length > 0}
<div class="my-repos-section"> <!-- My Repositories Section -->
<h3>My Repositories</h3> <div class="repo-section">
<div class="my-repos-badges"> <div class="section-header">
<h3>My Repositories</h3>
<span class="section-badge">{myRepos.length}</span>
</div>
<div class="repos-list">
{#each myRepos as item} {#each myRepos as item}
{@const repo = item.event} {@const repo = item.event}
{@const repoImage = getRepoImage(repo)} {@const repoImage = getRepoImage(repo)}
{@const isTransferred = item.transferred || false} {@const isTransferred = item.transferred || false}
<a <div class="repo-card repo-card-my" class:transferred={isTransferred}>
href="/repos/{item.npub}/{item.repoName}" <div class="repo-card-content">
class="repo-badge" <div class="repo-header">
class:transferred={isTransferred} <div class="repo-header-text">
title={isTransferred ? 'Transferred to another owner' : ''} <div class="repo-title-row">
> {#if repoImage}
{#if repoImage} <img src={repoImage} alt="Repository" class="repo-avatar" />
<img src={repoImage} alt={getRepoName(repo)} class="repo-badge-image" /> {/if}
{:else} <h3>{getRepoName(repo)}</h3>
<img src="/icons/package.svg" alt="Repository" class="repo-badge-icon" /> {#if isTransferred}
{/if} <span class="transferred-badge" title="Transferred to another owner"></span>
<span class="repo-badge-name">{getRepoName(repo)}</span> {/if}
{#if isTransferred} </div>
<span class="transferred-badge" title="Transferred"></span> {#if getRepoDescription(repo)}
{/if} <p class="description">{getRepoDescription(repo)}</p>
</a> {/if}
</div>
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository">
<img src="/icons/arrow-right.svg" alt="View" />
</a>
</div>
<div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
{#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span>
{/if}
</div>
</div>
</div>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Most Favorited Repositories --> <!-- Most Favorited Repositories -->
<div class="most-favorited-section"> <div class="repo-section">
<div class="section-header"> <div class="section-header">
<h3>Most Favorited Repositories</h3> <h3>Most Favorited Repositories</h3>
<span class="section-badge">{mostFavoritedRepos.length}</span>
{#if loadingMostFavorited} {#if loadingMostFavorited}
<span class="loading-indicator">Loading...</span> <span class="loading-indicator">Loading...</span>
{/if} {/if}
@ -678,24 +697,38 @@
{:else if mostFavoritedRepos.length === 0} {:else if mostFavoritedRepos.length === 0}
<div class="empty">No favorited repositories found.</div> <div class="empty">No favorited repositories found.</div>
{:else} {:else}
<div class="most-favorited-list"> <div class="repos-list">
{#each mostFavoritedRepos as item} {#each mostFavoritedRepos as item}
{@const repo = item.event} {@const repo = item.event}
{@const repoImage = getRepoImage(repo)} {@const repoImage = getRepoImage(repo)}
<a href="/repos/{item.npub}/{item.repoName}" class="most-favorited-item"> <div class="repo-card repo-card-favorited">
{#if repoImage} <div class="repo-card-content">
<img src={repoImage} alt={getRepoName(repo)} class="most-favorited-image" /> <div class="repo-header">
{:else} <div class="repo-header-text">
<img src="/icons/package.svg" alt="Repository" class="most-favorited-icon" /> <div class="repo-title-row">
{/if} {#if repoImage}
<div class="most-favorited-info"> <img src={repoImage} alt="Repository" class="repo-avatar" />
<h4>{getRepoName(repo)}</h4> {/if}
{#if getRepoDescription(repo)} <h3>{getRepoName(repo)}</h3>
<p class="most-favorited-description">{getRepoDescription(repo)}</p> </div>
{/if} {#if getRepoDescription(repo)}
<span class="most-favorited-count">{item.favoriteCount} {item.favoriteCount === 1 ? 'favorite' : 'favorites'}</span> <p class="description">{getRepoDescription(repo)}</p>
{/if}
</div>
<a href="/repos/{item.npub}/{item.repoName}" class="view-button" title="View repository">
<img src="/icons/arrow-right.svg" alt="View" />
</a>
</div>
<div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
<span class="favorite-count">{item.favoriteCount} {item.favoriteCount === 1 ? 'favorite' : 'favorites'}</span>
{#if getForkCount(repo) > 0}
{@const forkCount = getForkCount(repo)}
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span>
{/if}
</div>
</div> </div>
</a> </div>
{/each} {/each}
</div> </div>
{#if mostFavoritedCache && mostFavoritedCache.data.length > 10} {#if mostFavoritedCache && mostFavoritedCache.data.length > 10}
@ -745,13 +778,7 @@
{#each registeredRepos as item} {#each registeredRepos as item}
{@const repo = item.event} {@const repo = item.event}
{@const repoImage = getRepoImage(repo)} {@const repoImage = getRepoImage(repo)}
{@const repoBanner = getRepoBanner(repo)}
<div class="repo-card repo-card-registered"> <div class="repo-card repo-card-registered">
{#if repoBanner}
<div class="repo-card-banner">
<img src={repoBanner} alt="Banner" />
</div>
{/if}
<div class="repo-card-content"> <div class="repo-card-content">
<div class="repo-header"> <div class="repo-header">
<div class="repo-header-text"> <div class="repo-header-text">
@ -799,14 +826,8 @@
{#each localRepos as item} {#each localRepos as item}
{@const repo = item.announcement} {@const repo = item.announcement}
{@const repoImage = repo ? getRepoImage(repo) : null} {@const repoImage = repo ? getRepoImage(repo) : null}
{@const repoBanner = repo ? getRepoBanner(repo) : null}
{@const canDelete = isOwner(item.npub, item.repoName)} {@const canDelete = isOwner(item.npub, item.repoName)}
<div class="repo-card repo-card-local"> <div class="repo-card repo-card-local">
{#if repoBanner}
<div class="repo-card-banner">
<img src={repoBanner} alt="Banner" />
</div>
{/if}
<div class="repo-card-content"> <div class="repo-card-content">
<div class="repo-header"> <div class="repo-header">
<div class="repo-header-text"> <div class="repo-header-text">
@ -859,105 +880,11 @@
</div> </div>
<style> <style>
.most-favorited-section {
margin: 2rem 0;
padding: 1.5rem;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.5rem;
}
.most-favorited-section .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.most-favorited-section .section-header h3 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary, #1a1a1a);
}
.loading-indicator { .loading-indicator {
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
font-size: 0.875rem; font-size: 0.875rem;
} }
.most-favorited-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.most-favorited-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.most-favorited-item:hover {
background: var(--bg-tertiary, #eeeeee);
border-color: var(--accent, #007bff);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.most-favorited-image,
.most-favorited-icon {
width: 48px;
height: 48px;
border-radius: 0.25rem;
object-fit: cover;
flex-shrink: 0;
}
.most-favorited-icon {
padding: 8px;
background: var(--bg-tertiary, #eeeeee);
}
.most-favorited-info {
flex: 1;
min-width: 0;
}
.most-favorited-info h4 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
color: var(--text-primary, #1a1a1a);
}
.most-favorited-description {
margin: 0 0 0.5rem 0;
color: var(--text-secondary, #666);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.most-favorited-count {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--accent, #007bff);
color: var(--accent-text, #ffffff);
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -981,7 +908,6 @@
.pagination button:hover:not(:disabled) { .pagination button:hover:not(:disabled) {
background: var(--accent-hover, #0056b3); background: var(--accent-hover, #0056b3);
transform: translateY(-1px);
} }
.pagination button:disabled { .pagination button:disabled {
@ -1006,27 +932,15 @@
border: 1px solid var(--border-color, #e0e0e0); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
transition: all 0.2s ease; transition: box-shadow 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 160px;
height: 100%;
} }
.repo-card:hover { .repo-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.repo-card-banner {
width: 100%;
height: 120px;
overflow: hidden;
background: var(--bg-secondary, #f5f5f5);
}
.repo-card-banner img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.repo-card-content { .repo-card-content {
@ -1034,6 +948,7 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.repo-header { .repo-header {
@ -1103,7 +1018,6 @@
.view-button:hover { .view-button:hover {
background: var(--accent-hover, #0056b3); background: var(--accent-hover, #0056b3);
transform: translateX(2px);
} }
.view-button img { .view-button img {
@ -1154,6 +1068,26 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
} }
.favorite-count {
color: var(--text-secondary, #666);
font-weight: 500;
}
.transferred-badge {
font-size: 0.875rem;
color: var(--text-secondary, #666);
margin-left: 0.5rem;
opacity: 0.7;
}
.repo-card.transferred {
opacity: 0.7;
}
.repo-card.transferred:hover {
opacity: 0.9;
}
.repo-section { .repo-section {
margin: 2rem 0; margin: 2rem 0;
} }

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

File diff suppressed because it is too large Load Diff

20
src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte

@ -29,9 +29,20 @@
repo: string; repo: string;
repoAnnouncement?: NostrEvent; repoAnnouncement?: NostrEvent;
userPubkey?: string | null; userPubkey?: string | null;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { npub, repo, repoAnnouncement, userPubkey }: Props = $props(); let {
npub,
repo,
repoAnnouncement,
userPubkey,
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props();
let discussions = $state<Discussion[]>([]); let discussions = $state<Discussion[]>([]);
let loadingDiscussions = $state(false); let loadingDiscussions = $state(false);
@ -326,7 +337,12 @@
} }
</script> </script>
<TabLayout> <TabLayout
{activeTab}
{tabs}
{onTabChange}
title="Discussions"
>
{#snippet leftPane()} {#snippet leftPane()}
<div class="discussions-sidebar"> <div class="discussions-sidebar">
<div class="discussions-header"> <div class="discussions-header">

139
src/routes/repos/[npub]/[repo]/components/DocsTab.svelte

@ -17,13 +17,19 @@
repo?: string; repo?: string;
currentBranch?: string | null; currentBranch?: string | null;
relays?: string[]; relays?: string[];
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
npub = '', npub = '',
repo = '', repo = '',
currentBranch = null, currentBranch = null,
relays = DEFAULT_NOSTR_RELAYS relays = DEFAULT_NOSTR_RELAYS,
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
let documentationContent = $state<string | null>(null); let documentationContent = $state<string | null>(null);
@ -50,15 +56,47 @@
try { try {
logger.operation('Loading documentation', { npub, repo, branch: currentBranch }); logger.operation('Loading documentation', { npub, repo, branch: currentBranch });
// Try to find documentation files // Try README first (faster, always available if repo has content)
const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`); const readmePromise = (async () => {
try {
const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`);
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json();
if (readmeData.content) {
return {
content: readmeData.content,
kind: readmeData.type || 'markdown',
path: 'README.md'
};
}
}
} catch (readmeErr) {
logger.debug({ error: readmeErr, npub, repo }, 'No README found');
}
return null;
})();
if (response.ok) { // Try docs folder in parallel
const data = await response.json(); const docsPromise = (async () => {
docFiles = data.files || []; try {
const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`);
// Look for README or index files first if (response.ok) {
const readmeFile = docFiles.find(f => const data = await response.json();
return Array.isArray(data) ? data : (data.files || []);
}
} catch (err) {
logger.debug({ error: err, npub, repo }, 'Docs folder not found');
}
return [];
})();
// Wait for both, prefer docs folder if it has files
const [readmeResult, docsFiles] = await Promise.all([readmePromise, docsPromise]);
docFiles = docsFiles;
if (docsFiles.length > 0) {
// Look for README or index files first in docs folder
const readmeFile = docsFiles.find((f: { name: string; path: string }) =>
f.name.toLowerCase() === 'readme.md' || f.name.toLowerCase() === 'readme.md' ||
f.name.toLowerCase() === 'readme.adoc' || f.name.toLowerCase() === 'readme.adoc' ||
f.name.toLowerCase() === 'index.md' f.name.toLowerCase() === 'index.md'
@ -66,26 +104,21 @@
if (readmeFile) { if (readmeFile) {
await loadDocFile(readmeFile.path); await loadDocFile(readmeFile.path);
} else if (docFiles.length > 0) { } else {
// Load first file // Load first file from docs folder
await loadDocFile(docFiles[0].path); await loadDocFile(docsFiles[0].path);
}
} else {
// Try to load README from root
try {
const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`);
if (readmeResponse.ok) {
const readmeData = await readmeResponse.json();
documentationContent = readmeData.content || '';
documentationKind = readmeData.type || 'markdown';
}
} catch {
// No README found
} }
} else if (readmeResult) {
// No docs folder, use README from root
documentationContent = readmeResult.content;
documentationKind = readmeResult.kind as 'markdown' | 'asciidoc';
selectedDoc = readmeResult.path;
} }
// Check for kind 30040 publication index // Check for kind 30040 publication index (only if no content found yet)
await checkForPublicationIndex(); if (!documentationContent && !indexEvent) {
await checkForPublicationIndex();
}
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load documentation'; error = err instanceof Error ? err.message : 'Failed to load documentation';
@ -154,11 +187,22 @@
} }
</script> </script>
<TabLayout {loading} {error}> <TabLayout
{loading}
{error}
{activeTab}
{tabs}
{onTabChange}
title="Documentation"
>
{#snippet leftPane()} {#snippet leftPane()}
<div class="docs-sidebar"> <div class="docs-sidebar">
<h3>Documentation</h3> <h3>Documentation</h3>
{#if docFiles.length > 0} {#if loading}
<div class="loading">Loading documentation...</div>
{:else if error}
<div class="error">{error}</div>
{:else if docFiles.length > 0}
<ul class="doc-list"> <ul class="doc-list">
{#each docFiles as file} {#each docFiles as file}
<li> <li>
@ -171,12 +215,24 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if documentationContent}
<div class="empty-sidebar">
<p>No custom documentation found. Displaying the ReadMe, instead.</p>
</div>
{:else}
<div class="empty-sidebar">
<p>No documentation files found</p>
</div>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
{#snippet rightPanel()} {#snippet rightPanel()}
{#if documentationKind === '30040' && indexEvent} {#if loading}
<div class="loading">Loading documentation...</div>
{:else if error}
<div class="error">{error}</div>
{:else if documentationKind === '30040' && indexEvent}
<DocsViewer <DocsViewer
contentType="30040" contentType="30040"
{indexEvent} {indexEvent}
@ -187,6 +243,10 @@
<DocsViewer <DocsViewer
content={documentationContent} content={documentationContent}
contentType={documentationKind || 'text'} contentType={documentationKind || 'text'}
npub={npub}
repo={repo}
currentBranch={currentBranch || 'HEAD'}
filePath={selectedDoc || 'README.md'}
/> />
{:else} {:else}
<div class="empty-docs"> <div class="empty-docs">
@ -246,4 +306,25 @@
font-size: 0.9rem; font-size: 0.9rem;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.empty-sidebar {
padding: 1rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
.loading {
padding: 1rem;
text-align: center;
color: var(--text-secondary);
}
.error {
padding: 1rem;
background: var(--error-bg, #ffebee);
color: var(--error-color, #c62828);
border-radius: 4px;
margin: 1rem;
}
</style> </style>

162
src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte

@ -9,19 +9,78 @@
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { renderContent } from '../utils/content-renderer.js';
// Rewrite image paths in HTML to point to repository file API
function rewriteImagePaths(html: string, filePath: string, npub: string, repo: string, branch: string): string {
if (!html || !filePath) return html;
// Get the directory of the current file
const fileDir = filePath.includes('/')
? filePath.substring(0, filePath.lastIndexOf('/'))
: '';
// Rewrite relative image paths
return html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => {
// Skip if it's already an absolute URL (http/https/data) or already an API URL
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) {
return match;
}
// Resolve relative path
let imagePath: string;
if (src.startsWith('/')) {
// Absolute path from repo root
imagePath = src.substring(1);
} else if (src.startsWith('./')) {
// Relative to current file directory
imagePath = fileDir ? `${fileDir}/${src.substring(2)}` : src.substring(2);
} else {
// Relative to current file directory
imagePath = fileDir ? `${fileDir}/${src}` : src;
}
// Normalize path (remove .. and .)
const pathParts = imagePath.split('/').filter(p => p !== '.' && p !== '');
const normalizedPath: string[] = [];
for (const part of pathParts) {
if (part === '..') {
normalizedPath.pop();
} else {
normalizedPath.push(part);
}
}
imagePath = normalizedPath.join('/');
// Build API URL
const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`;
return `<img${before} src="${apiUrl}"${after}>`;
});
}
interface Props { interface Props {
content?: string; content?: string;
contentType?: 'markdown' | 'asciidoc' | 'text' | '30040'; contentType?: 'markdown' | 'asciidoc' | 'text' | '30040';
indexEvent?: NostrEvent | null; indexEvent?: NostrEvent | null;
relays?: string[]; relays?: string[];
onItemClick?: ((item: any) => void) | null;
npub?: string;
repo?: string;
currentBranch?: string;
filePath?: string | null;
} }
let { let {
content = '', content = '',
contentType = 'text', contentType = 'text',
indexEvent = null, indexEvent = null,
relays = [] relays = [],
onItemClick = null,
npub = '',
repo = '',
currentBranch = 'HEAD',
filePath = null
}: Props = $props(); }: Props = $props();
let renderedContent = $state(''); let renderedContent = $state('');
@ -35,66 +94,29 @@
} }
if (content) { if (content) {
renderContent(); doRenderContent();
} }
}); });
async function renderContent() { async function doRenderContent() {
loading = true; loading = true;
error = null; error = null;
try { try {
logger.operation('Rendering content', { contentType, length: content.length }); logger.operation('Rendering content', { contentType, length: content.length });
if (contentType === 'markdown') { // Use the shared content renderer utility
const MarkdownIt = (await import('markdown-it')).default; // contentType '30040' is handled separately by PublicationIndexViewer
const hljsModule = await import('highlight.js'); if (contentType === '30040') {
const hljs = hljsModule.default || hljsModule; // Should not reach here, but handle gracefully
renderedContent = '';
const md = new MarkdownIt({
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang }).value +
'</code></pre>';
} catch (err) {
// Fallback to escaped HTML
}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
renderedContent = md.render(content);
// Add IDs to headings for anchor links
renderedContent = renderedContent.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => {
const textContent = text.replace(/<[^>]*>/g, '').trim();
const slug = textContent
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `<h${level} id="${slug}">${text}</h${level}>`;
});
} else if (contentType === 'asciidoc') {
const asciidoctor = (await import('asciidoctor')).default();
renderedContent = asciidoctor.convert(content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
});
} else { } else {
// Plain text - escape HTML renderedContent = await renderContent(content, contentType as 'markdown' | 'asciidoc' | 'text');
renderedContent = content
.replace(/&/g, '&amp;') // Rewrite image paths to use API endpoint
.replace(/</g, '&lt;') if (npub && repo && filePath) {
.replace(/>/g, '&gt;') renderedContent = rewriteImagePaths(renderedContent, filePath, npub, repo, currentBranch);
.replace(/\n/g, '<br>'); }
} }
logger.operation('Content rendered', { contentType }); logger.operation('Content rendered', { contentType });
@ -107,10 +129,14 @@
} }
function handleItemClick(item: any) { function handleItemClick(item: any) {
logger.debug({ item }, 'Publication index item clicked'); if (onItemClick) {
// Could navigate to item URL or emit event onItemClick(item);
if (item.url) { } else {
window.open(item.url, '_blank'); logger.debug({ item }, 'Publication index item clicked');
// Could navigate to item URL or emit event
if (item.url) {
window.open(item.url, '_blank');
}
} }
} }
</script> </script>
@ -137,8 +163,9 @@
<style> <style>
.docs-viewer { .docs-viewer {
padding: 1rem; width: 100%;
max-width: 100%; max-width: 100%;
box-sizing: border-box;
} }
.loading, .error, .empty { .loading, .error, .empty {
@ -152,7 +179,18 @@
} }
.rendered-content { .rendered-content {
width: 100%;
max-width: 100%;
box-sizing: border-box;
line-height: 1.6; line-height: 1.6;
overflow-wrap: break-word;
word-wrap: break-word;
}
.rendered-content :global(img) {
max-width: 100%;
height: auto;
display: block;
} }
.rendered-content :global(h1), .rendered-content :global(h1),
@ -198,6 +236,8 @@
border-radius: 4px; border-radius: 4px;
overflow-x: auto; overflow-x: auto;
margin: 1rem 0; margin: 1rem 0;
max-width: 100%;
box-sizing: border-box;
} }
.rendered-content :global(pre code) { .rendered-content :global(pre code) {
@ -233,8 +273,18 @@
.rendered-content :global(table) { .rendered-content :global(table) {
width: 100%; width: 100%;
max-width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 1rem 0; margin: 1rem 0;
box-sizing: border-box;
display: table;
table-layout: auto;
}
.rendered-content :global(table) :global(td),
.rendered-content :global(table) :global(th) {
word-wrap: break-word;
overflow-wrap: break-word;
} }
.rendered-content :global(th), .rendered-content :global(th),

230
src/routes/repos/[npub]/[repo]/components/FilesTab.svelte

@ -47,6 +47,9 @@
defaultBranch?: string | null; defaultBranch?: string | null;
onBranchChange?: (branch: string) => void; onBranchChange?: (branch: string) => void;
userPubkey?: string | null; userPubkey?: string | null;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
@ -87,11 +90,21 @@
currentBranch = null, currentBranch = null,
defaultBranch = null, defaultBranch = null,
onBranchChange = () => {}, onBranchChange = () => {},
userPubkey = null userPubkey = null,
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
</script> </script>
<TabLayout {loading} {error}> <TabLayout
{loading}
{error}
activeTab={activeTab}
tabs={tabs}
onTabChange={onTabChange}
title={currentFile ? `File: ${currentFile.split('/').pop()}` : (readmeContent ? 'README' : 'Files')}
>
{#snippet leftPane()} {#snippet leftPane()}
<FileBrowser <FileBrowser
{files} {files}
@ -128,7 +141,7 @@
{@html readmeHtml} {@html readmeHtml}
</div> </div>
{:else if readmeContent} {:else if readmeContent}
<div class="readme-content"> <div class="readme-content raw-content">
<pre><code class="hljs language-text">{readmeContent}</code></pre> <pre><code class="hljs language-text">{readmeContent}</code></pre>
</div> </div>
{/if} {/if}
@ -227,9 +240,13 @@
{@html fileHtml} {@html fileHtml}
</div> </div>
{:else if highlightedFileContent} {:else if highlightedFileContent}
{@html highlightedFileContent} <div class="raw-content">
{@html highlightedFileContent}
</div>
{:else} {:else}
<pre><code class="hljs">{fileContent}</code></pre> <div class="raw-content">
<pre><code class="hljs">{fileContent}</code></pre>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -255,6 +272,36 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow: hidden;
}
.file-editor .editor-header {
display: flex !important;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
width: 100%;
min-width: 0;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
position: relative;
z-index: 1;
visibility: visible !important;
opacity: 1 !important;
}
.file-path {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.875rem;
color: var(--text-primary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 1rem;
} }
.editor-footer { .editor-footer {
@ -275,4 +322,177 @@
height: 100%; height: 100%;
color: var(--text-secondary); color: var(--text-secondary);
} }
.raw-content {
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: auto;
box-sizing: border-box;
contain: layout;
min-width: 0;
}
.raw-content pre {
margin: 0;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
overflow-x: auto;
word-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.raw-content code {
display: block;
overflow-x: auto;
max-width: 100%;
box-sizing: border-box;
width: 100%;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
}
.raw-content :global(code.hljs) {
overflow-x: auto;
display: block;
max-width: 100% !important;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
}
.raw-content :global(code.hljs *),
.raw-content :global(code.hljs span),
.raw-content :global(code.hljs .hljs-tag),
.raw-content :global(code.hljs .hljs-name),
.raw-content :global(code.hljs .hljs-attr),
.raw-content :global(code.hljs .hljs-string),
.raw-content :global(code.hljs .hljs-section),
.raw-content :global(code.hljs .hljs-quote),
.raw-content :global(code.hljs .hljs-link),
.raw-content :global(code.hljs .hljs-code),
.raw-content :global(code.hljs .hljs-bullet),
.raw-content :global(code.hljs .language-xml) {
max-width: 100% !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
white-space: pre-wrap !important;
display: inline;
box-sizing: border-box;
}
.raw-content :global(pre code.hljs) {
width: 100%;
max-width: 100% !important;
min-width: 0;
}
.readme-content.raw-content {
max-width: 100%;
overflow-x: auto;
box-sizing: border-box;
width: 100%;
}
.editor-container {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
min-width: 0;
}
.read-only-editor {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
min-width: 0;
}
.read-only-editor > .raw-content,
.read-only-editor > .file-preview {
flex: 1;
min-height: 0;
overflow: auto;
width: 100%;
max-width: 100%;
min-width: 0;
}
.read-only-editor > .raw-content > pre {
max-width: 100%;
min-width: 0;
}
.read-only-editor > .raw-content > pre > code {
max-width: 100%;
min-width: 0;
display: block;
}
.readme-section {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.readme-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
width: 100%;
}
.readme-header h3 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
}
.readme-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.readme-content {
flex: 1;
min-height: 0;
overflow: auto;
width: 100%;
max-width: 100%;
padding: 1.5rem;
}
.file-editor .editor-actions {
display: flex !important;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
flex-shrink: 0;
visibility: visible !important;
opacity: 1 !important;
width: auto;
min-width: 0;
}
</style> </style>

107
src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte

@ -22,6 +22,9 @@
verifyingCommits?: Set<string>; verifyingCommits?: Set<string>;
showDiff?: boolean; showDiff?: boolean;
diffData?: Array<{ file: string; additions: number; deletions: number; diff: string }>; diffData?: Array<{ file: string; additions: number; deletions: number; diff: string }>;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
@ -33,11 +36,21 @@
onVerify = () => {}, onVerify = () => {},
verifyingCommits = new Set(), verifyingCommits = new Set(),
showDiff = false, showDiff = false,
diffData = [] diffData = [],
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
</script> </script>
<TabLayout {loading} {error}> <TabLayout
{loading}
{error}
{activeTab}
{tabs}
{onTabChange}
title="Commit History"
>
{#snippet leftPane()} {#snippet leftPane()}
<div class="commits-list"> <div class="commits-list">
<h3>Commits</h3> <h3>Commits</h3>
@ -218,6 +231,24 @@
.commit-detail { .commit-detail {
padding: 1rem; padding: 1rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-width: 0;
overflow-wrap: break-word;
word-wrap: break-word;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 100%;
max-width: 100%;
height: 100%;
color: var(--text-secondary);
box-sizing: border-box;
} }
.commit-detail-header { .commit-detail-header {
@ -227,14 +258,34 @@
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
min-width: 0;
flex-wrap: wrap;
gap: 0.5rem;
}
.commit-detail-header h2 {
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
flex: 1;
} }
.commit-info { .commit-info {
margin: 1rem 0; margin: 1rem 0;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.info-row { .info-row {
margin: 1rem 0; margin: 1rem 0;
width: 100%;
max-width: 100%;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
box-sizing: border-box;
} }
.commit-message-text { .commit-message-text {
@ -243,21 +294,45 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 4px; border-radius: 4px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow-x: hidden;
} }
.files-list { .files-list {
margin-top: 0.5rem; margin-top: 0.5rem;
padding-left: 1.5rem; padding-left: 1.5rem;
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
}
.files-list li {
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
} }
.diff-section { .diff-section {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.diff-file { .diff-file {
margin: 1rem 0; margin: 1rem 0;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
} }
.diff-header { .diff-header {
@ -267,11 +342,22 @@
padding: 0.5rem; padding: 0.5rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
min-width: 0;
flex-wrap: wrap;
gap: 0.5rem;
}
.diff-header strong {
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
flex: 1;
} }
.diff-stats { .diff-stats {
font-family: monospace; font-family: monospace;
font-size: 0.9rem; font-size: 0.9rem;
flex-shrink: 0;
} }
.diff-content { .diff-content {
@ -280,7 +366,24 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
font-family: monospace; font-family: monospace;
font-size: 0.85rem; font-size: 0.85rem;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.diff-content code {
display: block;
max-width: 100%;
min-width: 0;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
box-sizing: border-box;
} }
</style> </style>

116
src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte

@ -4,6 +4,7 @@
*/ */
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js';
interface Props { interface Props {
issues: Array<{ issues: Array<{
@ -23,6 +24,9 @@
onStatusUpdate?: (id: string, status: string) => void; onStatusUpdate?: (id: string, status: string) => void;
issueReplies?: Array<any>; issueReplies?: Array<any>;
loadingReplies?: boolean; loadingReplies?: boolean;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
@ -33,20 +37,37 @@
onSelect = () => {}, onSelect = () => {},
onStatusUpdate = () => {}, onStatusUpdate = () => {},
issueReplies = [], issueReplies = [],
loadingReplies = false loadingReplies = false,
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
const items = $derived(issues.map(issue => ({ const items = $derived(issues.map(issue => ({
...issue,
id: issue.id, id: issue.id,
title: issue.subject, title: issue.subject,
status: issue.status, status: issue.status || 'open'
...issue
}))); })));
const selectedId = $derived(selectedIssue); const selectedId = $derived(selectedIssue);
// Cache for rendered content
let renderedContent = $state<Map<string, string>>(new Map());
async function getRenderedContent(content: string, kind?: number): Promise<string> {
if (!content) return 'No content';
const cacheKey = `${kind || 'markdown'}:${content.slice(0, 50)}`;
if (renderedContent.has(cacheKey)) {
return renderedContent.get(cacheKey)!;
}
const rendered = await renderContent(content, kind);
renderedContent.set(cacheKey, rendered);
return rendered;
}
</script> </script>
{#snippet itemRenderer({ item })} {#snippet itemRenderer({ item }: { item: any })}
<div class="issue-item-content"> <div class="issue-item-content">
<div class="issue-subject">{item.subject}</div> <div class="issue-subject">{item.subject}</div>
<div class="issue-meta"> <div class="issue-meta">
@ -56,41 +77,58 @@
</div> </div>
{/snippet} {/snippet}
{#snippet detailRenderer({ item })} {#snippet detailRenderer({ item }: { item: any })}
<div class="issue-detail"> {@const contentPromise = getRenderedContent(item.content || '', item.kind)}
<div class="issue-detail-header"> {@const currentStatus = item.status || 'open'}
<h2>{item.subject}</h2> <div class="issue-detail">
<div class="issue-actions"> <div class="issue-detail-header">
<select <h2>{item.subject}</h2>
value={item.status} <div class="issue-actions">
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} <select
> value={currentStatus}
<option value="open">Open</option> onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
<option value="closed">Closed</option> >
<option value="resolved">Resolved</option> <option value="open">Open</option>
</select> <option value="closed">Closed</option>
</div> <option value="resolved">Resolved</option>
</select>
</div> </div>
<div class="issue-content">
{@html item.content || 'No content'}
</div>
{#if loadingReplies}
<div class="loading">Loading replies...</div>
{:else if issueReplies.length > 0}
<div class="issue-replies">
<h3>Replies</h3>
{#each issueReplies as reply}
<div class="reply">
<div class="reply-author">{reply.author}</div>
<div class="reply-content">{reply.content}</div>
<div class="reply-date">{new Date(reply.created_at * 1000).toLocaleString()}</div>
</div>
{/each}
</div>
{/if}
</div> </div>
<div class="issue-content">
{#await contentPromise}
<div class="loading">Rendering content...</div>
{:then html}
{@html html}
{:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await}
</div>
{#if loadingReplies}
<div class="loading">Loading replies...</div>
{:else if issueReplies.length > 0}
<div class="issue-replies">
<h3>Replies</h3>
{#each issueReplies as reply}
{@const replyPromise = getRenderedContent(reply.content || '', reply.kind)}
<div class="reply">
<div class="reply-author">{reply.author}</div>
<div class="reply-content">
{#await replyPromise}
<div class="loading">Rendering...</div>
{:then html}
{@html html}
{:catch err}
{reply.content}
{/await}
</div>
<div class="reply-date">{new Date(reply.created_at * 1000).toLocaleString()}</div>
</div>
{/each}
</div>
{/if}
</div>
{/snippet} {/snippet}
<StatusTabLayout <StatusTabLayout
@ -106,6 +144,10 @@
]} ]}
{itemRenderer} {itemRenderer}
{detailRenderer} {detailRenderer}
{activeTab}
{tabs}
{onTabChange}
title="Issues"
/> />
<style> <style>

48
src/routes/repos/[npub]/[repo]/components/PRsTab.svelte

@ -4,6 +4,8 @@
*/ */
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js';
import { onMount } from 'svelte';
interface Props { interface Props {
prs: Array<{ prs: Array<{
@ -21,6 +23,9 @@
error?: string | null; error?: string | null;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onStatusUpdate?: (id: string, status: string) => void; onStatusUpdate?: (id: string, status: string) => void;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
@ -29,20 +34,37 @@
loading = false, loading = false,
error = null, error = null,
onSelect = () => {}, onSelect = () => {},
onStatusUpdate = () => {} onStatusUpdate = () => {},
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
const items = $derived(prs.map(pr => ({ const items = $derived(prs.map(pr => ({
...pr,
id: pr.id, id: pr.id,
title: pr.subject, title: pr.subject,
status: pr.status, status: pr.status || 'open'
...pr
}))); })));
const selectedId = $derived(selectedPR); const selectedId = $derived(selectedPR);
// Cache for rendered content
let renderedContent = $state<Map<string, string>>(new Map());
async function getRenderedContent(content: string, kind?: number): Promise<string> {
if (!content) return 'No content';
const cacheKey = `${kind || 'markdown'}:${content.slice(0, 50)}`;
if (renderedContent.has(cacheKey)) {
return renderedContent.get(cacheKey)!;
}
const rendered = await renderContent(content, kind);
renderedContent.set(cacheKey, rendered);
return rendered;
}
</script> </script>
{#snippet itemRenderer({ item })} {#snippet itemRenderer({ item }: { item: any })}
<div class="pr-item-content"> <div class="pr-item-content">
<div class="pr-subject">{item.subject}</div> <div class="pr-subject">{item.subject}</div>
<div class="pr-meta"> <div class="pr-meta">
@ -55,13 +77,15 @@
</div> </div>
{/snippet} {/snippet}
{#snippet detailRenderer({ item })} {#snippet detailRenderer({ item }: { item: any })}
{@const contentPromise = getRenderedContent(item.content || '', item.kind)}
{@const currentStatus = item.status || 'open'}
<div class="pr-detail"> <div class="pr-detail">
<div class="pr-detail-header"> <div class="pr-detail-header">
<h2>{item.subject}</h2> <h2>{item.subject}</h2>
<div class="pr-actions"> <div class="pr-actions">
<select <select
value={item.status} value={currentStatus}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
> >
<option value="open">Open</option> <option value="open">Open</option>
@ -72,7 +96,13 @@
</div> </div>
<div class="pr-content"> <div class="pr-content">
{@html item.content || 'No content'} {#await contentPromise}
<div class="loading">Rendering content...</div>
{:then html}
{@html html}
{:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await}
</div> </div>
{#if item.commitId} {#if item.commitId}
@ -96,6 +126,10 @@
]} ]}
{itemRenderer} {itemRenderer}
{detailRenderer} {detailRenderer}
{activeTab}
{tabs}
{onTabChange}
title="Pull Requests"
/> />
<style> <style>

78
src/routes/repos/[npub]/[repo]/components/PatchesTab.svelte

@ -4,6 +4,7 @@
*/ */
import StatusTabLayout from './StatusTabLayout.svelte'; import StatusTabLayout from './StatusTabLayout.svelte';
import { renderContent } from '../utils/content-renderer.js';
interface Props { interface Props {
patches: Array<{ patches: Array<{
@ -13,6 +14,7 @@
status: string; status: string;
author: string; author: string;
created_at: number; created_at: number;
kind?: number;
[key: string]: any; [key: string]: any;
}>; }>;
selectedPatch?: string | null; selectedPatch?: string | null;
@ -20,7 +22,11 @@
error?: string | null; error?: string | null;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onApply?: (id: string) => void; onApply?: (id: string) => void;
onStatusUpdate?: (id: string, status: string) => void;
applying?: Record<string, boolean>; applying?: Record<string, boolean>;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
} }
let { let {
@ -30,20 +36,38 @@
error = null, error = null,
onSelect = () => {}, onSelect = () => {},
onApply = () => {}, onApply = () => {},
applying = {} onStatusUpdate = () => {},
applying = {},
activeTab = '',
tabs = [],
onTabChange = () => {}
}: Props = $props(); }: Props = $props();
const items = $derived(patches.map(patch => ({ const items = $derived(patches.map(patch => ({
...patch,
id: patch.id, id: patch.id,
title: patch.subject, title: patch.subject,
status: patch.status || 'open', status: patch.status || 'open'
...patch
}))); })));
const selectedId = $derived(selectedPatch); const selectedId = $derived(selectedPatch);
// Cache for rendered content
let renderedContent = $state<Map<string, string>>(new Map());
async function getRenderedContent(content: string, kind?: number): Promise<string> {
if (!content) return 'No content';
const cacheKey = `${kind || 'markdown'}:${content.slice(0, 50)}`;
if (renderedContent.has(cacheKey)) {
return renderedContent.get(cacheKey)!;
}
const rendered = await renderContent(content, kind);
renderedContent.set(cacheKey, rendered);
return rendered;
}
</script> </script>
{#snippet itemRenderer({ item })} {#snippet itemRenderer({ item }: { item: any })}
<div class="patch-item-content"> <div class="patch-item-content">
<div class="patch-subject">{item.subject}</div> <div class="patch-subject">{item.subject}</div>
<div class="patch-meta"> <div class="patch-meta">
@ -53,12 +77,23 @@
</div> </div>
{/snippet} {/snippet}
{#snippet detailRenderer({ item })} {#snippet detailRenderer({ item }: { item: any })}
{@const contentPromise = getRenderedContent(item.content || '', item.kind)}
{@const currentStatus = item.status || 'open'}
<div class="patch-detail"> <div class="patch-detail">
<div class="patch-detail-header"> <div class="patch-detail-header">
<h2>{item.subject}</h2> <h2>{item.subject}</h2>
<div class="patch-actions"> <div class="patch-actions">
{#if item.status === 'open'} <select
value={currentStatus}
onchange={(e) => onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)}
class="status-select"
>
<option value="open">Open</option>
<option value="applied">Applied</option>
<option value="rejected">Rejected</option>
</select>
{#if currentStatus === 'open'}
<button <button
onclick={() => onApply(item.id)} onclick={() => onApply(item.id)}
disabled={applying[item.id]} disabled={applying[item.id]}
@ -71,7 +106,13 @@
</div> </div>
<div class="patch-content"> <div class="patch-content">
<pre><code>{item.content}</code></pre> {#await contentPromise}
<div class="loading">Rendering content...</div>
{:then html}
{@html html}
{:catch err}
<div class="error">Failed to render content: {err instanceof Error ? err.message : String(err)}</div>
{/await}
</div> </div>
</div> </div>
{/snippet} {/snippet}
@ -89,6 +130,10 @@
]} ]}
{itemRenderer} {itemRenderer}
{detailRenderer} {detailRenderer}
{activeTab}
{tabs}
{onTabChange}
title="Patches"
/> />
<style> <style>
@ -124,15 +169,7 @@
.patch-content { .patch-content {
margin: 1rem 0; margin: 1rem 0;
} line-height: 1.6;
.patch-content pre {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-family: monospace;
font-size: 0.9rem;
} }
.apply-button { .apply-button {
@ -148,4 +185,13 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.status-select {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
margin-right: 0.5rem;
}
</style> </style>

38
src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte

@ -20,6 +20,10 @@
statusGroups?: Array<{ label: string; value: string }>; statusGroups?: Array<{ label: string; value: string }>;
itemRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>; itemRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>;
detailRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>; detailRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
title?: string;
} }
let { let {
@ -33,7 +37,11 @@
{ label: 'Closed', value: 'closed' } { label: 'Closed', value: 'closed' }
], ],
itemRenderer, itemRenderer,
detailRenderer detailRenderer,
activeTab = '',
tabs = [],
onTabChange = () => {},
title = ''
}: Props = $props(); }: Props = $props();
let selectedItem = $derived(items.find(item => item.id === selectedId) || null); let selectedItem = $derived(items.find(item => item.id === selectedId) || null);
@ -58,7 +66,14 @@
const grouped = $derived(groupByStatus()); const grouped = $derived(groupByStatus());
</script> </script>
<TabLayout {loading} {error}> <TabLayout
{loading}
{error}
{activeTab}
{tabs}
{onTabChange}
{title}
>
{#snippet leftPane()} {#snippet leftPane()}
<div class="status-groups"> <div class="status-groups">
{#each statusGroups as { label, value }} {#each statusGroups as { label, value }}
@ -104,6 +119,10 @@
<pre>{JSON.stringify(selectedItem, null, 2)}</pre> <pre>{JSON.stringify(selectedItem, null, 2)}</pre>
</div> </div>
{/if} {/if}
{:else}
<div class="empty-state">
<p>Select an item to view details</p>
</div>
{/if} {/if}
{/snippet} {/snippet}
</TabLayout> </TabLayout>
@ -167,9 +186,24 @@
.detail-view { .detail-view {
padding: 1rem; padding: 1rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
} }
.detail-view h2 { .detail-view h2 {
margin-top: 0; margin-top: 0;
} }
.empty-state {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: 100%;
max-width: 100%;
height: 100%;
color: var(--text-secondary);
box-sizing: border-box;
}
</style> </style>

109
src/routes/repos/[npub]/[repo]/components/TabLayout.svelte

@ -4,49 +4,98 @@
* Provides left-pane/right-panel structure for all tabs * Provides left-pane/right-panel structure for all tabs
*/ */
import TabsMenu from '$lib/components/TabsMenu.svelte';
interface Props { interface Props {
leftPane?: any; leftPane?: any;
rightPanel?: any; rightPanel?: any;
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
activeTab?: string;
tabs?: Array<{ id: string; label: string; icon?: string }>;
onTabChange?: (tab: string) => void;
title?: string;
} }
let { let {
leftPane = null, leftPane = null,
rightPanel = null, rightPanel = null,
loading = false, loading = false,
error = null error = null,
activeTab = '',
tabs = [],
onTabChange = () => {},
title = ''
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="tab-layout"> <div class="tab-layout-wrapper">
<div class="left-pane"> {#if tabs.length > 0}
{#if loading} <div class="tab-header">
<div class="loading">Loading...</div> <TabsMenu
{:else if error} activeTab={activeTab || ''}
<div class="error">{error}</div> {tabs}
{:else} onTabChange={(tab) => onTabChange(tab)}
{#if leftPane} />
{@render leftPane()} {#if title}
<h2 class="tab-title">{title}</h2>
{/if} {/if}
{/if} </div>
</div> {/if}
<div class="right-panel"> <div class="tab-layout">
{#if rightPanel} <div class="left-pane">
{@render rightPanel()} {#if loading}
{:else} <div class="loading">Loading...</div>
<div class="empty-state"> {:else if error}
<p>Select an item to view details</p> <div class="error">{error}</div>
</div> {:else}
{/if} {#if leftPane}
{@render leftPane()}
{/if}
{/if}
</div>
<div class="right-panel">
{#if rightPanel}
{@render rightPanel()}
{:else}
<div class="empty-state">
<p>Select an item to view details</p>
</div>
{/if}
</div>
</div> </div>
</div> </div>
<style> <style>
.tab-layout { .tab-layout-wrapper {
display: flex; display: flex;
flex-direction: column;
height: 100%; height: 100%;
}
.tab-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--bg-primary);
flex-shrink: 0;
}
.tab-title {
flex: 1;
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.tab-layout {
display: flex;
flex: 1;
min-height: 0;
gap: 1rem; gap: 1rem;
} }
@ -59,8 +108,22 @@
.right-panel { .right-panel {
flex: 1; flex: 1;
min-width: 0;
width: 100%;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 1rem; padding: 1rem;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.right-panel > * {
min-width: 0;
max-width: 100%;
width: 100%;
flex: 1;
box-sizing: border-box;
} }
.loading, .error { .loading, .error {
@ -76,7 +139,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
min-width: 100%;
max-width: 100%;
height: 100%; height: 100%;
color: var(--text-secondary); color: var(--text-secondary);
box-sizing: border-box;
} }
</style> </style>

6
src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte

@ -25,7 +25,7 @@
</p> </p>
{#if state.verification.selectedCloneUrl} {#if state.verification.selectedCloneUrl}
<p style="margin: 1rem 0;"> <p style="margin: 1rem 0;">
<strong>Clone URL:</strong> <code>{state.verification.selectedCloneUrl}</code> <strong>Clone URL:</strong> <code class="verification-code">{state.verification.selectedCloneUrl}</code>
</p> </p>
{/if} {/if}
{#if state.clone.isCloned !== true} {#if state.clone.isCloned !== true}
@ -34,7 +34,7 @@
</div> </div>
{:else} {:else}
<p style="margin: 1rem 0; color: var(--text-secondary);"> <p style="margin: 1rem 0; color: var(--text-secondary);">
This will commit the repository announcement event to <code>nostr/repo-events.jsonl</code> in the default branch, which verifies that you control this repository. This will commit the repository announcement event to <code class="verification-code">nostr/repo-events.jsonl</code> in the default branch, which verifies that you control this repository.
</p> </p>
{/if} {/if}
{#if state.error} {#if state.error}
@ -71,7 +71,7 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.verification-instructions code { .verification-code {
background: var(--bg-secondary, #f5f5f5); background: var(--bg-secondary, #f5f5f5);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 3px; border-radius: 3px;

4
src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte

@ -16,7 +16,7 @@
<Modal {open} title="Repository Verification File" ariaLabel="Repository verification file" {onClose}> <Modal {open} title="Repository Verification File" ariaLabel="Repository verification file" {onClose}>
<div class="modal-body"> <div class="modal-body">
<p class="verification-instructions"> <p class="verification-instructions">
The announcement event should be saved to <code>nostr/repo-events.jsonl</code> in your repository. The announcement event should be saved to <code class="verification-code">nostr/repo-events.jsonl</code> in your repository.
You can download the announcement event JSON below for reference. You can download the announcement event JSON below for reference.
</p> </p>
<div class="verification-file-content"> <div class="verification-file-content">
@ -45,7 +45,7 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
} }
.verification-instructions code { .verification-code {
background: var(--bg-secondary, #f5f5f5); background: var(--bg-secondary, #f5f5f5);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 3px; border-radius: 3px;

77
src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts

@ -6,17 +6,15 @@
* where $page and $userStore runes are available * where $page and $userStore runes are available
*/ */
import { page } from '$app/stores';
import { userStore } from '$lib/stores/user-store.js';
import { settingsStore } from '$lib/services/settings-store.js'; import { settingsStore } from '$lib/services/settings-store.js';
import type { RepoState } from '../stores/repo-state.js'; import type { RepoState } from '../stores/repo-state.js';
/** /**
* Sync pageData from $page store * Sync pageData from $page store
* Must be called from component context where $page is available * Returns effect callback that should be called within $effect in component
*/ */
export function usePageDataEffect(state: RepoState, getPageData: () => any): void { export function usePageDataEffect(state: RepoState, getPageData: () => any): () => void {
$effect(() => { return () => {
if (typeof window === 'undefined' || !state.isMounted) return; if (typeof window === 'undefined' || !state.isMounted) return;
try { try {
const data = getPageData(); const data = getPageData();
@ -28,15 +26,15 @@ export function usePageDataEffect(state: RepoState, getPageData: () => any): voi
console.warn('Failed to update pageData:', err); console.warn('Failed to update pageData:', err);
} }
} }
}); };
} }
/** /**
* Sync params from $page store * Sync params from $page store
* Must be called from component context where $page is available * Returns effect callback that should be called within $effect in component
*/ */
export function usePageParamsEffect(state: RepoState, getPageParams: () => { npub?: string; repo?: string }): void { export function usePageParamsEffect(state: RepoState, getPageParams: () => { npub?: string; repo?: string }): () => void {
$effect(() => { return () => {
if (typeof window === 'undefined' || !state.isMounted) return; if (typeof window === 'undefined' || !state.isMounted) return;
try { try {
const params = getPageParams(); const params = getPageParams();
@ -58,12 +56,12 @@ export function usePageParamsEffect(state: RepoState, getPageParams: () => { npu
// Ignore errors - params will be set eventually // Ignore errors - params will be set eventually
} }
} }
}); };
} }
/** /**
* Load maintainers when repo data is available * Load maintainers when repo data is available
* Must be called from component context where $page is available * Returns effect callback that should be called within $effect in component
*/ */
export function useMaintainersEffect( export function useMaintainersEffect(
state: RepoState, state: RepoState,
@ -71,8 +69,8 @@ export function useMaintainersEffect(
getRepoMaintainers: () => string[], getRepoMaintainers: () => string[],
loadAllMaintainers: () => Promise<void>, loadAllMaintainers: () => Promise<void>,
getPageData: () => any getPageData: () => any
): void { ): () => void {
$effect(() => { return () => {
if (typeof window === 'undefined' || !state.isMounted) return; if (typeof window === 'undefined' || !state.isMounted) return;
try { try {
const data = getPageData(); const data = getPageData();
@ -107,18 +105,19 @@ export function useMaintainersEffect(
console.warn('Maintainers effect error:', err); console.warn('Maintainers effect error:', err);
} }
} }
}); };
} }
/** /**
* Watch auto-save settings and manage auto-save interval * Watch auto-save settings and manage auto-save interval
* Returns effect callback that should be called within $effect in component
*/ */
export function useAutoSaveEffect( export function useAutoSaveEffect(
state: RepoState, state: RepoState,
autoSaveInterval: { value: ReturnType<typeof setInterval> | null }, autoSaveInterval: { value: ReturnType<typeof setInterval> | null },
setupAutoSave: () => void setupAutoSave: () => void
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted) return; if (!state.isMounted) return;
settingsStore.getSettings().then(settings => { settingsStore.getSettings().then(settings => {
if (!state.isMounted) return; if (!state.isMounted) return;
@ -135,12 +134,12 @@ export function useAutoSaveEffect(
console.warn('Failed to check auto-save setting:', err); console.warn('Failed to check auto-save setting:', err);
} }
}); });
}); };
} }
/** /**
* Sync user state from userStore and reload data on login/logout * Sync user state from userStore and reload data on login/logout
* Must be called from component context where $userStore is available * Returns effect callback that should be called within $effect in component
*/ */
export function useUserStoreEffect( export function useUserStoreEffect(
state: RepoState, state: RepoState,
@ -157,8 +156,8 @@ export function useUserStoreEffect(
loadTags: () => Promise<void>; loadTags: () => Promise<void>;
loadDiscussions: () => Promise<void>; loadDiscussions: () => Promise<void>;
} }
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted) return; if (!state.isMounted) return;
try { try {
const currentUser = getUserStore(); const currentUser = getUserStore();
@ -245,18 +244,19 @@ export function useUserStoreEffect(
console.warn('User store sync error:', err); console.warn('User store sync error:', err);
} }
} }
}); };
} }
/** /**
* Handle tab switching when clone status changes * Handle tab switching when clone status changes
* Returns effect callback that should be called within $effect in component
*/ */
export function useTabSwitchEffect( export function useTabSwitchEffect(
state: RepoState, state: RepoState,
tabs: Array<{ id: string }>, tabs: Array<{ id: string }>,
canUseApiFallback: boolean canUseApiFallback: boolean
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted) return; if (!state.isMounted) return;
if (state.clone.isCloned === false && !canUseApiFallback && tabs.length > 0) { if (state.clone.isCloned === false && !canUseApiFallback && tabs.length > 0) {
const currentTab = tabs.find(t => t.id === state.ui.activeTab); const currentTab = tabs.find(t => t.id === state.ui.activeTab);
@ -264,15 +264,15 @@ export function useTabSwitchEffect(
state.ui.activeTab = tabs[0].id as typeof state.ui.activeTab; state.ui.activeTab = tabs[0].id as typeof state.ui.activeTab;
} }
} }
}); };
} }
/** /**
* Update repo images from pageData * Update repo images from pageData
* Must be called from component context where $page is available * Returns effect callback that should be called within $effect in component
*/ */
export function useRepoImagesEffect(state: RepoState, getPageData: () => any): void { export function useRepoImagesEffect(state: RepoState, getPageData: () => any): () => void {
$effect(() => { return () => {
if (typeof window === 'undefined' || !state.isMounted) return; if (typeof window === 'undefined' || !state.isMounted) return;
try { try {
const data = getPageData(); const data = getPageData();
@ -290,17 +290,18 @@ export function useRepoImagesEffect(state: RepoState, getPageData: () => any): v
console.warn('Image update effect error:', err); console.warn('Image update effect error:', err);
} }
} }
}); };
} }
/** /**
* Load patch highlights when patch is selected * Load patch highlights when patch is selected
* Returns effect callback that should be called within $effect in component
*/ */
export function usePatchHighlightsEffect( export function usePatchHighlightsEffect(
state: RepoState, state: RepoState,
loadPatchHighlights: (patchId: string, author: string) => Promise<void> loadPatchHighlights: (patchId: string, author: string) => Promise<void>
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted || !state.selected.patch) return; if (!state.isMounted || !state.selected.patch) return;
const patch = state.patches.find(p => p.id === state.selected.patch); const patch = state.patches.find(p => p.id === state.selected.patch);
if (patch) { if (patch) {
@ -308,11 +309,12 @@ export function usePatchHighlightsEffect(
if (state.isMounted) console.warn('Failed to load patch highlights:', err); if (state.isMounted) console.warn('Failed to load patch highlights:', err);
}); });
} }
}); };
} }
/** /**
* Load tab content when tab changes * Load tab content when tab changes
* Returns effect callback that should be called within $effect in component
*/ */
export function useTabChangeEffect( export function useTabChangeEffect(
state: RepoState, state: RepoState,
@ -330,8 +332,8 @@ export function useTabChangeEffect(
loadDiscussions: () => Promise<void>; loadDiscussions: () => Promise<void>;
loadPatches: () => Promise<void>; loadPatches: () => Promise<void>;
} }
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted) return; if (!state.isMounted) return;
if (state.ui.activeTab !== lastTab.value) { if (state.ui.activeTab !== lastTab.value) {
lastTab.value = state.ui.activeTab; lastTab.value = state.ui.activeTab;
@ -389,11 +391,12 @@ export function useTabChangeEffect(
}); });
} }
} }
}); };
} }
/** /**
* Reload branch-dependent data when branch changes * Reload branch-dependent data when branch changes
* Returns effect callback that should be called within $effect in component
*/ */
export function useBranchChangeEffect( export function useBranchChangeEffect(
state: RepoState, state: RepoState,
@ -405,8 +408,8 @@ export function useBranchChangeEffect(
loadCommitHistory: () => Promise<void>; loadCommitHistory: () => Promise<void>;
loadDocumentation: () => Promise<void>; loadDocumentation: () => Promise<void>;
} }
): void { ): () => void {
$effect(() => { return () => {
if (!state.isMounted) return; if (!state.isMounted) return;
if (state.git.currentBranch && state.git.currentBranch !== lastBranch.value) { if (state.git.currentBranch && state.git.currentBranch !== lastBranch.value) {
lastBranch.value = state.git.currentBranch; lastBranch.value = state.git.currentBranch;
@ -443,5 +446,5 @@ export function useBranchChangeEffect(
}); });
} }
} }
}); };
} }

122
src/routes/repos/[npub]/[repo]/services/auth-operations.ts

@ -0,0 +1,122 @@
/**
* Authentication operations service
* Handles user authentication and login
*/
import type { RepoState } from '../stores/repo-state.js';
import { userStore } from '$lib/stores/user-store.js';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { get } from 'svelte/store';
/**
* Check authentication status
*/
export async function checkAuth(state: RepoState): Promise<void> {
// Check userStore first
const currentUser = get(userStore);
if (currentUser.userPubkey && currentUser.userPubkeyHex) {
state.user.pubkey = currentUser.userPubkey;
state.user.pubkeyHex = currentUser.userPubkeyHex;
// Recheck maintainer status and bookmark status after auth
// These will be called by useUserStoreEffect hook
}
}
/**
* Login with NIP-07
*/
export async function login(
state: RepoState,
callbacks: {
checkMaintainerStatus: () => Promise<void>;
loadBookmarkStatus: () => Promise<void>;
}
): Promise<void> {
// Check userStore first
const currentUser = get(userStore);
if (currentUser.userPubkey && currentUser.userPubkeyHex) {
state.user.pubkey = currentUser.userPubkey;
state.user.pubkeyHex = currentUser.userPubkeyHex;
// Recheck maintainer status and bookmark status after auth
await callbacks.checkMaintainerStatus();
await callbacks.loadBookmarkStatus();
return;
}
try {
// Get public key from NIP-07 extension
const pubkey = await getPublicKeyWithNIP07();
if (!pubkey) {
state.error = 'Failed to get public key from NIP-07 extension';
return;
}
state.user.pubkey = pubkey;
// Convert npub to hex if needed
let pubkeyHex: string;
if (pubkey.startsWith('npub')) {
try {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkeyHex = decoded.data as string;
} else {
state.error = 'Invalid public key format';
return;
}
} catch {
state.error = 'Invalid public key format';
return;
}
} else {
pubkeyHex = pubkey;
}
state.user.pubkeyHex = pubkeyHex;
// Check write access and update user store
const { determineUserLevel } = await import('$lib/services/nostr/user-level-service.js');
const levelResult = await determineUserLevel(state.user.pubkey, state.user.pubkeyHex);
// Update user store with write access level
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity tracking
const { updateActivity } = await import('$lib/services/activity-tracker.js');
updateActivity();
// Check for pending transfer events
if (state.user.pubkeyHex) {
try {
const response = await fetch('/api/transfers/pending', {
headers: {
'X-User-Pubkey': state.user.pubkeyHex
}
});
if (response.ok) {
const data = await response.json();
if (data.pendingTransfers && data.pendingTransfers.length > 0) {
window.dispatchEvent(new CustomEvent('pendingTransfers', {
detail: { transfers: data.pendingTransfers }
}));
}
}
} catch (err) {
console.error('Failed to check for pending transfers:', err);
}
}
// Re-check maintainer status and bookmark status after login
await callbacks.checkMaintainerStatus();
await callbacks.loadBookmarkStatus();
} catch (err) {
state.error = err instanceof Error ? err.message : 'Failed to connect';
console.error('Login error:', err);
}
}

42
src/routes/repos/[npub]/[repo]/services/patch-handlers.ts

@ -0,0 +1,42 @@
/**
* Patch handler utilities
* UI interaction handlers for patch operations
*/
import type { RepoState } from '../stores/repo-state.js';
/**
* Handle patch code selection
*/
export function handlePatchCodeSelection(
text: string,
startLine: number,
endLine: number,
startPos: number,
endPos: number,
state: RepoState
): void {
if (!text.trim() || !state.user.pubkey) return;
state.forms.patchHighlight.text = text;
state.forms.patchHighlight.startLine = startLine;
state.forms.patchHighlight.endLine = endLine;
state.forms.patchHighlight.startPos = startPos;
state.forms.patchHighlight.endPos = endPos;
state.openDialog = 'patchHighlight';
}
/**
* Start patch comment
*/
export function startPatchComment(
parentId: string | undefined,
state: RepoState
): void {
if (!state.user.pubkey) {
alert('Please connect your NIP-07 extension');
return;
}
state.forms.patchComment.replyingTo = parentId || null;
state.openDialog = 'patchComment';
}

264
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -557,3 +557,267 @@ export async function loadRepoImages(
console.error('Error loading repo images:', err); console.error('Error loading repo images:', err);
} }
} }
/**
* Generate announcement file for repository
*/
export async function generateAnnouncementFileForRepo(
state: RepoState,
repoOwnerPubkeyDerived: string | null
): Promise<void> {
if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) {
state.error = 'Unable to generate announcement file: missing repository or user information';
return;
}
try {
// Fetch the repository announcement event
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js');
const { KIND } = await import('$lib/types/nostr.js');
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]);
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkeyDerived],
'#d': [state.repo],
limit: 1
}
]);
if (events.length === 0) {
state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.';
return;
}
const announcement = events[0] as NostrEvent;
// Generate announcement event JSON (for download/reference)
state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n';
state.openDialog = 'verification';
} catch (err) {
console.error('Failed to generate announcement file:', err);
state.error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`;
}
}
/**
* Copy verification file content to clipboard
*/
export function copyVerificationToClipboard(state: RepoState): void {
if (!state.verification.fileContent) return;
navigator.clipboard.writeText(state.verification.fileContent).then(() => {
alert('Verification file content copied to clipboard!');
}).catch((err) => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
/**
* Download verification file
*/
export function downloadVerificationFile(state: RepoState): void {
if (!state.verification.fileContent) return;
const blob = new Blob([state.verification.fileContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'announcement-event.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Verify clone URL
*/
export async function verifyCloneUrl(
state: RepoState,
repoOwnerPubkeyDerived: string | null,
callbacks: { checkVerification: () => Promise<void> }
): Promise<void> {
if (!state.verification.selectedCloneUrl || !state.user.pubkey || !state.user.pubkeyHex) {
state.error = 'Unable to verify: missing information';
return;
}
if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) {
state.error = 'Only repository owners and maintainers can verify clone URLs';
return;
}
// selectedCloneUrl is already set when user selects it
state.error = null;
try {
const data = await apiRequest<{ message?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, {
method: 'POST'
} as RequestInit);
// Close dialog
state.openDialog = null;
state.verification.selectedCloneUrl = null;
// Reload verification status after a short delay
setTimeout(() => {
callbacks.checkVerification().catch((err: unknown) => {
console.warn('Failed to reload verification status:', err);
});
}, 1000);
// Show success message
alert(data.message || 'Repository verification initiated. The verification status will update shortly.');
} catch (err) {
state.error = err instanceof Error ? err.message : 'Failed to verify repository';
console.error('Error verifying clone URL:', err);
} finally {
state.verification.selectedCloneUrl = null;
}
}
/**
* Delete repository announcement
*/
export async function deleteAnnouncement(
state: RepoState,
repoOwnerPubkeyDerived: string | null,
announcementEventId: { value: string | null }
): Promise<void> {
if (!state.user.pubkey || !state.user.pubkeyHex) {
alert('Please connect your NIP-07 extension');
return;
}
if (!repoOwnerPubkeyDerived || state.user.pubkeyHex !== repoOwnerPubkeyDerived) {
alert('Only the repository owner can delete the announcement');
return;
}
// First confirmation
if (!confirm('WARNING: Are you sure you want to delete this repository announcement?\n\nThis will permanently delete the repository announcement from Nostr relays. This action CANNOT be undone.\n\nClick OK to continue, or Cancel to abort.')) {
return;
}
// Second confirmation for critical operation
if (!confirm('FINAL CONFIRMATION: This will permanently delete the repository announcement.\n\nAre you absolutely certain you want to proceed?\n\nThis action CANNOT be undone.')) {
return;
}
state.creating.announcement = true;
state.error = null;
try {
// Fetch the repository announcement to get its event ID
const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } = await import('$lib/config.js');
const { getUserRelays } = await import('$lib/services/nostr/user-relays.js');
const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js');
const { KIND } = await import('$lib/types/nostr.js');
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]);
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkeyDerived],
'#d': [state.repo],
limit: 1
}
]);
if (events.length === 0) {
throw new Error('Repository announcement not found');
}
const announcement = events[0];
announcementEventId.value = announcement.id;
// Get user relays
const { outbox } = await getUserRelays(state.user.pubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
// Create deletion request (NIP-09)
const deletionRequestTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.DELETION_REQUEST,
pubkey: state.user.pubkeyHex,
created_at: Math.floor(Date.now() / 1000),
content: `Requesting deletion of repository announcement for ${state.repo}`,
tags: [
['e', announcement.id], // Reference to the announcement event
['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${state.repo}`], // Repository address
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted
]
};
// Sign with NIP-07
const signedDeletionRequest = await signEventWithNIP07(deletionRequestTemplate);
// Publish to relays
const publishResult = await nostrClient.publishEvent(signedDeletionRequest, combinedRelays);
if (publishResult.success.length > 0) {
alert(`Deletion request published successfully to ${publishResult.success.length} relay(s).`);
} else {
throw new Error(`Failed to publish deletion request to any relay. Errors: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`);
}
} catch (err) {
console.error('Failed to delete announcement:', err);
state.error = err instanceof Error ? err.message : 'Failed to send deletion request';
alert(state.error);
} finally {
state.creating.announcement = false;
}
}
/**
* Copy event ID to clipboard
*/
export async function copyEventId(
state: RepoState,
repoOwnerPubkeyDerived: string | null
): Promise<void> {
if (!state.metadata.address || !repoOwnerPubkeyDerived) {
alert('Repository address not available');
return;
}
try {
const { nip19 } = await import('nostr-tools');
const { KIND } = await import('$lib/types/nostr.js');
// Create naddr (NIP-19 address) for the repository
const naddr = nip19.naddrEncode({
kind: KIND.REPO_ANNOUNCEMENT,
pubkey: repoOwnerPubkeyDerived,
identifier: state.repo,
relays: []
});
// Try to use the Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(naddr);
} else {
// Fallback: use execCommand for older browsers or if clipboard API fails
const textArea = document.createElement('textarea');
textArea.value = naddr;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
textArea.remove();
} catch (execErr) {
textArea.remove();
throw new Error('Failed to copy to clipboard. Please copy manually: ' + naddr);
}
}
// Show message with naddr
alert(`Event ID copied to clipboard!\n\nnaddr (repository address):\n${naddr}`);
} catch (err) {
console.error('Failed to copy event ID:', err);
alert(`Failed to copy event ID: ${err instanceof Error ? err.message : String(err)}`);
}
}

6
src/routes/repos/[npub]/[repo]/services/tag-operations.ts

@ -22,13 +22,15 @@ export async function loadTags(
const tags = await apiRequest<Array<{ name: string; hash: string; message?: string; date?: number }>>( const tags = await apiRequest<Array<{ name: string; hash: string; message?: string; date?: number }>>(
`/api/repos/${state.npub}/${state.repo}/tags` `/api/repos/${state.npub}/${state.repo}/tags`
); );
state.git.tags = tags; state.git.tags = tags || [];
// Auto-select first tag if none selected // Auto-select first tag if none selected
if (state.git.tags.length > 0 && !state.git.selectedTag) { if (state.git.tags.length > 0 && !state.git.selectedTag) {
state.git.selectedTag = state.git.tags[0].name; state.git.selectedTag = state.git.tags[0].name;
} }
} catch (err) { } catch (err) {
console.error('Failed to load tags:', err); // If tags endpoint returns 404 or error, just set empty array (tags are optional)
console.warn('Failed to load tags (this is OK if repo has no tags):', err);
state.git.tags = [];
} }
} }

2
src/routes/repos/[npub]/[repo]/stores/repo-state.ts

@ -582,7 +582,7 @@ export function createRepoState(): RepoState {
pubkeyHex: null pubkeyHex: null
}, },
ui: { ui: {
activeTab: 'files', activeTab: 'docs',
showRepoMenu: false, showRepoMenu: false,
showFileListOnMobile: true, showFileListOnMobile: true,
showLeftPanelOnMobile: true, showLeftPanelOnMobile: true,

87
src/routes/repos/[npub]/[repo]/utils/content-renderer.ts

@ -0,0 +1,87 @@
/**
* Content rendering utility
* Renders content as Markdown (default) or AsciiDoc (for kinds 30041 and 30818)
* Consolidates rendering logic used by DocsViewer, PRsTab, IssuesTab, and PatchesTab
*/
/**
* Render content as HTML based on kind or contentType
* @param content - The content to render
* @param kindOrType - The Nostr event kind (30041 or 30818 for AsciiDoc) or contentType string ('asciidoc' for AsciiDoc, everything else for Markdown)
* @returns Promise<string> - Rendered HTML
*/
export async function renderContent(
content: string,
kindOrType?: number | 'markdown' | 'asciidoc' | 'text'
): Promise<string> {
if (!content) return '';
// Determine if we should use AsciiDoc
let useAsciiDoc = false;
if (typeof kindOrType === 'number') {
// Nostr event kind: 30041 or 30818 for AsciiDoc
useAsciiDoc = kindOrType === 30041 || kindOrType === 30818;
} else if (typeof kindOrType === 'string') {
// Content type string: 'asciidoc' for AsciiDoc
useAsciiDoc = kindOrType === 'asciidoc';
}
if (useAsciiDoc) {
// Use AsciiDoc parser
const asciidoctor = (await import('asciidoctor')).default();
const result = asciidoctor.convert(content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
});
return typeof result === 'string' ? result : String(result);
} else if (typeof kindOrType === 'string' && kindOrType === 'text') {
// Plain text - escape HTML
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
} else {
// Use Markdown parser (default)
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang }).value +
'</code></pre>';
} catch (err) {
// Fallback to escaped HTML if highlighting fails
}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
let rendered = md.render(content);
// Add IDs to headings for anchor links (like DocsViewer does)
rendered = rendered.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (match, level, text) => {
const textContent = text.replace(/<[^>]*>/g, '').trim();
const slug = textContent
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return `<h${level} id="${slug}">${text}</h${level}>`;
});
return rendered;
}
}

30
src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts

@ -47,3 +47,33 @@ export function getReferencedEventFromDiscussion(
} }
return undefined; return undefined;
} }
/**
* Count all replies recursively
*/
export function countAllReplies(comments: Array<{ replies?: Array<any> }> | undefined): number {
if (!comments || comments.length === 0) {
return 0;
}
let count = comments.length;
for (const comment of comments) {
if (comment.replies && comment.replies.length > 0) {
count += countAllReplies(comment.replies);
}
}
return count;
}
/**
* Toggle thread expansion
*/
export function toggleThread(
threadId: string,
expandedThreads: Set<string>
): void {
if (expandedThreads.has(threadId)) {
expandedThreads.delete(threadId);
} else {
expandedThreads.add(threadId);
}
}

134
src/routes/repos/[npub]/[repo]/utils/file-handlers.ts

@ -0,0 +1,134 @@
/**
* File handler utilities
* UI interaction handlers for file operations
*/
import type { RepoState } from '../stores/repo-state.js';
import { getMimeType } from './file-helpers.js';
/**
* Handle content change in file editor
*/
export function handleContentChange(value: string, state: RepoState): void {
state.files.editedContent = value;
state.files.hasChanges = value !== state.files.content;
}
/**
* Handle file click (directory or file)
*/
export function handleFileClick(
file: { name: string; path: string; type: 'file' | 'directory' },
state: RepoState,
callbacks: {
loadFiles: (path: string) => Promise<void>;
loadFile: (path: string) => Promise<void>;
}
): void {
if (file.type === 'directory') {
state.files.pathStack.push(state.files.currentPath);
callbacks.loadFiles(file.path);
} else {
callbacks.loadFile(file.path);
// On mobile, switch to file viewer when a file is clicked
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
state.ui.showFileListOnMobile = false;
}
}
}
/**
* Copy file content to clipboard
*/
export async function copyFileContent(
state: RepoState,
event?: Event
): Promise<void> {
if (!state.files.content || state.preview.copying) return;
state.preview.copying = true;
try {
await navigator.clipboard.writeText(state.files.content);
// Show temporary feedback
const button = event?.target as HTMLElement;
if (button) {
const originalTitle = button.getAttribute('title') || '';
button.setAttribute('title', 'Copied!');
setTimeout(() => {
button.setAttribute('title', originalTitle);
}, 2000);
}
} catch (err) {
console.error('Failed to copy file content:', err);
alert('Failed to copy file content to clipboard');
} finally {
state.preview.copying = false;
}
}
/**
* Download file
*/
export function downloadFile(state: RepoState): void {
if (!state.files.content || !state.files.currentFile) return;
try {
// Determine MIME type based on file extension
const ext = state.files.currentFile.split('.').pop()?.toLowerCase() || '';
const mimeType = getMimeType(ext);
const blob = new Blob([state.files.content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = state.files.currentFile.split('/').pop() || 'file';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download file:', err);
alert('Failed to download file');
}
}
/**
* Handle back navigation in file browser
*/
export function handleBack(
state: RepoState,
callbacks: {
loadFiles: (path: string) => Promise<void>;
}
): void {
if (state.files.pathStack.length > 0) {
const parentPath = state.files.pathStack.pop() || '';
callbacks.loadFiles(parentPath);
} else {
callbacks.loadFiles('');
}
}
/**
* Toggle word wrap
*/
export async function toggleWordWrap(
state: RepoState,
callbacks: {
applySyntaxHighlighting: (content: string, ext: string) => Promise<void>;
}
): Promise<void> {
state.ui.wordWrap = !state.ui.wordWrap;
console.log('Word wrap toggled:', state.ui.wordWrap);
// Force DOM update by accessing the element
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
// Re-apply syntax highlighting to refresh the display
if (state.files.currentFile && state.files.content) {
const ext = state.files.currentFile.split('.').pop() || '';
await callbacks.applySyntaxHighlighting(state.files.content, ext);
}
}

78
src/routes/repos/[npub]/[repo]/utils/repo-handlers.ts

@ -0,0 +1,78 @@
/**
* Repository handler utilities
* UI interaction handlers for repository operations
*/
import type { RepoState } from '../stores/repo-state.js';
import type { Page } from '@sveltejs/kit';
/**
* Copy clone URL to clipboard
*/
export async function copyCloneUrl(
state: RepoState,
pageData: { gitDomain?: string } | undefined,
pageUrl: Page['url'] | undefined
): Promise<void> {
if (state.clone.copyingUrl) return;
state.clone.copyingUrl = true;
try {
// Guard against SSR
if (typeof window === 'undefined') return;
if (!pageUrl) {
return;
}
// Use gitDomain from page data if available, otherwise use current URL host
// gitDomain is set from GIT_DOMAIN env var and is the actual production domain
let host: string;
let protocol: string;
if (pageData?.gitDomain) {
const gitDomain = pageData.gitDomain;
// Check if gitDomain is localhost - if so, we should use the actual current domain
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1');
if (isLocalhost) {
// During development, use the actual current domain from the URL
host = pageUrl.host;
protocol = pageUrl.protocol.slice(0, -1); // Remove trailing ":"
} else {
// Use the configured git domain (production)
host = gitDomain;
protocol = 'https'; // Production domains should use HTTPS
}
} else {
// Fallback to current URL
host = pageUrl.host;
protocol = pageUrl.protocol.slice(0, -1);
}
// Use /api/git/ format for better compatibility with commit signing hook
const cloneUrl = `${protocol}://${host}/api/git/${state.npub}/${state.repo}.git`;
const cloneCommand = `git clone ${cloneUrl}`;
// Try to use the Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(cloneCommand);
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`);
} else {
// Fallback: create a temporary textarea
const textarea = document.createElement('textarea');
textarea.value = cloneCommand;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`);
}
} catch (err) {
console.error('Failed to copy clone command:', err);
alert('Failed to copy clone command to clipboard');
} finally {
state.clone.copyingUrl = false;
}
}
Loading…
Cancel
Save