Browse Source

refactor

Nostr-Signature: 62b813f817173c9e35eb05088240f7ec50ecab697c8c6d4a5c19d47664ef3837 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc ca9c70fc7bf8b1bb1726461bb843127d1bddc4de96652cfc7497698a3f5c4dc4a8c3f5a7a240710db77afabeee2a3b7d594f75f42a0a8b28aeeef50f66b506c9
main
Silberengel 3 weeks ago
parent
commit
1063079b8d
  1. 1
      nostr/commit-signatures.jsonl
  2. 482
      src/lib/components/RepoHeaderEnhanced.svelte
  3. 177
      src/lib/components/RepoTabs.svelte
  4. 32
      src/lib/components/UserBadge.svelte
  5. 2
      src/lib/services/git/repo-manager.ts
  6. 737
      src/lib/styles/components.css
  7. 1879
      src/lib/styles/repo.css
  8. 3
      src/routes/repos/+page.svelte
  9. 1924
      src/routes/repos/[npub]/[repo]/+page.svelte
  10. 595
      src/routes/users/[npub]/+page.svelte
  11. 5
      static/icons/git-commit.svg
  12. 5
      static/icons/more-vertical.svg
  13. 4
      static/icons/tag.svg

1
nostr/commit-signatures.jsonl

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

482
src/lib/components/RepoHeaderEnhanced.svelte

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import UserBadge from './UserBadge.svelte'; import UserBadge from './UserBadge.svelte';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import '$lib/styles/components.css';
interface Props { interface Props {
repoName: string; repoName: string;
@ -39,6 +40,7 @@
deletingAnnouncement?: boolean; deletingAnnouncement?: boolean;
hasUnlimitedAccess?: boolean; hasUnlimitedAccess?: boolean;
needsClone?: boolean; needsClone?: boolean;
allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>;
} }
let { let {
@ -77,12 +79,14 @@
onDeleteAnnouncement, onDeleteAnnouncement,
deletingAnnouncement = false, deletingAnnouncement = false,
hasUnlimitedAccess = false, hasUnlimitedAccess = false,
needsClone = false needsClone = false,
allMaintainers = []
}: Props = $props(); }: Props = $props();
let showCloneMenu = $state(false); let showCloneMenu = $state(false);
let showMoreMenu = $state(false); let showMoreMenu = $state(false);
let showBranchMenu = $state(false); let showBranchMenu = $state(false);
let showOwnerMenu = $state(false);
</script> </script>
<header class="repo-header"> <header class="repo-header">
@ -107,6 +111,7 @@
</div> </div>
<div class="repo-header-actions"> <div class="repo-header-actions">
{#if userPubkey} {#if userPubkey}
<div class="menu-button-wrapper">
<button <button
class="menu-button" class="menu-button"
onclick={() => { onclick={() => {
@ -117,6 +122,80 @@
> >
<img src="/icons/more-vertical.svg" alt="" class="icon" /> <img src="/icons/more-vertical.svg" alt="" class="icon" />
</button> </button>
{#if showMoreMenu}
<div
class="more-menu-overlay"
onclick={() => showMoreMenu = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showMoreMenu = false;
}
}}
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<div class="more-menu">
{#if onFork}
<button class="menu-item" onclick={() => { onFork(); showMoreMenu = false; }} disabled={forking}>
{forking ? 'Forking...' : 'Fork'}
</button>
{/if}
{#if onCreateIssue}
<button class="menu-item" onclick={() => { onCreateIssue(); showMoreMenu = false; }}>
Create Issue
</button>
{/if}
{#if onCreatePR}
<button class="menu-item" onclick={() => { onCreatePR(); showMoreMenu = false; }}>
Create Pull Request
</button>
{/if}
{#if onCreatePatch}
<button class="menu-item" onclick={() => { onCreatePatch(); showMoreMenu = false; }}>
Create Patch
</button>
{/if}
{#if hasUnlimitedAccess && (isRepoCloned === false || isRepoCloned === null) && onCloneToServer}
<button
class="menu-item"
onclick={() => { onCloneToServer(); showMoreMenu = false; }}
disabled={cloning || checkingCloneStatus}
>
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')}
</button>
{/if}
{#if isMaintainer && onSettings}
<button class="menu-item" onclick={() => { onSettings(); showMoreMenu = false; }}>
Settings
</button>
{/if}
{#if onGenerateVerification}
<button class="menu-item" onclick={() => { onGenerateVerification(); showMoreMenu = false; }}>
Generate Verification File
</button>
{/if}
{#if isMaintainer && onCreateBranch}
<button
class="menu-item"
onclick={() => { onCreateBranch(); showMoreMenu = false; }}
disabled={needsClone}
>
Create New Branch
</button>
{/if}
{#if onDeleteAnnouncement}
<button
class="menu-item menu-item-danger"
onclick={() => { onDeleteAnnouncement(); showMoreMenu = false; }}
disabled={deletingAnnouncement}
>
{deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'}
</button>
{/if}
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -127,8 +206,57 @@
<div class="repo-meta"> <div class="repo-meta">
<div class="repo-owner"> <div class="repo-owner">
<button
class="owner-badge-button"
onclick={() => showOwnerMenu = !showOwnerMenu}
aria-expanded={showOwnerMenu}
aria-label="Show owners and maintainers"
>
<span class="meta-label">Owner:</span> <span class="meta-label">Owner:</span>
<UserBadge pubkey={ownerPubkey} disableLink={true} />
{#if allMaintainers.length > 1}
<span class="owner-badge-count">+{allMaintainers.length - 1}</span>
{/if}
</button>
{#if showOwnerMenu && (allMaintainers.length > 0 || ownerPubkey)}
<div
class="owner-menu-overlay"
onclick={() => showOwnerMenu = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showOwnerMenu = false;
}
}}
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<div class="owner-menu">
<div class="owner-menu-header">Owners & Maintainers</div>
<div class="owner-menu-list">
{#if allMaintainers.length > 0}
{#each allMaintainers as maintainer}
<div
class="owner-menu-item"
class:owner-menu-owner={maintainer.isOwner}
>
<UserBadge pubkey={maintainer.pubkey} />
{#if maintainer.isOwner}
<span class="owner-menu-badge owner">Owner</span>
{:else}
<span class="owner-menu-badge maintainer">Maintainer</span>
{/if}
</div>
{/each}
{:else}
<div class="owner-menu-item owner-menu-owner">
<UserBadge pubkey={ownerPubkey} /> <UserBadge pubkey={ownerPubkey} />
<span class="owner-menu-badge owner">Owner</span>
</div>
{/if}
</div>
</div>
{/if}
</div> </div>
{#if cloneUrls.length > 0} {#if cloneUrls.length > 0}
@ -214,356 +342,4 @@
{/if} {/if}
</div> </div>
{#if showMoreMenu && userPubkey}
<div
class="more-menu-overlay"
onclick={() => showMoreMenu = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showMoreMenu = false;
}
}}
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<div class="more-menu">
{#if onFork}
<button class="menu-item" onclick={() => { onFork(); showMoreMenu = false; }} disabled={forking}>
{forking ? 'Forking...' : 'Fork'}
</button>
{/if}
{#if onCreateIssue}
<button class="menu-item" onclick={() => { onCreateIssue(); showMoreMenu = false; }}>
Create Issue
</button>
{/if}
{#if onCreatePR}
<button class="menu-item" onclick={() => { onCreatePR(); showMoreMenu = false; }}>
Create Pull Request
</button>
{/if}
{#if onCreatePatch}
<button class="menu-item" onclick={() => { onCreatePatch(); showMoreMenu = false; }}>
Create Patch
</button>
{/if}
{#if hasUnlimitedAccess && (isRepoCloned === false || isRepoCloned === null) && onCloneToServer}
<button
class="menu-item"
onclick={() => { onCloneToServer(); showMoreMenu = false; }}
disabled={cloning || checkingCloneStatus}
>
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')}
</button>
{/if}
{#if isMaintainer && onSettings}
<button class="menu-item" onclick={() => { onSettings(); showMoreMenu = false; }}>
Settings
</button>
{/if}
{#if onGenerateVerification}
<button class="menu-item" onclick={() => { onGenerateVerification(); showMoreMenu = false; }}>
Generate Verification File
</button>
{/if}
{#if onDeleteAnnouncement}
<button
class="menu-item menu-item-danger"
onclick={() => { onDeleteAnnouncement(); showMoreMenu = false; }}
disabled={deletingAnnouncement}
>
{deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'}
</button>
{/if}
{#if isMaintainer && onCreateBranch}
<button
class="menu-item"
onclick={() => { onCreateBranch(); showMoreMenu = false; }}
disabled={needsClone}
>
Create New Branch
</button>
{/if}
</div>
{/if}
</header> </header>
<style>
.repo-header {
padding: 0.75rem 1rem;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
position: sticky;
top: 0;
z-index: 100;
}
.repo-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
.repo-title-section {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.repo-name {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
word-break: break-word;
}
.repo-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
font-weight: 500;
}
.repo-badge.private {
background: var(--error-bg, #fee);
color: var(--error-text, #c00);
}
.bookmark-button {
padding: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
}
.bookmark-button.bookmarked img {
filter: brightness(0) saturate(100%) invert(67%) sepia(93%) saturate(1352%) hue-rotate(358deg) brightness(102%) contrast(106%);
}
.repo-header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.menu-button,
.clone-button,
.branch-button,
.copy-clone-button {
padding: 0.5rem;
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
transition: all 0.2s ease;
}
.menu-button:hover,
.clone-button:hover,
.branch-button:hover,
.copy-clone-button:hover {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--accent, #007bff);
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.repo-description {
margin: 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary, #666);
line-height: 1.5;
}
.repo-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: 0.75rem;
font-size: 0.875rem;
}
.repo-owner {
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta-label {
color: var(--text-secondary, #666);
}
.repo-clone,
.repo-branch {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.clone-menu,
.branch-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
min-width: 200px;
max-width: 90vw;
max-height: 300px;
overflow-y: auto;
}
.clone-url-item,
.branch-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
word-break: break-all;
}
.branch-item {
display: flex;
justify-content: space-between;
align-items: center;
word-break: normal;
}
.branch-item.active {
background: var(--bg-secondary, #f5f5f5);
font-weight: 600;
}
.branch-badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 0.25rem;
color: var(--text-secondary, #666);
}
.clone-url-item:last-child,
.branch-item:last-child {
border-bottom: none;
}
.clone-url-item:hover,
.branch-item:hover {
background: var(--bg-secondary, #f5f5f5);
}
.delete-branch-button {
padding: 0.25rem 0.5rem;
background: var(--error-text, #dc2626);
color: #ffffff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.delete-branch-button:hover {
background: var(--error-hover, #c82333);
}
.more-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.more-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 200px;
}
.menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover:not(:disabled) {
background: var(--bg-secondary, #f5f5f5);
}
.menu-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.menu-item-danger {
color: var(--error-text, #dc2626);
}
.menu-item-danger:hover:not(:disabled) {
background: var(--error-bg, #fee);
}
@media (min-width: 768px) {
.repo-header {
padding: 1rem 1.5rem;
}
.repo-name {
font-size: 1.5rem;
}
.repo-description {
font-size: 1rem;
}
}
</style>

177
src/lib/components/RepoTabs.svelte

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import '$lib/styles/components.css';
interface Props { interface Props {
activeTab: string; activeTab: string;
tabs: Array<{ id: string; label: string; icon?: string; count?: number }>; tabs: Array<{ id: string; label: string; icon?: string; count?: number }>;
@ -10,6 +11,20 @@
</script> </script>
<nav class="repo-tabs"> <nav class="repo-tabs">
<!-- Mobile tab menu button -->
<button
class="mobile-tabs-menu-button"
onclick={() => showMobileMenu = !showMobileMenu}
aria-expanded={showMobileMenu}
aria-label="Tab menu"
title={tabs.find(t => t.id === activeTab)?.label || 'Menu'}
>
<img src="/icons/menu.svg" alt="" class="icon" />
<span class="current-tab-label">
{tabs.find(t => t.id === activeTab)?.label || 'Menu'}
</span>
</button>
<div class="tabs-container"> <div class="tabs-container">
{#each tabs as tab} {#each tabs as tab}
<button <button
@ -32,19 +47,6 @@
{/each} {/each}
</div> </div>
<!-- Mobile menu button -->
<button
class="mobile-menu-button"
onclick={() => showMobileMenu = !showMobileMenu}
aria-expanded={showMobileMenu}
aria-label="Tab menu"
>
<img src="/icons/menu.svg" alt="" class="icon" />
<span class="current-tab-label">
{tabs.find(t => t.id === activeTab)?.label || 'Menu'}
</span>
</button>
{#if showMobileMenu} {#if showMobileMenu}
<div class="mobile-tabs-menu"> <div class="mobile-tabs-menu">
{#each tabs as tab} {#each tabs as tab}
@ -68,152 +70,3 @@
</div> </div>
{/if} {/if}
</nav> </nav>
<style>
.repo-tabs {
position: relative;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tabs-container {
display: none;
gap: 0;
}
.tab-button {
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary, #666);
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
transition: all 0.2s ease;
position: relative;
}
.tab-button:hover {
color: var(--text-primary, #1a1a1a);
background: var(--bg-secondary, #f5f5f5);
}
.tab-button.active {
color: var(--accent, #007bff);
border-bottom-color: var(--accent, #007bff);
font-weight: 600;
}
.tab-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.tab-label {
display: none;
}
.tab-count {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
}
.tab-button.active .tab-count {
background: var(--accent, #007bff);
color: var(--accent-text, #ffffff);
}
.mobile-menu-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
font-weight: 500;
}
.mobile-menu-button .icon {
width: 18px;
height: 18px;
}
.current-tab-label {
flex: 1;
text-align: left;
}
.mobile-tabs-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
z-index: 50;
max-height: 70vh;
overflow-y: auto;
}
.mobile-tab-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
text-align: left;
transition: background 0.2s ease;
}
.mobile-tab-item:hover {
background: var(--bg-secondary, #f5f5f5);
}
.mobile-tab-item.active {
background: var(--bg-secondary, #f5f5f5);
color: var(--accent, #007bff);
font-weight: 600;
}
.mobile-tab-item .tab-count {
margin-left: auto;
}
@media (min-width: 768px) {
.tabs-container {
display: flex;
}
.mobile-menu-button,
.mobile-tabs-menu {
display: none;
}
.tab-label {
display: inline;
}
}
</style>

32
src/lib/components/UserBadge.svelte

@ -9,9 +9,10 @@
interface Props { interface Props {
pubkey: string; pubkey: string;
disableLink?: boolean; disableLink?: boolean;
inline?: boolean;
} }
let { pubkey, disableLink = false }: Props = $props(); let { pubkey, disableLink = false, inline = false }: Props = $props();
// Convert pubkey to npub for navigation (reactive) // Convert pubkey to npub for navigation (reactive)
const profileUrl = $derived.by(() => { const profileUrl = $derived.by(() => {
@ -183,7 +184,21 @@
} }
</script> </script>
{#if inline}
{#if disableLink} {#if disableLink}
<span class="user-badge-inline">{truncateHandle(userProfile?.name) || getShortNpub()}</span>
{:else}
<a
href={profileUrl}
class="user-badge-inline"
onclick={(e) => {
e.stopPropagation();
}}
>
{truncateHandle(userProfile?.name) || getShortNpub()}
</a>
{/if}
{:else if disableLink}
<div class="user-badge"> <div class="user-badge">
{#if userProfile?.picture} {#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="user-badge-avatar" /> <img src={userProfile.picture} alt="Profile" class="user-badge-avatar" />
@ -249,6 +264,18 @@
white-space: nowrap; white-space: nowrap;
} }
.user-badge-inline {
display: inline;
color: var(--accent);
text-decoration: none;
font-weight: 500;
font-size: inherit;
}
.user-badge-inline:hover {
text-decoration: underline;
}
/* Hide name on narrow screens, show only picture */ /* Hide name on narrow screens, show only picture */
@media (max-width: 768px) { @media (max-width: 768px) {
.user-badge-name { .user-badge-name {
@ -257,6 +284,9 @@
.user-badge { .user-badge {
padding: 0.25rem; padding: 0.25rem;
width: fit-content;
display: inline-flex;
flex-shrink: 0;
} }
} }
</style> </style>

2
src/lib/services/git/repo-manager.ts

@ -3,7 +3,7 @@
* Handles repo provisioning, syncing, and NIP-34 integration * Handles repo provisioning, syncing, and NIP-34 integration
*/ */
import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync } from 'fs'; import { existsSync, mkdirSync, statSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { readdir, readFile } from 'fs/promises'; import { readdir, readFile } from 'fs/promises';
import { spawn } from 'child_process'; import { spawn } from 'child_process';

737
src/lib/styles/components.css

@ -0,0 +1,737 @@
/* Component Styles - Shared across components */
/* RepoHeaderEnhanced Component */
.repo-header {
padding: 0.75rem 1rem;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
position: sticky;
top: 0;
z-index: 100;
}
@media (max-width: 768px) {
.repo-header {
padding: 0.5rem 0.75rem;
}
}
.repo-header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.5rem;
}
@media (max-width: 768px) {
.repo-header-top {
margin-bottom: 0.25rem;
gap: 0.5rem;
}
}
.repo-title-section {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.repo-name {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
word-break: break-word;
}
.repo-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
font-weight: 500;
}
.repo-badge.private {
background: var(--error-bg, #fee);
color: var(--error-text, #c00);
}
.bookmark-button {
padding: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
}
.bookmark-button.bookmarked img {
filter: brightness(0) saturate(100%) invert(67%) sepia(93%) saturate(1352%) hue-rotate(358deg) brightness(102%) contrast(106%);
}
.repo-header-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.menu-button-wrapper {
position: relative;
}
.menu-button,
.clone-button,
.branch-button,
.copy-clone-button {
position: relative;
padding: 0.5rem;
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
transition: all 0.2s ease;
}
.menu-button:hover,
.clone-button:hover,
.branch-button:hover,
.copy-clone-button:hover {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--accent, #007bff);
}
.icon {
width: 18px;
height: 18px;
flex-shrink: 0;
/* Theme-aware icon colors */
filter: brightness(0) saturate(100%) invert(1); /* Default white for dark themes */
opacity: 1;
}
/* Light theme: black icon */
:global([data-theme="light"]) .icon {
filter: brightness(0) saturate(100%); /* Black in light theme */
opacity: 1;
}
/* Dark themes: white icon */
:global([data-theme="dark"]) .icon,
:global([data-theme="black"]) .icon {
filter: brightness(0) saturate(100%) invert(1); /* White in dark themes */
opacity: 1;
}
.repo-description {
margin: 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary, #666);
line-height: 1.5;
}
@media (max-width: 768px) {
.repo-description {
margin: 0.25rem 0;
}
}
.repo-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: 0.75rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.repo-meta {
margin-top: 0.5rem;
gap: 0.75rem;
}
}
.repo-owner {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-badge-button {
display: flex;
align-items: center;
gap: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: background 0.2s ease;
color: inherit;
font-size: inherit;
}
.owner-badge-button:hover {
background: var(--bg-secondary, #f5f5f5);
}
.owner-badge-button .meta-label {
color: var(--text-secondary, #666);
}
.owner-badge-count {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
}
.owner-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.owner-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 250px;
max-width: 400px;
}
.owner-menu-header {
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.owner-menu-list {
max-height: 300px;
overflow-y: auto;
}
.owner-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
transition: background 0.2s ease;
}
.owner-menu-item:last-child {
border-bottom: none;
}
.owner-menu-item:hover {
background: var(--bg-secondary, #f5f5f5);
}
.owner-menu-item :global(.user-badge) {
flex: 1;
min-width: 0;
}
.owner-menu-item :global(a.user-badge) {
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
gap: 0.5rem;
}
.owner-menu-item :global(a.user-badge:hover) {
text-decoration: none;
opacity: 0.8;
}
.owner-menu-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
flex-shrink: 0;
}
.owner-menu-badge.owner {
background: var(--accent-bg, #e7f3ff);
color: var(--accent, #007bff);
}
.owner-menu-badge.maintainer {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary, #666);
}
.meta-label {
color: var(--text-secondary, #666);
}
.repo-clone,
.repo-branch {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.clone-menu,
.branch-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
min-width: 200px;
max-width: 90vw;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 768px) {
.clone-menu {
max-width: calc(100vw - 1.5rem);
min-width: min(200px, calc(100vw - 1.5rem));
}
.clone-url-item {
font-size: 0.8125rem;
padding: 0.5rem;
}
}
.clone-url-item,
.branch-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
word-break: break-all;
overflow-wrap: break-word;
white-space: normal;
line-height: 1.4;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
}
.branch-item {
display: flex;
justify-content: space-between;
align-items: center;
word-break: normal;
}
.branch-item.active {
background: var(--bg-secondary, #f5f5f5);
font-weight: 600;
}
.branch-badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 0.25rem;
color: var(--text-secondary, #666);
}
.clone-url-item:last-child,
.branch-item:last-child {
border-bottom: none;
}
.clone-url-item:hover,
.branch-item:hover {
background: var(--bg-secondary, #f5f5f5);
}
.delete-branch-button {
padding: 0.25rem 0.5rem;
background: var(--error-text, #dc2626);
color: #ffffff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.delete-branch-button:hover {
background: var(--error-hover, #c82333);
}
.more-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.more-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
background: var(--card-bg, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 0.375rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 240px;
max-width: min(90vw, 360px);
}
/* On mobile, ensure menu doesn't overflow screen */
@media (max-width: 768px) {
.more-menu {
right: 0;
max-width: calc(100vw - 2rem);
min-width: 220px;
}
}
/* On very small screens, position menu to not overflow */
@media (max-width: 480px) {
.more-menu {
right: 0;
max-width: calc(100vw - 1rem);
min-width: auto;
}
}
.menu-item {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
text-align: left;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover:not(:disabled) {
background: var(--bg-secondary, #f5f5f5);
}
.menu-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.menu-item-danger {
color: var(--error-text, #dc2626);
}
.menu-item-danger:hover:not(:disabled) {
background: var(--error-bg, #fee);
}
/* Icon button - icon-only button style */
.icon-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
min-width: auto;
width: auto;
}
.icon-button .icon {
width: 18px;
height: 18px;
margin: 0;
}
@media (min-width: 768px) {
.repo-header {
padding: 1rem 1.5rem;
}
.repo-name {
font-size: 1.5rem;
}
.repo-description {
font-size: 1rem;
}
}
/* RepoTabs Component */
.repo-tabs {
position: relative;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.mobile-tabs-menu-button {
display: none;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
font-weight: 500;
}
.mobile-tabs-menu-button .icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.current-tab-label {
flex: 1;
text-align: left;
}
.tabs-container {
display: none;
gap: 0;
}
.tab-button {
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary, #666);
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
transition: all 0.2s ease;
position: relative;
}
.tab-button:hover {
color: var(--text-primary, #1a1a1a);
background: var(--bg-secondary, #f5f5f5);
}
.tab-button.active {
color: var(--accent, #007bff);
border-bottom-color: var(--accent, #007bff);
font-weight: 600;
}
.tab-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
/* Theme-aware icon colors */
filter: brightness(0) saturate(100%) invert(1); /* Default white for dark themes */
opacity: 1;
}
/* Light theme: black icon */
:global([data-theme="light"]) .tab-icon {
filter: brightness(0) saturate(100%); /* Black in light theme */
opacity: 1;
}
/* Dark themes: white icon */
:global([data-theme="dark"]) .tab-icon,
:global([data-theme="black"]) .tab-icon {
filter: brightness(0) saturate(100%) invert(1); /* White in dark themes */
opacity: 1;
}
/* Active tab: match text color (accent color) */
.tab-button.active .tab-icon {
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(200deg) brightness(118%) contrast(119%);
opacity: 1;
}
.tab-label {
display: none;
}
.tab-count {
padding: 0.125rem 0.375rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #666);
}
.tab-button.active .tab-count {
background: var(--accent, #007bff);
color: var(--accent-text, #ffffff);
}
.mobile-menu-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
font-weight: 500;
}
.mobile-menu-button .icon {
width: 20px;
height: 20px;
}
@media (max-width: 480px) {
.mobile-menu-button .current-tab-label {
display: none;
}
.mobile-menu-button {
justify-content: center;
}
}
.mobile-menu-button .icon {
width: 18px;
height: 18px;
}
.current-tab-label {
flex: 1;
text-align: left;
}
.mobile-tabs-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--card-bg, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
z-index: 50;
max-height: 70vh;
overflow-y: auto;
min-width: 200px;
max-width: 100vw;
}
.mobile-tab-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
width: 100%;
min-width: 0;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #1a1a1a);
text-align: left;
transition: background 0.2s ease;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
}
.mobile-tab-item span {
flex: 1;
min-width: 0;
word-wrap: break-word;
overflow-wrap: break-word;
}
.mobile-tab-item:hover {
background: var(--bg-secondary, #f5f5f5);
}
.mobile-tab-item.active {
background: var(--bg-secondary, #f5f5f5);
color: var(--accent, #007bff);
font-weight: 600;
}
.mobile-tab-item .tab-count {
margin-left: auto;
}
@media (min-width: 768px) {
.tabs-container {
display: flex;
}
.mobile-tabs-menu-button,
.mobile-tabs-menu {
display: none;
}
.tab-label {
display: inline;
}
}
@media (max-width: 767px) {
.tabs-container {
display: none;
}
.mobile-tabs-menu-button {
display: flex;
}
}

1879
src/lib/styles/repo.css

File diff suppressed because it is too large Load Diff

3
src/routes/repos/+page.svelte

@ -37,7 +37,7 @@
let mostFavoritedRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; favoriteCount: number }>>([]); let mostFavoritedRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; favoriteCount: number }>>([]);
let loadingMostFavorited = $state(false); let loadingMostFavorited = $state(false);
let mostFavoritedPage = $state(0); let mostFavoritedPage = $state(0);
let mostFavoritedCache: { data: typeof mostFavoritedRepos; timestamp: number } | null = null; let mostFavoritedCache = $state<{ data: typeof mostFavoritedRepos; timestamp: number } | null>(null);
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
@ -962,6 +962,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }

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

File diff suppressed because it is too large Load Diff

595
src/routes/users/[npub]/+page.svelte

@ -89,21 +89,22 @@
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // npub -> pubkey hex let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // npub -> pubkey hex
// Parse nostr: links from content and extract IDs/pubkeys // Parse nostr: links from content and extract IDs/pubkeys
function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile' | 'nprofile'; value: string; start: number; end: number }> {
const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile' | 'nprofile'; value: string; start: number; end: number }> = [];
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1|nprofile1)[a-zA-Z0-9]+/g;
let match; let match;
while ((match = nostrLinkRegex.exec(content)) !== null) { while ((match = nostrLinkRegex.exec(content)) !== null) {
const fullMatch = match[0]; const fullMatch = match[0];
const prefix = match[1]; const prefix = match[1];
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile' | 'nprofile';
if (prefix === 'nevent1') type = 'nevent'; if (prefix === 'nevent1') type = 'nevent';
else if (prefix === 'naddr1') type = 'naddr'; else if (prefix === 'naddr1') type = 'naddr';
else if (prefix === 'note1') type = 'note1'; else if (prefix === 'note1') type = 'note1';
else if (prefix === 'npub1') type = 'npub'; else if (prefix === 'npub1') type = 'npub';
else if (prefix === 'profile1') type = 'profile'; else if (prefix === 'profile1') type = 'profile';
else if (prefix === 'nprofile1') type = 'nprofile';
else continue; else continue;
links.push({ links.push({
@ -141,11 +142,18 @@
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
aTags.push(aTag); aTags.push(aTag);
} }
} else if (link.type === 'npub' || link.type === 'profile') { } else if (link.type === 'npub' || link.type === 'profile' || link.type === 'nprofile') {
const decoded = nip19.decode(link.value.replace('nostr:', '')); const decoded = nip19.decode(link.value.replace('nostr:', ''));
if (decoded.type === 'npub') { if (decoded.type === 'npub') {
npubs.push(link.value); npubs.push(link.value);
nostrLinkProfiles.set(link.value, decoded.data as string); nostrLinkProfiles.set(link.value, decoded.data as string);
} else if (decoded.type === 'nprofile') {
// nprofile contains { pubkey: string, relays?: string[] }
const pubkey = (decoded.data as { pubkey: string }).pubkey;
if (pubkey) {
npubs.push(link.value);
nostrLinkProfiles.set(link.value, pubkey);
}
} }
} }
} catch { } catch {
@ -222,9 +230,29 @@
return undefined; return undefined;
} }
// Get pubkey from nostr: npub/profile link // Get pubkey from nostr: npub/profile/nprofile link
function getPubkeyFromNostrLink(link: string): string | undefined { function getPubkeyFromNostrLink(link: string): string | undefined {
return nostrLinkProfiles.get(link); // Check cache first
const cached = nostrLinkProfiles.get(link);
if (cached) return cached;
// If not in cache, try to decode nprofile on the fly
if (link.startsWith('nostr:nprofile1')) {
try {
const decoded = nip19.decode(link.replace('nostr:', ''));
if (decoded.type === 'nprofile') {
const pubkey = (decoded.data as { pubkey: string }).pubkey;
if (pubkey) {
nostrLinkProfiles.set(link, pubkey);
return pubkey;
}
}
} catch {
// Invalid link
}
}
return undefined;
} }
// Process content with nostr links into parts for rendering // Process content with nostr links into parts for rendering
@ -843,13 +871,22 @@ i *
const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety
loadingActivity = true; loadingActivity = true;
try { try {
// Step 1: Fetch all repo announcements where user is owner or maintainer // Step 1: Fetch repo announcements in parallel (reduced limit)
const repoAnnouncements = await nostrClient.fetchEvents([ const [repoAnnouncements, allAnnouncements] = await Promise.all([
nostrClient.fetchEvents([
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [userPubkey], authors: [userPubkey],
limit: 100 limit: 50 // Reduced from 100
} }
]),
nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
'#p': [userPubkey],
limit: 50 // Reduced from 100
}
])
]); ]);
// Step 2: Extract a-tags from repo announcements // Step 2: Extract a-tags from repo announcements
@ -862,22 +899,11 @@ i *
} }
} }
// Step 3: Also check for repos where user is a maintainer (not just owner) // Step 3: Check for repos where user is a maintainer
// We'll fetch announcements and check maintainer tags
const allAnnouncements = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
'#p': [userPubkey], // Events that mention the user
limit: 100
}
]);
for (const announcement of allAnnouncements) { for (const announcement of allAnnouncements) {
// Check if user is in maintainers tag
const maintainersTag = announcement.tags.find(t => t[0] === 'maintainers'); const maintainersTag = announcement.tags.find(t => t[0] === 'maintainers');
if (maintainersTag) { if (maintainersTag) {
const isMaintainer = maintainersTag.slice(1).some(m => { const isMaintainer = maintainersTag.slice(1).some(m => {
// Handle both hex and npub formats
try { try {
const decoded = nip19.decode(m); const decoded = nip19.decode(m);
if (decoded.type === 'npub') { if (decoded.type === 'npub') {
@ -899,89 +925,88 @@ i *
} }
} }
// Step 4: Fetch events that reference the user or their repos // Step 4: Fetch events that reference the user or their repos (reduced limits)
const filters: any[] = []; const filters: any[] = [];
// Events with user in p-tag // Events with user in p-tag
filters.push({ filters.push({
'#p': [userPubkey], '#p': [userPubkey],
limit: 200 limit: 100 // Reduced from 200
}); });
// Events with user in q-tag // Events with user in q-tag
filters.push({ filters.push({
'#q': [userPubkey], '#q': [userPubkey],
limit: 200 limit: 100 // Reduced from 200
}); });
// Events with repo a-tags // Events with repo a-tags
if (aTags.size > 0) { if (aTags.size > 0) {
filters.push({ filters.push({
'#a': Array.from(aTags), '#a': Array.from(aTags),
limit: 200 limit: 100 // Reduced from 200
}); });
} }
const allActivityEvents = await nostrClient.fetchEvents(filters); const allActivityEvents = await Promise.race([
nostrClient.fetchEvents(filters),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 15000)) // 15s timeout
]);
// Step 5: Deduplicate, filter, and sort by created_at (newest first) // Step 5: Deduplicate, filter, and sort by created_at (newest first)
const eventMap = new Map<string, NostrEvent>(); const eventMap = new Map<string, NostrEvent>();
for (const event of allActivityEvents) { for (const event of allActivityEvents) {
// Use shared exclusion function to filter out:
// - User's own events
// - Ephemeral events (20000-29999)
// - Replaceable events (0, 3, 10000-19999) - metadata/configuration
// - Non-repo regular kinds (1, 2, 5, 6, 7, 8, 24)
if (shouldExcludeEvent(event, userPubkey, true)) { if (shouldExcludeEvent(event, userPubkey, true)) {
continue; continue;
} }
// Keep the newest version if duplicate
const existing = eventMap.get(event.id); const existing = eventMap.get(event.id);
if (!existing || event.created_at > existing.created_at) { if (!existing || event.created_at > existing.created_at) {
eventMap.set(event.id, event); eventMap.set(event.id, event);
} }
} }
// Sort by created_at descending and limit to 200 // Sort by created_at descending and limit to 50 (reduced from 200)
activityEvents = Array.from(eventMap.values()) activityEvents = Array.from(eventMap.values())
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.slice(0, 200); .slice(0, 50);
// Fetch referenced events from a-tags and e-tags // Step 6: Load referenced events and nostr links in parallel (limited)
await loadReferencedEvents(activityEvents); await Promise.all([
loadReferencedEvents(activityEvents.slice(0, 50)), // Only load for first 50
// Fetch nostr: links from event content // Batch load nostr links for all events at once
for (const event of activityEvents) { Promise.all(activityEvents.slice(0, 50).map(event =>
if (event.content) { event.content ? loadNostrLinks(event.content) : Promise.resolve()
await loadNostrLinks(event.content); ))
} ]);
}
} catch (err) { } catch (err) {
console.error('Failed to load activity:', err); console.error('Failed to load activity:', err);
error = 'Failed to load activity'; error = 'Failed to load activity';
} finally { } finally {
activityLoaded = true; // Mark as loaded to prevent infinite loop (even on error) activityLoaded = true;
loadingActivity = false; loadingActivity = false;
} }
} }
async function loadReferencedEvents(events: NostrEvent[]) { async function loadReferencedEvents(events: NostrEvent[]) {
// Limit to first 50 events to avoid too many queries
const eventsToProcess = events.slice(0, 50);
// Collect all referenced event IDs and a-tags // Collect all referenced event IDs and a-tags
const eventIds = new Set<string>(); const eventIds = new Set<string>();
const aTags = new Set<string>(); const aTags = new Set<string>();
for (const event of events) { for (const event of eventsToProcess) {
// Collect e-tags (event references) // Collect e-tags (event references) - limit to first 5 per event
const eTags = event.tags.filter(t => t[0] === 'e' && t[1]); const eTags = event.tags.filter(t => t[0] === 'e' && t[1]).slice(0, 5);
for (const eTag of eTags) { for (const eTag of eTags) {
if (eTag[1]) { if (eTag[1]) {
eventIds.add(eTag[1]); eventIds.add(eTag[1]);
} }
} }
// Collect a-tags (addressable event references) // Collect a-tags (addressable event references) - limit to first 5 per event
const aTagValues = event.tags.filter(t => t[0] === 'a' && t[1]); const aTagValues = event.tags.filter(t => t[0] === 'a' && t[1]).slice(0, 5);
for (const aTag of aTagValues) { for (const aTag of aTagValues) {
if (aTag[1]) { if (aTag[1]) {
aTags.add(aTag[1]); aTags.add(aTag[1]);
@ -991,53 +1016,81 @@ i *
if (eventIds.size === 0 && aTags.size === 0) return; if (eventIds.size === 0 && aTags.size === 0) return;
// Fetch events by ID // Limit total references to prevent too many queries
const limitedEventIds = Array.from(eventIds).slice(0, 50);
const limitedATags = Array.from(aTags).slice(0, 50);
// Fetch events by ID (single batch query)
const eventsToFetch: Promise<NostrEvent[]>[] = []; const eventsToFetch: Promise<NostrEvent[]>[] = [];
if (eventIds.size > 0) { if (limitedEventIds.length > 0) {
eventsToFetch.push( eventsToFetch.push(
Promise.race([
nostrClient.fetchEvents([ nostrClient.fetchEvents([
{ {
ids: Array.from(eventIds), ids: limitedEventIds,
limit: eventIds.size limit: limitedEventIds.length
} }
]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 8000))
]).catch(() => []) ]).catch(() => [])
); );
} }
// Fetch events by a-tags // Batch a-tags by kind and author to reduce queries
if (aTags.size > 0) { if (limitedATags.length > 0) {
for (const aTag of aTags) { const aTagGroups = new Map<string, Set<string>>(); // key: "kind:pubkey", value: Set of d-tags
for (const aTag of limitedATags) {
const parts = aTag.split(':'); const parts = aTag.split(':');
if (parts.length === 3) { if (parts.length === 3) {
const kind = parseInt(parts[0]); const kind = parts[0];
const pubkey = parts[1]; const pubkey = parts[1];
const dTag = parts[2]; const dTag = parts[2];
const key = `${kind}:${pubkey}`;
if (!aTagGroups.has(key)) {
aTagGroups.set(key, new Set());
}
aTagGroups.get(key)!.add(dTag);
}
}
// Fetch events by grouped a-tags (one query per kind:pubkey combination)
for (const [key, dTags] of aTagGroups.entries()) {
const parts = key.split(':');
const kind = parseInt(parts[0]);
const pubkey = parts[1];
// Limit d-tags per query to 10
const dTagsArray = Array.from(dTags).slice(0, 10);
eventsToFetch.push( eventsToFetch.push(
Promise.race([
nostrClient.fetchEvents([ nostrClient.fetchEvents([
{ {
kinds: [kind], kinds: [kind],
authors: [pubkey], authors: [pubkey],
'#d': [dTag], '#d': dTagsArray,
limit: 1 limit: dTagsArray.length
} }
]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 8000))
]).catch(() => []) ]).catch(() => [])
); );
} }
} }
}
// Fetch all referenced events // Fetch all referenced events with timeout
try { try {
const fetchPromises = eventsToFetch.map(p => const fetchedEventsArrays = await Promise.all(
eventsToFetch.map(p =>
Promise.race([ Promise.race([
p, p,
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 8000))
]) ])
)
); );
const fetchedEventsArrays = await Promise.all(fetchPromises);
const fetchedEvents = fetchedEventsArrays.flat(); const fetchedEvents = fetchedEventsArrays.flat();
// Update cache reactively - add new events, avoid duplicates // Update cache reactively - add new events, avoid duplicates
@ -1051,6 +1104,97 @@ i *
} }
} }
// Extract repo info from a-tag
function getRepoInfoFromATag(aTag: string): { ownerPubkey: string; repoName: string } | null {
const parts = aTag.split(':');
if (parts.length >= 3) {
const kind = parseInt(parts[0]);
if (kind === KIND.REPO_ANNOUNCEMENT) {
return {
ownerPubkey: parts[1],
repoName: parts[2]
};
}
}
return null;
}
// Get repo info from event (checks a-tags)
function getRepoInfo(event: NostrEvent): { ownerPubkey: string; repoName: string; ownerNpub: string } | null {
const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1];
if (aTag) {
const repoInfo = getRepoInfoFromATag(aTag);
if (repoInfo) {
try {
const ownerNpub = nip19.npubEncode(repoInfo.ownerPubkey);
return {
...repoInfo,
ownerNpub
};
} catch {
// If encoding fails, return null instead of incomplete object
return null;
}
}
}
return null;
}
// Get git event type name
function getGitEventTypeName(kind: number): string {
switch (kind) {
case KIND.ISSUE:
return 'Issue';
case KIND.PULL_REQUEST:
return 'Pull Request';
case KIND.PULL_REQUEST_UPDATE:
return 'Pull Request Update';
case KIND.PATCH:
return 'Patch';
case KIND.STATUS_OPEN:
return 'Status: Open';
case KIND.STATUS_APPLIED:
return 'Status: Applied';
case KIND.STATUS_CLOSED:
return 'Status: Closed';
case KIND.STATUS_DRAFT:
return 'Status: Draft';
default:
return `Event (kind ${kind})`;
}
}
// Get status from event tags
function getEventStatus(event: NostrEvent): string | null {
const statusTag = event.tags.find(t => t[0] === 'status' && t[1]);
if (statusTag?.[1]) {
return statusTag[1];
}
// Check if kind itself indicates status
if (event.kind === KIND.STATUS_OPEN) return 'open';
if (event.kind === KIND.STATUS_APPLIED) return 'applied';
if (event.kind === KIND.STATUS_CLOSED) return 'closed';
if (event.kind === KIND.STATUS_DRAFT) return 'draft';
return null;
}
// Check if event is git-related
function isGitEvent(kind: number): boolean {
const gitKinds = [
KIND.ISSUE,
KIND.PULL_REQUEST,
KIND.PULL_REQUEST_UPDATE,
KIND.PATCH,
KIND.STATUS_OPEN,
KIND.STATUS_APPLIED,
KIND.STATUS_CLOSED,
KIND.STATUS_DRAFT
];
return gitKinds.includes(kind as any);
}
function getEventContext(event: NostrEvent): string { function getEventContext(event: NostrEvent): string {
// Special handling for reaction events (kind 7) // Special handling for reaction events (kind 7)
if (event.kind === 7) { if (event.kind === 7) {
@ -1446,6 +1590,15 @@ i *
} }
} }
async function copyNpub() {
try {
await navigator.clipboard.writeText(npub);
alert('Npub copied to clipboard!');
} catch (err) {
console.error('Failed to copy npub:', err);
}
}
const isOwnProfile = $derived(viewerPubkeyHex === profileOwnerPubkeyHex); const isOwnProfile = $derived(viewerPubkeyHex === profileOwnerPubkeyHex);
// Sort payment targets with lightning first // Sort payment targets with lightning first
@ -1496,6 +1649,14 @@ i *
{/if} {/if}
<div class="profile-meta"> <div class="profile-meta">
<code class="profile-npub">{npub}</code> <code class="profile-npub">{npub}</code>
<button
class="copy-button copy-npub-button"
onclick={copyNpub}
title="Copy npub"
aria-label="Copy npub"
>
<img src="/icons/copy.svg" alt="Copy" class="icon-themed" />
</button>
</div> </div>
</div> </div>
@ -1810,7 +1971,7 @@ i *
<div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div> <div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div>
</div> </div>
{:else if part.type === 'profile' && part.pubkey} {:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} /> <UserBadge pubkey={part.pubkey} inline={true} />
{:else} {:else}
<span class="nostr-link-placeholder">{part.value}</span> <span class="nostr-link-placeholder">{part.value}</span>
{/if} {/if}
@ -1904,13 +2065,53 @@ i *
<div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div> <div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div>
</div> </div>
{:else if part.type === 'profile' && part.pubkey} {:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} /> <UserBadge pubkey={part.pubkey} inline={true} />
{:else} {:else}
<span class="nostr-link-placeholder">{part.value}</span> <span class="nostr-link-placeholder">{part.value}</span>
{/if} {/if}
{/each} {/each}
</div> </div>
{/if} {/if}
{:else if isGitEvent(event.kind)}
{@const repoInfo = getRepoInfo(event)}
{@const eventType = getGitEventTypeName(event.kind)}
{@const status = getEventStatus(event)}
{@const subjectTag = event.tags.find(t => t[0] === 'subject' && t[1])?.[1]}
{@const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]}
{@const rootETag = event.tags.find(t => t[0] === 'e' && t[3] === 'root')?.[1]}
{@const referencedGitEvent = rootETag ? getReferencedEvent(rootETag) : null}
{@const referencedSubject = referencedGitEvent ? referencedGitEvent.tags.find(t => t[0] === 'subject' && t[1])?.[1] : null}
{@const displaySubject = subjectTag || referencedSubject}
<div class="git-event-display">
{#if repoInfo}
<div class="git-event-header">
<a href={`/repos/${repoInfo.ownerNpub}/${repoInfo.repoName}`} class="git-event-repo-link">
<span class="git-event-repo-name">{repoInfo.repoName}</span>
</a>
<span class="git-event-type">{eventType}</span>
{#if status}
<span class="git-event-status" class:status-open={status === 'open'} class:status-closed={status === 'closed'} class:status-applied={status === 'applied'} class:status-draft={status === 'draft'}>
{status}
</span>
{/if}
</div>
{:else}
<div class="git-event-header">
<span class="git-event-type">{eventType}</span>
{#if status}
<span class="git-event-status" class:status-open={status === 'open'} class:status-closed={status === 'closed'} class:status-applied={status === 'applied'} class:status-draft={status === 'draft'}>
{status}
</span>
{/if}
</div>
{/if}
{#if displaySubject}
<div class="git-event-subject">{displaySubject}</div>
{/if}
{#if event.content && event.content.trim()}
<div class="git-event-content">{event.content.trim().slice(0, 200)}{event.content.trim().length > 200 ? '...' : ''}</div>
{/if}
</div>
{:else} {:else}
{@const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]} {@const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]}
{@const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]} {@const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]}
@ -1939,7 +2140,7 @@ i *
<div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div> <div class="nostr-link-event-content">{part.event.content || getEventContext(part.event)}</div>
</div> </div>
{:else if part.type === 'profile' && part.pubkey} {:else if part.type === 'profile' && part.pubkey}
<UserBadge pubkey={part.pubkey} /> <UserBadge pubkey={part.pubkey} inline={true} />
{:else} {:else}
<span class="nostr-link-placeholder">{part.value}</span> <span class="nostr-link-placeholder">{part.value}</span>
{/if} {/if}
@ -2142,6 +2343,62 @@ i *
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@media (max-width: 768px) {
.profile-header {
grid-template-columns: 1fr;
gap: 1.5rem;
padding: 1.5rem;
}
.profile-avatar-section {
justify-self: center;
}
.profile-info {
text-align: center;
}
.profile-actions {
justify-content: center;
}
}
@media (max-width: 480px) {
.profile-header {
padding: 1rem;
gap: 1rem;
}
.profile-avatar,
.profile-avatar-placeholder {
width: 80px;
height: 80px;
}
.profile-avatar-placeholder {
font-size: 2rem;
}
.profile-name {
font-size: 1.5rem;
}
.profile-bio {
font-size: 1rem;
}
.profile-npub {
font-size: 0.75rem;
padding: 0.375rem 0.5rem;
word-break: break-all;
}
.action-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
.profile-avatar-section { .profile-avatar-section {
position: relative; position: relative;
} }
@ -2188,6 +2445,17 @@ i *
.profile-meta { .profile-meta {
margin-top: 1rem; margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
justify-content: center;
}
@media (min-width: 769px) {
.profile-meta {
justify-content: flex-start;
}
} }
.profile-npub { .profile-npub {
@ -2198,6 +2466,14 @@ i *
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
display: inline-block; display: inline-block;
word-break: break-all;
flex: 0 1 auto;
min-width: 0;
}
.copy-npub-button {
flex-shrink: 0;
flex-grow: 0;
} }
.profile-actions { .profile-actions {
@ -2626,6 +2902,18 @@ i *
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
flex: 1; flex: 1;
min-width: 0;
}
.message-participants :global(.user-badge) {
flex-shrink: 0;
}
@media (max-width: 768px) {
.message-participants :global(.user-badge) {
width: auto;
display: inline-flex;
}
} }
.participants-label { .participants-label {
@ -2691,21 +2979,25 @@ i *
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-left: 3px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.5rem; border-left: 3px solid var(--accent, #007bff);
border-radius: 0.375rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
:global([data-theme="light"]) .quoted-event { :global([data-theme="light"]) .quoted-event {
background: #e8e8e8; background: #f5f5f5;
border-color: #e0e0e0;
} }
:global([data-theme="dark"]) .quoted-event { :global([data-theme="dark"]) .quoted-event {
background: rgba(0, 0, 0, 0.2); background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
} }
:global([data-theme="black"]) .quoted-event { :global([data-theme="black"]) .quoted-event {
background: #0a0a0a; background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
} }
.quoted-event-header { .quoted-event-header {
@ -2713,26 +3005,136 @@ i *
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-wrap: wrap;
} }
.quoted-event-time { .quoted-event-time {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
margin-left: auto;
} }
.quoted-event-content { .quoted-event-content {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.5; line-height: 1.5;
max-height: 10rem; max-height: 8rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; position: relative;
}
.quoted-event-content::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2rem;
background: linear-gradient(to bottom, transparent, var(--bg-secondary));
pointer-events: none;
}
:global([data-theme="light"]) .quoted-event-content::after {
background: linear-gradient(to bottom, transparent, #f5f5f5);
}
:global([data-theme="dark"]) .quoted-event-content::after {
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.05));
}
:global([data-theme="black"]) .quoted-event-content::after {
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.03));
} }
.quoted-event-loading { .quoted-event-loading {
opacity: 0.6; opacity: 0.6;
font-style: italic; font-style: italic;
color: var(--text-muted);
}
/* Git Event Display */
.git-event-display {
padding: 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-left: 3px solid var(--accent, #007bff);
border-radius: 0.5rem;
}
.git-event-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.git-event-repo-link {
text-decoration: none;
color: inherit;
font-weight: 600;
transition: color 0.2s ease;
}
.git-event-repo-link:hover {
color: var(--accent, #007bff);
}
.git-event-repo-name {
font-size: 1rem;
color: var(--text-primary);
}
.git-event-type {
padding: 0.25rem 0.75rem;
background: var(--accent-bg, #e7f3ff);
color: var(--accent, #007bff);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
}
.git-event-status {
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
}
.git-event-status.status-open {
background: #d4edda;
color: #155724;
}
.git-event-status.status-closed {
background: #f8d7da;
color: #721c24;
}
.git-event-status.status-applied {
background: #d1ecf1;
color: #0c5460;
}
.git-event-status.status-draft {
background: #fff3cd;
color: #856404;
}
.git-event-subject {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.git-event-content {
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
} }
/* Activity */ /* Activity */
@ -2921,6 +3323,9 @@ i *
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.5rem; border-radius: 0.5rem;
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
max-height: 250px;
overflow: hidden;
position: relative;
} }
.repost-author { .repost-author {
@ -2939,7 +3344,34 @@ i *
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.6; line-height: 1.6;
max-height: calc(250px - 3rem);
overflow: hidden;
position: relative;
}
.repost-text::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2rem;
background: linear-gradient(to bottom, transparent, var(--bg-primary));
pointer-events: none;
}
:global([data-theme="light"]) .repost-text::after {
background: linear-gradient(to bottom, transparent, var(--bg-primary, #ffffff));
}
:global([data-theme="dark"]) .repost-text::after {
background: linear-gradient(to bottom, transparent, var(--bg-primary, #1a1a1a));
}
:global([data-theme="black"]) .repost-text::after {
background: linear-gradient(to bottom, transparent, var(--bg-primary, #000000));
} }
.referenced-event { .referenced-event {
@ -3199,15 +3631,6 @@ i *
padding: 1rem; padding: 1rem;
} }
.profile-header {
grid-template-columns: 1fr;
text-align: center;
}
.profile-avatar-section {
justify-self: center;
}
.repo-grid { .repo-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

5
static/icons/git-commit.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="1.05" y1="12" x2="8" y2="12"/>
<line x1="16" y1="12" x2="22.95" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

5
static/icons/more-vertical.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="1"/>
<circle cx="12" cy="12" r="1"/>
<circle cx="12" cy="19" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

4
static/icons/tag.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

Loading…
Cancel
Save