You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
635 lines
16 KiB
635 lines
16 KiB
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js'; |
|
import { userStore } from '$lib/stores/user-store.js'; |
|
|
|
let loading = $state(true); |
|
let error = $state<string | null>(null); |
|
let userPubkeyHex = $state<string | null>(null); |
|
|
|
// Sync with userStore |
|
$effect(() => { |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkeyHex) { |
|
userPubkeyHex = currentUser.userPubkeyHex; |
|
} else { |
|
userPubkeyHex = null; |
|
} |
|
}); |
|
let issues = $state<ExternalIssue[]>([]); |
|
let pullRequests = $state<ExternalPullRequest[]>([]); |
|
let activeTab = $state<'issues' | 'prs' | 'all'>('all'); |
|
|
|
const PLATFORM_NAMES: Record<string, string> = { |
|
github: 'GitHub', |
|
gitlab: 'GitLab', |
|
gitea: 'Gitea', |
|
codeberg: 'Codeberg', |
|
forgejo: 'Forgejo', |
|
onedev: 'OneDev', |
|
custom: 'Custom' |
|
}; |
|
|
|
onMount(async () => { |
|
await loadUserPubkey(); |
|
if (userPubkeyHex) { |
|
await loadDashboard(); |
|
} else { |
|
loading = false; |
|
error = 'Please connect your NIP-07 extension to view the dashboard'; |
|
} |
|
}); |
|
|
|
async function loadUserPubkey() { |
|
// Check userStore first |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkeyHex) { |
|
userPubkeyHex = currentUser.userPubkeyHex; |
|
return; |
|
} |
|
|
|
// Fallback: try NIP-07 if store doesn't have it |
|
if (!isNIP07Available()) { |
|
return; |
|
} |
|
|
|
try { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} else { |
|
userPubkeyHex = pubkey; |
|
} |
|
} catch { |
|
userPubkeyHex = pubkey; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to load user pubkey:', err); |
|
} |
|
} |
|
|
|
async function loadDashboard() { |
|
if (!userPubkeyHex) return; |
|
|
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch('/api/user/git-dashboard', { |
|
headers: { |
|
'X-User-Pubkey': userPubkeyHex |
|
} |
|
}); |
|
|
|
if (!response.ok) { |
|
const data = await response.json(); |
|
throw new Error(data.message || `Failed to load dashboard: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
issues = data.issues || []; |
|
pullRequests = data.pullRequests || []; |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load dashboard'; |
|
console.error('Error loading dashboard:', err); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
function getPlatformName(platform: string): string { |
|
return PLATFORM_NAMES[platform] || platform; |
|
} |
|
|
|
function getPlatformIcon(platform: string): string { |
|
const icons: Record<string, string> = { |
|
github: '/icons/github.svg', |
|
gitlab: '/icons/gitlab.svg', |
|
gitea: '/icons/git-branch.svg', |
|
codeberg: '/icons/git-branch.svg', |
|
forgejo: '/icons/hammer.svg', |
|
onedev: '/icons/package.svg', |
|
custom: '/icons/settings.svg' |
|
}; |
|
return icons[platform] || '/icons/package.svg'; |
|
} |
|
|
|
function formatDate(dateString: string): string { |
|
if (!dateString) return 'Unknown'; |
|
try { |
|
const date = new Date(dateString); |
|
const now = new Date(); |
|
const diffMs = now.getTime() - date.getTime(); |
|
const diffMins = Math.floor(diffMs / 60000); |
|
const diffHours = Math.floor(diffMs / 3600000); |
|
const diffDays = Math.floor(diffMs / 86400000); |
|
|
|
if (diffMins < 1) return 'Just now'; |
|
if (diffMins < 60) return `${diffMins}m ago`; |
|
if (diffHours < 24) return `${diffHours}h ago`; |
|
if (diffDays < 7) return `${diffDays}d ago`; |
|
return date.toLocaleDateString(); |
|
} catch { |
|
return dateString; |
|
} |
|
} |
|
|
|
function getRepoDisplay(issue: ExternalIssue | ExternalPullRequest): string { |
|
return `${issue.owner}/${issue.repo}`; |
|
} |
|
|
|
const filteredItems = $derived(() => { |
|
if (activeTab === 'issues') { |
|
return issues; |
|
} else if (activeTab === 'prs') { |
|
return pullRequests; |
|
} else { |
|
// Combine and sort by updated_at |
|
const all = [ |
|
...issues.map(i => ({ ...i, type: 'issue' as const })), |
|
...pullRequests.map(pr => ({ ...pr, type: 'pr' as const })) |
|
]; |
|
return all.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); |
|
} |
|
}); |
|
</script> |
|
|
|
<div class="dashboard-container"> |
|
<header class="dashboard-header"> |
|
<h1>Universal Git Dashboard</h1> |
|
<p class="dashboard-subtitle">Aggregated issues and pull requests from all your configured git platforms</p> |
|
{#if userPubkeyHex} |
|
<button onclick={loadDashboard} class="refresh-button" disabled={loading}> |
|
{#if loading} |
|
Refreshing... |
|
{:else} |
|
<img src="/icons/refresh-cw.svg" alt="Refresh" class="icon-inline" /> |
|
Refresh |
|
{/if} |
|
</button> |
|
{/if} |
|
</header> |
|
|
|
{#if loading} |
|
<div class="loading">Loading dashboard...</div> |
|
{:else if error} |
|
<div class="error"> |
|
<p>{error}</p> |
|
{#if !userPubkeyHex} |
|
<p>Please connect your NIP-07 extension to view the dashboard.</p> |
|
{/if} |
|
</div> |
|
{:else if issues.length === 0 && pullRequests.length === 0} |
|
<div class="empty-state"> |
|
<h2>No items found</h2> |
|
<p>Configure git platform forwarding in your messaging preferences to see issues and pull requests here.</p> |
|
{#if userPubkeyHex} |
|
<p>Go to your <a href="/users/{nip19.npubEncode(userPubkeyHex)}">profile</a> to configure platforms.</p> |
|
{/if} |
|
</div> |
|
{:else} |
|
<!-- Tabs --> |
|
<div class="tabs"> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'all'} |
|
onclick={() => activeTab = 'all'} |
|
> |
|
All ({issues.length + pullRequests.length}) |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'issues'} |
|
onclick={() => activeTab = 'issues'} |
|
> |
|
Issues ({issues.length}) |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'prs'} |
|
onclick={() => activeTab = 'prs'} |
|
> |
|
Pull Requests ({pullRequests.length}) |
|
</button> |
|
</div> |
|
|
|
<!-- Items List --> |
|
<div class="items-list"> |
|
{#each filteredItems() as item} |
|
{@const isPR = 'head' in item} |
|
<div class="item-card" class:pr={isPR} class:issue={!isPR}> |
|
<div class="item-header"> |
|
<div class="item-title-row"> |
|
<span class="item-type-badge" class:pr={isPR} class:issue={!isPR}> |
|
{#if isPR} |
|
<img src="/icons/git-pull-request.svg" alt="PR" class="icon-inline" /> |
|
PR |
|
{:else} |
|
<img src="/icons/clipboard-list.svg" alt="Issue" class="icon-inline" /> |
|
Issue |
|
{/if} |
|
</span> |
|
<a |
|
href={item.html_url} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
class="item-title" |
|
> |
|
{item.title || 'Untitled'} |
|
</a> |
|
</div> |
|
<div class="item-meta"> |
|
<span class="platform-badge" title={item.apiUrl || ''}> |
|
<img src={getPlatformIcon(item.platform)} alt={getPlatformName(item.platform)} class="icon-inline" /> |
|
{getPlatformName(item.platform)} |
|
</span> |
|
<span class="repo-name">{getRepoDisplay(item)}</span> |
|
{#if item.number} |
|
<span class="item-number">#{item.number}</span> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<div class="item-body"> |
|
<p class="item-description"> |
|
{item.body ? (item.body.length > 200 ? item.body.slice(0, 200) + '...' : item.body) : 'No description'} |
|
</p> |
|
</div> |
|
|
|
<div class="item-footer"> |
|
<div class="item-status"> |
|
<span class="status-badge" class:open={item.state === 'open'} class:closed={item.state === 'closed'} class:merged={item.state === 'merged'}> |
|
{item.state} |
|
</span> |
|
{#if isPR && 'merged_at' in item && item.merged_at} |
|
<span class="merged-indicator">✓ Merged</span> |
|
{/if} |
|
</div> |
|
<div class="item-info"> |
|
{#if item.user.login || item.user.username} |
|
<span class="item-author">@{item.user.login || item.user.username}</span> |
|
{/if} |
|
<span class="item-date">Updated {formatDate(item.updated_at)}</span> |
|
{#if item.comments_count !== undefined && item.comments_count > 0} |
|
<span class="comments-count"> |
|
<img src="/icons/message-circle.svg" alt="Comments" class="icon-inline" /> |
|
{item.comments_count} |
|
</span> |
|
{/if} |
|
</div> |
|
{#if item.labels && item.labels.length > 0} |
|
<div class="item-labels"> |
|
{#each item.labels.slice(0, 5) as label} |
|
<span |
|
class="label-badge" |
|
style={label.color ? `background-color: #${label.color}20; color: #${label.color}; border-color: #${label.color}` : ''} |
|
> |
|
{label.name} |
|
</span> |
|
{/each} |
|
{#if item.labels.length > 5} |
|
<span class="more-labels">+{item.labels.length - 5} more</span> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="item-actions"> |
|
<a |
|
href={item.html_url} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
class="external-link" |
|
> |
|
View on {getPlatformName(item.platform)} → |
|
</a> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.dashboard-container { |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
padding: 2rem; |
|
} |
|
|
|
.dashboard-header { |
|
margin-bottom: 2rem; |
|
padding-bottom: 1rem; |
|
border-bottom: 1px solid var(--border-color); |
|
} |
|
|
|
.dashboard-header h1 { |
|
margin: 0 0 0.5rem 0; |
|
color: var(--text-primary); |
|
} |
|
|
|
.dashboard-subtitle { |
|
color: var(--text-secondary); |
|
margin: 0 0 1rem 0; |
|
} |
|
|
|
.refresh-button { |
|
padding: 0.5rem 1rem; |
|
background: var(--button-primary); |
|
color: var(--accent-text, #ffffff); |
|
border: none; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 0.9rem; |
|
transition: background 0.2s; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.refresh-button:hover:not(:disabled) { |
|
background: var(--button-primary-hover); |
|
} |
|
|
|
.refresh-button:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.tabs { |
|
display: flex; |
|
gap: 0.5rem; |
|
border-bottom: 1px solid var(--border-color); |
|
margin-bottom: 2rem; |
|
} |
|
|
|
.tab-button { |
|
padding: 0.75rem 1.5rem; |
|
background: none; |
|
border: none; |
|
border-bottom: 2px solid transparent; |
|
cursor: pointer; |
|
font-size: 1rem; |
|
color: var(--text-secondary); |
|
transition: all 0.2s; |
|
} |
|
|
|
.tab-button:hover { |
|
color: var(--text-primary); |
|
background: var(--bg-secondary); |
|
} |
|
|
|
.tab-button.active { |
|
color: var(--accent); |
|
border-bottom-color: var(--accent); |
|
font-weight: 500; |
|
} |
|
|
|
.items-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.item-card { |
|
background: var(--card-bg); |
|
border: 1px solid var(--border-color); |
|
border-radius: 8px; |
|
padding: 1.5rem; |
|
transition: box-shadow 0.2s, border-color 0.2s; |
|
} |
|
|
|
.item-card:hover { |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
border-color: var(--accent); |
|
} |
|
|
|
.item-card.pr { |
|
border-left: 4px solid var(--accent); |
|
} |
|
|
|
.item-card.issue { |
|
border-left: 4px solid var(--success-color, #10b981); |
|
} |
|
|
|
.item-header { |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.item-title-row { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75rem; |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
.item-type-badge { |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 4px; |
|
font-size: 0.75rem; |
|
font-weight: 600; |
|
flex-shrink: 0; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.item-type-badge.pr { |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
} |
|
|
|
.item-type-badge.issue { |
|
background: var(--success-bg, #d1fae5); |
|
color: var(--success-text, #065f46); |
|
} |
|
|
|
.item-title { |
|
font-size: 1.1rem; |
|
font-weight: 600; |
|
color: var(--text-primary); |
|
text-decoration: none; |
|
flex: 1; |
|
} |
|
|
|
.item-title:hover { |
|
color: var(--accent); |
|
text-decoration: underline; |
|
} |
|
|
|
.item-meta { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75rem; |
|
flex-wrap: wrap; |
|
font-size: 0.85rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
.platform-badge { |
|
padding: 0.25rem 0.5rem; |
|
background: var(--bg-secondary); |
|
border-radius: 4px; |
|
font-weight: 500; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.repo-name { |
|
font-family: 'IBM Plex Mono', monospace; |
|
color: var(--text-secondary); |
|
} |
|
|
|
.item-number { |
|
color: var(--text-muted); |
|
} |
|
|
|
.item-body { |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.item-description { |
|
color: var(--text-secondary); |
|
line-height: 1.6; |
|
margin: 0; |
|
} |
|
|
|
.item-footer { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 0.75rem; |
|
padding-top: 1rem; |
|
border-top: 1px solid var(--border-color); |
|
} |
|
|
|
.item-status { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
} |
|
|
|
.status-badge { |
|
padding: 0.25rem 0.75rem; |
|
border-radius: 4px; |
|
font-size: 0.85rem; |
|
font-weight: 600; |
|
text-transform: capitalize; |
|
} |
|
|
|
.status-badge.open { |
|
background: var(--success-bg); |
|
color: var(--success-text); |
|
} |
|
|
|
.status-badge.closed { |
|
background: var(--error-bg); |
|
color: var(--error-text); |
|
} |
|
|
|
.status-badge.merged { |
|
background: var(--accent-light); |
|
color: var(--accent); |
|
} |
|
|
|
.merged-indicator { |
|
color: var(--accent); |
|
font-size: 0.85rem; |
|
} |
|
|
|
.item-info { |
|
display: flex; |
|
align-items: center; |
|
gap: 1rem; |
|
font-size: 0.85rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
.item-author { |
|
font-weight: 500; |
|
} |
|
|
|
.comments-count { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.icon-inline { |
|
width: 14px; |
|
height: 14px; |
|
display: inline-block; |
|
vertical-align: middle; |
|
} |
|
|
|
.item-labels { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
} |
|
|
|
.label-badge { |
|
padding: 0.2rem 0.5rem; |
|
border-radius: 4px; |
|
font-size: 0.75rem; |
|
border: 1px solid var(--border-color); |
|
background: var(--bg-secondary); |
|
color: var(--text-primary); |
|
} |
|
|
|
.more-labels { |
|
font-size: 0.75rem; |
|
color: var(--text-muted); |
|
font-style: italic; |
|
} |
|
|
|
.item-actions { |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.external-link { |
|
color: var(--accent); |
|
text-decoration: none; |
|
font-size: 0.9rem; |
|
font-weight: 500; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.25rem; |
|
} |
|
|
|
.external-link:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
.loading, .error, .empty-state { |
|
text-align: center; |
|
padding: 3rem 2rem; |
|
color: var(--text-muted); |
|
} |
|
|
|
.error { |
|
color: var(--error-text); |
|
background: var(--error-bg); |
|
border: 1px solid var(--error-text); |
|
border-radius: 8px; |
|
padding: 1.5rem; |
|
} |
|
|
|
.empty-state h2 { |
|
margin: 0 0 1rem 0; |
|
color: var(--text-primary); |
|
} |
|
|
|
.empty-state a { |
|
color: var(--accent); |
|
text-decoration: none; |
|
} |
|
|
|
.empty-state a:hover { |
|
text-decoration: underline; |
|
} |
|
</style>
|
|
|