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.
 
 
 
 
 

1935 lines
56 KiB

<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { PublicMessagesService, type PublicMessage } from '$lib/services/nostr/public-messages-service.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import UserBadge from '$lib/components/UserBadge.svelte';
import { userStore } from '$lib/stores/user-store.js';
import { fetchUserProfile, extractProfileData } from '$lib/utils/user-profile.js';
import { combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
const npub = ($page.params as { npub?: string }).npub || '';
// State
let loading = $state(true);
let error = $state<string | null>(null);
let profileOwnerPubkeyHex = $state<string | null>(null);
let viewerPubkeyHex = $state<string | null>(null);
let repos = $state<NostrEvent[]>([]);
let ownedRepos = $state<NostrEvent[]>([]);
let maintainedRepos = $state<NostrEvent[]>([]);
let favoriteRepos = $state<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string; banner?: string } | null>(null);
let profileEvent = $state<NostrEvent | null>(null);
let profileData = $state<any>(null);
let profileTags = $state<Array<{ name: string; values: string[]; verified?: boolean[] }>>([]);
let paymentTargets = $state<Array<{ type: string; authority: string; payto: string }>>([]);
// Messages
let activeTab = $state<'repos' | 'messages' | 'activity'>('repos');
let messages = $state<PublicMessage[]>([]);
let loadingMessages = $state(false);
let messagesLoaded = $state(false); // Track if we've attempted to load messages
let showSendMessageDialog = $state(false);
let newMessageContent = $state('');
let sendingMessage = $state(false);
let messagesService: PublicMessagesService | null = null;
// Activity
let activityEvents = $state<NostrEvent[]>([]);
let loadingActivity = $state(false);
let activityLoaded = $state(false); // Track if we've attempted to load activity
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const gitDomain = $page.data.gitDomain || 'localhost:6543';
// Sync viewer pubkey from store
$effect(() => {
const currentUser = $userStore;
viewerPubkeyHex = currentUser.userPubkeyHex || null;
});
onMount(async () => {
await loadUserProfile();
});
// Load messages when tab is active
$effect(() => {
if (activeTab === 'messages' && profileOwnerPubkeyHex && !messagesLoaded && !loadingMessages) {
loadMessages();
}
});
// Load activity when tab is active
$effect(() => {
if (activeTab === 'activity' && profileOwnerPubkeyHex && !activityLoaded && !loadingActivity) {
loadActivity();
}
});
async function loadUserProfile() {
loading = true;
error = null;
try {
// Decode npub
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
error = 'Invalid npub format';
return;
}
profileOwnerPubkeyHex = decoded.data as string;
// Load repositories
const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`;
const response = await fetch(url, {
headers: viewerPubkeyHex ? { 'X-User-Pubkey': viewerPubkeyHex } : {}
});
if (!response.ok) {
throw new Error(`Failed to load repositories: ${response.statusText}`);
}
const data = await response.json();
repos = data.repos || [];
// Organize repos into owned, maintained, and favorites
if (profileOwnerPubkeyHex) {
await organizeRepos();
}
// Load profile
profileEvent = await fetchUserProfile(profileOwnerPubkeyHex, DEFAULT_NOSTR_RELAYS);
if (profileEvent) {
// Parse JSON content
try {
if (profileEvent.content?.trim()) {
profileData = JSON.parse(profileEvent.content);
}
} catch {
profileData = null;
}
// Extract tags - only bot, nip05, and website, grouped by tag name
const tagsToShow = new Set(['bot', 'nip05', 'website']);
const groupedTags = new Map<string, string[]>();
for (const tag of profileEvent.tags) {
if (tag.length > 0 && tag[0] && tagsToShow.has(tag[0])) {
const tagName = tag[0];
const values: string[] = [];
for (let i = 1; i < tag.length; i++) {
if (tag[i]) {
values.push(tag[i]);
}
}
if (values.length > 0) {
const existing = groupedTags.get(tagName) || [];
groupedTags.set(tagName, [...existing, ...values]);
}
}
}
// Fallback to JSON content for missing tags (old-fashioned events)
if (profileData && typeof profileData === 'object') {
// Check for nip05
if (!groupedTags.has('nip05') && profileData.nip05) {
const nip05Values = Array.isArray(profileData.nip05) ? profileData.nip05 : [profileData.nip05];
groupedTags.set('nip05', nip05Values.filter(Boolean).map(String));
}
// Check for website
if (!groupedTags.has('website') && profileData.website) {
const websiteValues = Array.isArray(profileData.website) ? profileData.website : [profileData.website];
groupedTags.set('website', websiteValues.filter(Boolean).map(String));
}
// Check for bot
if (!groupedTags.has('bot') && profileData.bot !== undefined) {
const botValue = profileData.bot === true || profileData.bot === 'true' || profileData.bot === '1' ? 'true' : String(profileData.bot);
groupedTags.set('bot', [botValue]);
}
}
// Convert to array (nip05 verification happens asynchronously)
profileTags = [];
for (const [tagName, values] of groupedTags.entries()) {
if (tagName === 'nip05') {
// Initialize with unverified status, verify asynchronously
profileTags.push({ name: tagName, values, verified: new Array(values.length).fill(false) });
} else {
profileTags.push({ name: tagName, values });
}
}
// Verify nip05 values asynchronously
verifyNip05Tags();
// Extract profile fields (with fallback to JSON content)
const nameTag = profileEvent.tags.find(t => t[0] === 'name' || t[0] === 'display_name')?.[1];
const aboutTag = profileEvent.tags.find(t => t[0] === 'about')?.[1];
const pictureTag = profileEvent.tags.find(t => t[0] === 'picture' || t[0] === 'avatar')?.[1];
const bannerTag = profileEvent.tags.find(t => t[0] === 'banner')?.[1];
userProfile = {
name: nameTag || profileData?.display_name || profileData?.name,
about: aboutTag || profileData?.about,
picture: pictureTag || profileData?.picture,
banner: bannerTag || profileData?.banner
};
}
// Load payment targets (kind 10133)
const paymentEvents = await nostrClient.fetchEvents([{
kinds: [10133],
authors: [profileOwnerPubkeyHex],
limit: 1
}]);
const lightningAddresses = new Set<string>();
// Extract from profile event (tags first, then JSON content fallback)
if (profileEvent) {
// Extract from tags
const lud16Tags = profileEvent.tags.filter(t => t[0] === 'lud16').map(t => t[1]).filter(Boolean);
lud16Tags.forEach(addr => lightningAddresses.add(addr.toLowerCase()));
// Fallback to JSON content for lud16 (old-fashioned events)
if (profileData?.lud16) {
const lud16Values = Array.isArray(profileData.lud16) ? profileData.lud16 : [profileData.lud16];
lud16Values.forEach((addr: any) => {
if (addr) lightningAddresses.add(String(addr).toLowerCase());
});
}
}
// Extract from kind 10133
if (paymentEvents.length > 0) {
const paytoTags = paymentEvents[0].tags.filter(t => t[0] === 'payto' && t[1] === 'lightning' && t[2]);
paytoTags.forEach(tag => {
if (tag[2]) lightningAddresses.add(tag[2].toLowerCase());
});
}
// Build payment targets
const targets: Array<{ type: string; authority: string; payto: string }> =
Array.from(lightningAddresses).map(authority => ({
type: 'lightning',
authority,
payto: `payto://lightning/${authority}`
}));
if (paymentEvents.length > 0) {
const otherPaytoTags = paymentEvents[0].tags.filter(t =>
t[0] === 'payto' && t[1] && t[1] !== 'lightning' && t[2]
);
otherPaytoTags.forEach(tag => {
const type = tag[1]?.toLowerCase() || '';
const authority = tag[2] || '';
if (type && authority && !targets.find(p => p.type === type && p.authority.toLowerCase() === authority.toLowerCase())) {
targets.push({ type, authority, payto: `payto://${type}/${authority}` });
}
});
}
paymentTargets = targets;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load user profile';
console.error('Error loading user profile:', err);
} finally {
loading = false;
}
}
async function verifyNip05Tags() {
if (!profileOwnerPubkeyHex) return;
// Find nip05 tags and verify them
const updatedTags = [...profileTags];
for (let i = 0; i < updatedTags.length; i++) {
const tag = updatedTags[i];
if (tag.name === 'nip05' && tag.verified) {
const verified: boolean[] = [];
for (const value of tag.values) {
try {
const { resolvePubkey } = await import('$lib/utils/pubkey-resolver.js');
const resolvedPubkey = await resolvePubkey(value);
verified.push(resolvedPubkey === profileOwnerPubkeyHex);
} catch {
verified.push(false);
}
}
// Update the verified array
updatedTags[i] = { ...tag, verified };
}
}
// Update profileTags to trigger reactivity
profileTags = updatedTags;
}
// Shared function to filter out user's own events and write-proof messages
function shouldExcludeEvent(event: NostrEvent, userPubkey: string): boolean {
// Exclude write-proof kind 24 events
if (event.kind === KIND.PUBLIC_MESSAGE && event.content && event.content.includes('gitrepublic-write-proof')) {
return true;
}
// Exclude user's own events (messages FROM the user)
// Note: We want to SHOW messages TO the user from other people, so we only exclude messages FROM the user
if (event.pubkey === userPubkey) {
return true;
}
// Note: We don't exclude messages TO the user from other people - those should be shown
// The check for "messages to themselves" is already covered by the check above (event.pubkey === userPubkey)
return false;
}
async function loadMessages() {
if (!profileOwnerPubkeyHex || loadingMessages || messagesLoaded) return;
loadingMessages = true;
try {
if (!messagesService) {
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS);
}
const allMessages = await messagesService.getAllMessagesForUser(profileOwnerPubkeyHex, 100);
// Filter out user's own messages and write-proof events
messages = allMessages.filter(msg => {
// Convert PublicMessage to NostrEvent-like structure for filtering
const eventLike: NostrEvent = {
id: msg.id,
pubkey: msg.pubkey,
created_at: msg.created_at,
kind: msg.kind,
tags: msg.tags,
content: msg.content,
sig: msg.sig || ''
};
return !shouldExcludeEvent(eventLike, profileOwnerPubkeyHex || '');
});
} catch (err) {
console.error('Error loading messages:', err);
} finally {
messagesLoaded = true; // Mark as loaded to prevent infinite loop (even on error)
loadingMessages = false;
}
}
async function sendMessage() {
if (!newMessageContent.trim() || !viewerPubkeyHex || !profileOwnerPubkeyHex) {
alert('Please enter a message and make sure you are logged in');
return;
}
if (viewerPubkeyHex === profileOwnerPubkeyHex) {
alert('You cannot send a message to yourself');
return;
}
sendingMessage = true;
try {
if (!messagesService) {
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS);
}
const messageEvent = await messagesService.sendPublicMessage(
viewerPubkeyHex,
newMessageContent.trim(),
[{ pubkey: profileOwnerPubkeyHex }]
);
const { outbox } = await getUserRelays(viewerPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
const signedEvent = await signEventWithNIP07(messageEvent);
const result = await nostrClient.publishEvent(signedEvent, combinedRelays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish message to all relays');
}
messagesLoaded = false; // Reset flag to reload messages after sending
await loadMessages();
showSendMessageDialog = false;
newMessageContent = '';
alert('Message sent successfully!');
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to send message');
} finally {
sendingMessage = false;
}
}
function getRepoName(event: NostrEvent): string {
return event.tags.find(t => t[0] === 'name')?.[1] ||
event.tags.find(t => t[0] === 'd')?.[1] ||
'Unnamed';
}
function getRepoDescription(event: NostrEvent): string {
return event.tags.find(t => t[0] === 'description')?.[1] || '';
}
async function organizeRepos() {
if (!profileOwnerPubkeyHex) return;
ownedRepos = [];
maintainedRepos = [];
favoriteRepos = [];
const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety
if (!userPubkey) return;
try {
// Separate owned repos from the initial list
ownedRepos = repos.filter(r => r.pubkey === userPubkey);
// Load favorites (bookmarks kind 10003)
const bookmarkEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.BOOKMARKS],
authors: [userPubkey],
limit: 100
}
]);
// Extract repo a-tags from bookmarks
const favoriteATags = new Set<string>();
for (const bookmark of bookmarkEvents) {
for (const tag of bookmark.tags) {
if (tag[0] === 'a' && tag[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) {
favoriteATags.add(tag[1]);
}
}
}
// Fetch repo announcements for bookmarked repos
// Parse a-tags to get author and d-tag, then fetch specific repos
if (favoriteATags.size > 0) {
const favoriteRepoPromises: Promise<NostrEvent | null>[] = [];
for (const aTag of favoriteATags) {
// Parse a-tag format: "30617:pubkey:d-tag"
const parts = aTag.split(':');
if (parts.length >= 3 && parts[0] === String(KIND.REPO_ANNOUNCEMENT)) {
const repoOwnerPubkey = parts[1];
const repoId = parts[2];
// Skip if this is the user's own repo (already in ownedRepos)
if (repoOwnerPubkey.toLowerCase() === userPubkey.toLowerCase()) {
continue;
}
// Fetch the specific repo announcement
favoriteRepoPromises.push(
nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkey],
'#d': [repoId],
limit: 1
}
]).then(events => events[0] || null)
);
}
}
const favoriteRepoResults = await Promise.all(favoriteRepoPromises);
favoriteRepos = favoriteRepoResults.filter((repo): repo is NostrEvent => repo !== null);
}
// For maintained repos, we need to search through repos
// This is less efficient, so we'll search recent repos and check maintainers
// Limit to a reasonable number to avoid performance issues
const recentRepoEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
limit: 200 // Search through recent repos
}
]);
// Filter for repos where user is a maintainer (but not owner)
for (const repo of recentRepoEvents) {
if (repo.pubkey.toLowerCase() === userPubkey.toLowerCase()) continue; // Skip owned repos
const maintainersTag = repo.tags.find(t => t[0] === 'maintainers');
if (maintainersTag) {
const isMaintainer = maintainersTag.slice(1).some(m => {
if (!m) return false;
try {
const decoded = nip19.decode(m);
if (decoded.type === 'npub') {
return (decoded.data as string).toLowerCase() === userPubkey.toLowerCase();
}
} catch {
// Assume hex
}
return m.toLowerCase() === userPubkey.toLowerCase();
});
if (isMaintainer) {
maintainedRepos.push(repo);
}
}
}
// Remove duplicates (a repo could be both maintained and favorited)
maintainedRepos = maintainedRepos.filter((repo, index, self) =>
index === self.findIndex(r => r.id === repo.id)
);
favoriteRepos = favoriteRepos.filter((repo, index, self) =>
index === self.findIndex(r => r.id === repo.id) &&
!maintainedRepos.find(m => m.id === repo.id) &&
!ownedRepos.find(o => o.id === repo.id)
);
} catch (err) {
console.error('Failed to organize repos:', err);
// Fallback: just mark owned repos
ownedRepos = repos.filter(r => r.pubkey === profileOwnerPubkeyHex);
}
}
function getRepoId(event: NostrEvent): string {
return event.tags.find(t => t[0] === 'd')?.[1] || '';
}
function getMessageRecipients(message: PublicMessage): string[] {
return message.tags
.filter(tag => tag[0] === 'p' && tag[1])
.map(tag => tag[1]);
}
function formatMessageTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
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();
}
async function loadActivity() {
if (!profileOwnerPubkeyHex || loadingActivity || activityLoaded) return;
const userPubkey = profileOwnerPubkeyHex; // Store in local variable for type safety
loadingActivity = true;
try {
// Step 1: Fetch all repo announcements where user is owner or maintainer
const repoAnnouncements = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [userPubkey],
limit: 100
}
]);
// Step 2: Extract a-tags from repo announcements
const aTags = new Set<string>();
for (const announcement of repoAnnouncements) {
const dTag = announcement.tags.find(t => t[0] === 'd')?.[1];
if (dTag) {
const aTag = `${KIND.REPO_ANNOUNCEMENT}:${announcement.pubkey}:${dTag}`;
aTags.add(aTag);
}
}
// Step 3: Also check for repos where user is a maintainer (not just owner)
// 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) {
// Check if user is in maintainers tag
const maintainersTag = announcement.tags.find(t => t[0] === 'maintainers');
if (maintainersTag) {
const isMaintainer = maintainersTag.slice(1).some(m => {
// Handle both hex and npub formats
try {
const decoded = nip19.decode(m);
if (decoded.type === 'npub') {
return (decoded.data as string).toLowerCase() === userPubkey.toLowerCase();
}
} catch {
// Assume hex
}
return m.toLowerCase() === userPubkey.toLowerCase();
});
if (isMaintainer) {
const dTag = announcement.tags.find(t => t[0] === 'd')?.[1];
if (dTag) {
const aTag = `${KIND.REPO_ANNOUNCEMENT}:${announcement.pubkey}:${dTag}`;
aTags.add(aTag);
}
}
}
}
// Step 4: Fetch events that reference the user or their repos
const filters: any[] = [];
// Events with user in p-tag
filters.push({
'#p': [userPubkey],
limit: 200
});
// Events with user in q-tag
filters.push({
'#q': [userPubkey],
limit: 200
});
// Events with repo a-tags
if (aTags.size > 0) {
filters.push({
'#a': Array.from(aTags),
limit: 200
});
}
const allActivityEvents = await nostrClient.fetchEvents(filters);
// Step 5: Deduplicate, filter, and sort by created_at (newest first)
const eventMap = new Map<string, NostrEvent>();
for (const event of allActivityEvents) {
// Use shared exclusion function to filter out user's own events and write-proof messages
if (shouldExcludeEvent(event, userPubkey)) {
continue;
}
// Keep the newest version if duplicate
const existing = eventMap.get(event.id);
if (!existing || event.created_at > existing.created_at) {
eventMap.set(event.id, event);
}
}
// Sort by created_at descending and limit to 200
activityEvents = Array.from(eventMap.values())
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 200);
} catch (err) {
console.error('Failed to load activity:', err);
error = 'Failed to load activity';
} finally {
activityLoaded = true; // Mark as loaded to prevent infinite loop (even on error)
loadingActivity = false;
}
}
function getEventContext(event: NostrEvent): string {
// Extract context from event content or tags
if (event.content && event.content.trim()) {
// Limit to first 200 characters
const content = event.content.trim();
// Skip if it's just whitespace or very short
if (content.length > 3) {
if (content.length <= 200) {
return content;
}
return content.substring(0, 197) + '...';
}
}
// Try to get context from tags (in order of preference)
const nameTag = event.tags.find(t => t[0] === 'name')?.[1];
if (nameTag && nameTag.trim()) {
return nameTag.trim();
}
const descriptionTag = event.tags.find(t => t[0] === 'description')?.[1];
if (descriptionTag && descriptionTag.trim()) {
const desc = descriptionTag.trim();
return desc.length > 200 ? desc.substring(0, 197) + '...' : desc;
}
// Try summary tag
const summaryTag = event.tags.find(t => t[0] === 'summary')?.[1];
if (summaryTag && summaryTag.trim()) {
return summaryTag.trim();
}
// Try title tag
const titleTag = event.tags.find(t => t[0] === 'title')?.[1];
if (titleTag && titleTag.trim()) {
return titleTag.trim();
}
// Build context from kind and other tags
const kindNames: Record<number, string> = {
[KIND.PULL_REQUEST]: 'Pull Request',
[KIND.ISSUE]: 'Issue',
[KIND.COMMENT]: 'Comment',
[KIND.PATCH]: 'Patch',
[KIND.REPO_ANNOUNCEMENT]: 'Repository Announcement',
[KIND.REPO_STATE]: 'Repository State',
[KIND.PUBLIC_MESSAGE]: 'Public Message',
};
const kindName = kindNames[event.kind] || `Event kind ${event.kind}`;
// Try to add repo context if available
const aTag = event.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`));
if (aTag && aTag[1]) {
const parts = aTag[1].split(':');
if (parts.length >= 3) {
const repoId = parts[2];
return `${kindName} - ${repoId}`;
}
}
return kindName;
}
function getEventLink(event: NostrEvent): string {
// Create a link to view the event using nevent or naddr
try {
// Check if it's a parameterized replaceable event (has 'a' tag)
const aTag = event.tags.find(t => t[0] === 'a');
if (aTag && aTag[1]) {
// Use naddr for parameterized replaceable events
const naddr = nip19.naddrEncode({
identifier: event.tags.find(t => t[0] === 'd')?.[1] || '',
pubkey: event.pubkey,
kind: event.kind
});
return `https://aitherboard.imwald.eu/event/${naddr}`;
} else {
// Use nevent for regular events
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind
});
return `https://aitherboard.imwald.eu/event/${nevent}`;
}
} catch (err) {
console.error('Failed to encode event link:', err);
// Fallback to event ID
return `#${event.id.substring(0, 8)}`;
}
}
async function copyPaytoAddress(payto: string) {
try {
await navigator.clipboard.writeText(payto);
alert('Payment address copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
}
}
async function copyLightningAddress(authority: string) {
try {
await navigator.clipboard.writeText(authority);
alert('Lightning address copied to clipboard!');
} catch (err) {
console.error('Failed to copy lightning address:', err);
}
}
const isOwnProfile = $derived(viewerPubkeyHex === profileOwnerPubkeyHex);
// Sort payment targets with lightning first
const sortedPaymentTargets = $derived.by(() => {
return [...paymentTargets].sort((a, b) => {
const aType = a.type.toLowerCase();
const bType = b.type.toLowerCase();
if (aType === 'lightning') return -1;
if (bType === 'lightning') return 1;
return aType.localeCompare(bType);
});
});
// Display address without payto:// prefix
function getDisplayAddress(payto: string): string {
return payto.replace(/^payto:\/\//, '');
}
</script>
<div class="profile-page">
{#if loading}
<div class="loading-state">
<div class="spinner"></div>
<p>Loading profile...</p>
</div>
{:else if error}
<div class="error-state">
<h2>Error</h2>
<p>{error}</p>
</div>
{:else}
<!-- Profile Header -->
<header class="profile-header">
<div class="profile-avatar-section">
{#if userProfile?.picture}
<img src={userProfile.picture} alt="Profile" class="profile-avatar" />
{:else}
<div class="profile-avatar-placeholder">
{npub.slice(0, 2).toUpperCase()}
</div>
{/if}
</div>
<div class="profile-info">
<h1 class="profile-name">{userProfile?.name || npub.slice(0, 16) + '...'}</h1>
{#if userProfile?.about}
<p class="profile-bio">{userProfile.about}</p>
{/if}
<div class="profile-meta">
<code class="profile-npub">{npub}</code>
</div>
</div>
{#if isOwnProfile}
<div class="profile-actions">
<a href="/dashboard" class="action-button">
<img src="/icons/layout-dashboard.svg" alt="Dashboard" class="icon-themed" />
Dashboard
</a>
</div>
{/if}
</header>
<!-- Profile Tags -->
{#if profileTags.length > 0}
<section class="profile-tags-section">
<h2>Profile Metadata</h2>
<div class="profile-tags-grid">
{#each profileTags as tag}
<div class="profile-tag-item">
<span class="tag-name">{tag.name}:</span>
<div class="tag-values">
{#each tag.values as value, index}
<div class="tag-value-item">
{#if tag.name === 'website'}
<a href={value} target="_blank" rel="noopener noreferrer">{value}</a>
{:else if tag.name === 'nip05'}
<span class="nip05-value">{value}</span>
{#if tag.verified && tag.verified[index]}
<img src="/icons/check-circle.svg" alt="Verified" class="verified-icon" />
{/if}
{:else}
{value}
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
<!-- Payment Targets -->
{#if paymentTargets.length > 0}
<section class="payment-section">
<h2>Payment Methods</h2>
<div class="payment-grid">
{#each sortedPaymentTargets as target}
<div class="payment-card">
<code class="payment-address">{getDisplayAddress(target.payto)}</code>
<div class="payment-actions">
{#if target.type === 'lightning'}
<button
class="lightning-button"
onclick={() => copyLightningAddress(target.authority)}
title="Copy lightning address"
>
<img src="/icons/lightning.svg" alt="Lightning" class="icon-themed" />
</button>
{/if}
<button
class="copy-button"
onclick={() => copyPaytoAddress(target.payto)}
title="Copy payto address"
>
<img src="/icons/copy.svg" alt="Copy" class="icon-themed" />
</button>
</div>
</div>
{/each}
</div>
</section>
{/if}
<!-- Tabs -->
<div class="tabs-container">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'repos'}
onclick={() => activeTab = 'repos'}
>
Repositories <span class="tab-count">({repos.length})</span>
</button>
<button
class="tab"
class:active={activeTab === 'messages'}
onclick={() => activeTab = 'messages'}
>
Messages <span class="tab-count">({messages.length})</span>
</button>
<button
class="tab"
class:active={activeTab === 'activity'}
onclick={() => activeTab = 'activity'}
>
Activity <span class="tab-count">({activityEvents.length})</span>
</button>
</div>
</div>
<!-- Tab Content -->
<main class="tab-content">
{#if activeTab === 'repos'}
<section class="repos-section">
{#if repos.length === 0}
<div class="empty-state">
<p>No repositories found</p>
</div>
{:else}
<!-- Repositories I Own -->
{#if ownedRepos.length > 0}
<div class="repo-section-group">
<h3 class="repo-section-title">Repositories I Own</h3>
<div class="repo-grid">
{#each ownedRepos as event}
{@const repoId = getRepoId(event)}
<div
class="repo-card"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${npub}/${repoId}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${npub}/${repoId}`);
}
}}
>
<h3 class="repo-name">{getRepoName(event)}</h3>
{#if getRepoDescription(event)}
<p class="repo-description">{getRepoDescription(event)}</p>
{/if}
<div class="repo-footer">
<span class="repo-date">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Repositories I Maintain -->
{#if maintainedRepos.length > 0}
<div class="repo-section-group">
<h3 class="repo-section-title">Repositories I Maintain</h3>
<div class="repo-grid">
{#each maintainedRepos as event}
{@const repoId = getRepoId(event)}
<div
class="repo-card"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${npub}/${repoId}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${npub}/${repoId}`);
}
}}
>
<h3 class="repo-name">{getRepoName(event)}</h3>
{#if getRepoDescription(event)}
<p class="repo-description">{getRepoDescription(event)}</p>
{/if}
<div class="repo-footer">
<span class="repo-date">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Favorite Repositories -->
{#if favoriteRepos.length > 0}
<div class="repo-section-group">
<h3 class="repo-section-title">Favorite Repositories</h3>
<div class="repo-grid">
{#each favoriteRepos as event}
{@const repoId = getRepoId(event)}
<div
class="repo-card"
role="button"
tabindex="0"
onclick={() => goto(`/repos/${npub}/${repoId}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/repos/${npub}/${repoId}`);
}
}}
>
<h3 class="repo-name">{getRepoName(event)}</h3>
{#if getRepoDescription(event)}
<p class="repo-description">{getRepoDescription(event)}</p>
{/if}
<div class="repo-footer">
<span class="repo-date">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
</section>
{:else if activeTab === 'messages'}
<section class="messages-section">
<div class="messages-header">
<h2>Public Messages</h2>
{#if viewerPubkeyHex && !isOwnProfile}
<button onclick={() => showSendMessageDialog = true} class="send-button">
Send Message
</button>
{/if}
</div>
{#if loadingMessages}
<div class="loading-state">
<div class="spinner"></div>
<p>Loading messages...</p>
</div>
{:else if messages.length === 0}
<div class="empty-state">
<p>No messages found</p>
</div>
{:else}
<div class="messages-list">
{#each messages as message}
{@const isFromViewer = viewerPubkeyHex !== null && message.pubkey === viewerPubkeyHex}
{@const isToViewer = viewerPubkeyHex !== null && getMessageRecipients(message).includes(viewerPubkeyHex)}
<div class="message-card" class:from-viewer={isFromViewer} class:to-viewer={isToViewer && !isFromViewer}>
<div class="message-header">
<div class="message-participants">
<span class="participants-label">From:</span>
<UserBadge pubkey={message.pubkey} />
{#if getMessageRecipients(message).length > 0}
<span class="participants-label">To:</span>
{#each getMessageRecipients(message) as recipientPubkey}
<UserBadge pubkey={recipientPubkey} />
{/each}
{/if}
</div>
<span class="message-time">{formatMessageTime(message.created_at)}</span>
</div>
<div class="message-body">{message.content}</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'activity'}
<section class="activity-section">
<div class="activity-header">
<h2>Activity</h2>
</div>
{#if loadingActivity}
<div class="loading-state">
<div class="spinner"></div>
<p>Loading activity...</p>
</div>
{:else if activityEvents.length === 0}
<div class="empty-state">
<p>No activity found</p>
</div>
{:else}
<div class="activity-list">
{#each activityEvents as event}
<div class="activity-card">
<div class="activity-context">
<p class="activity-blurb">{getEventContext(event)}</p>
</div>
<div class="activity-footer">
<div class="activity-author">
<UserBadge pubkey={event.pubkey} />
<span class="activity-time">{formatMessageTime(event.created_at)}</span>
</div>
<a
href={getEventLink(event)}
class="activity-link-button"
target="_blank"
rel="noopener noreferrer"
>
View Event
</a>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</main>
{/if}
</div>
<!-- Send Message Dialog -->
{#if showSendMessageDialog}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Send message"
onclick={() => showSendMessageDialog = false}
onkeydown={(e) => {
if (e.key === 'Escape') {
showSendMessageDialog = false;
}
}}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal" role="document" onclick={(e) => e.stopPropagation()}>
<h3>Send Public Message</h3>
<p class="modal-note">This message will be publicly visible.</p>
<label>
<textarea
bind:value={newMessageContent}
rows="6"
placeholder="Type your message..."
disabled={sendingMessage}
class="message-input"
></textarea>
</label>
<div class="modal-actions">
<button
onclick={() => { showSendMessageDialog = false; newMessageContent = ''; }}
class="button-secondary"
disabled={sendingMessage}
>
Cancel
</button>
<button
onclick={sendMessage}
disabled={!newMessageContent.trim() || sendingMessage}
class="button-primary"
>
{sendingMessage ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
{/if}
<style>
.profile-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Loading & Error States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state h2 {
color: var(--text-primary);
margin-bottom: 0.5rem;
}
/* Profile Header */
.profile-header {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 2rem;
align-items: start;
padding: 2rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 1rem;
margin-bottom: 2rem;
}
.profile-avatar-section {
position: relative;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--border-color);
}
.profile-avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--accent);
color: var(--accent-text, #ffffff);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: bold;
border: 3px solid var(--border-color);
}
.profile-info {
flex: 1;
}
.profile-name {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.profile-bio {
font-size: 1.1rem;
color: var(--text-secondary);
margin: 0.5rem 0 1rem 0;
line-height: 1.6;
}
.profile-meta {
margin-top: 1rem;
}
.profile-npub {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.875rem;
color: var(--text-muted);
background: var(--bg-secondary);
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
display: inline-block;
}
.profile-actions {
display: flex;
gap: 0.75rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--accent);
color: var(--accent-text, #ffffff);
text-decoration: none;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s ease;
}
.action-button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.action-button .icon-themed {
width: 18px;
height: 18px;
}
/* Payment Section */
.payment-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 1rem;
}
/* Profile Tags */
.profile-tags-section {
margin: 2rem 0;
}
.profile-tags-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.profile-tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.profile-tag-item {
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.tag-name {
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.tag-values {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-value-item {
color: var(--text-primary);
word-break: break-word;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tag-value-item a {
color: var(--accent);
text-decoration: none;
}
.tag-value-item a:hover {
text-decoration: underline;
}
.nip05-value {
color: var(--text-primary);
}
.verified-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
opacity: 0.8;
filter: brightness(0) saturate(100%) invert(1); /* Default white for dark themes */
}
/* Light theme: green check icon */
:global([data-theme="light"]) .verified-icon {
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);
}
/* Dark themes: green check icon */
:global([data-theme="dark"]) .verified-icon,
:global([data-theme="black"]) .verified-icon {
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%);
}
.payment-section h2 {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.payment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.payment-card {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
position: relative;
}
.payment-address {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.875rem;
color: var(--text-secondary);
word-break: break-all;
flex: 1;
min-width: 0;
padding-right: 0.5rem;
}
.payment-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.copy-button,
.lightning-button {
background: transparent;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.copy-button:hover,
.lightning-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent);
}
.copy-button img,
.lightning-button img {
width: 14px;
height: 14px;
}
/* Icon Theming */
.icon-themed {
display: block;
filter: brightness(0) saturate(100%) invert(1) !important; /* Default white for dark themes */
opacity: 1 !important;
}
/* Light theme: black icon */
:global([data-theme="light"]) .icon-themed {
filter: brightness(0) saturate(100%) !important; /* Black in light theme */
opacity: 1 !important;
}
/* Dark themes: white icon */
:global([data-theme="dark"]) .icon-themed,
:global([data-theme="black"]) .icon-themed {
filter: brightness(0) saturate(100%) invert(1) !important; /* White in dark themes */
opacity: 1 !important;
}
/* Hover states - icons in buttons should stay visible */
.action-button:hover .icon-themed {
filter: brightness(0) saturate(100%) invert(1) !important;
opacity: 1 !important;
}
:global([data-theme="light"]) .action-button:hover .icon-themed {
filter: brightness(0) saturate(100%) !important;
opacity: 1 !important;
}
.copy-button:hover .icon-themed,
.lightning-button:hover .icon-themed {
filter: brightness(0) saturate(100%) invert(1) !important;
opacity: 1 !important;
}
:global([data-theme="light"]) .copy-button:hover .icon-themed,
:global([data-theme="light"]) .lightning-button:hover .icon-themed {
filter: brightness(0) saturate(100%) !important;
opacity: 1 !important;
}
/* Tabs */
.tabs-container {
margin-bottom: 2rem;
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
.tab {
padding: 1rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
transition: all 0.2s ease;
position: relative;
top: 2px;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.tab-count {
opacity: 0.7;
font-weight: normal;
}
/* Repositories */
.repo-section-group {
margin-bottom: 3rem;
}
.repo-section-group:last-child {
margin-bottom: 0;
}
.repo-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.repo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.repo-card {
padding: 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.repo-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.repo-name {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.repo-description {
color: var(--text-secondary);
margin: 0.5rem 0;
line-height: 1.5;
}
.repo-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.repo-date {
font-size: 0.875rem;
color: var(--text-muted);
}
/* Messages */
.messages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.messages-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.send-button {
padding: 0.75rem 1.5rem;
background: var(--accent);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.send-button:hover {
opacity: 0.9;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message-card {
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
}
/* Light theme: even lighter background for better contrast */
:global([data-theme="light"]) .message-card {
background: #f5f5f5;
}
/* Dark theme: darker background for better contrast */
:global([data-theme="dark"]) .message-card {
background: rgba(0, 0, 0, 0.3);
}
/* Black theme: gray background (not purple) */
:global([data-theme="black"]) .message-card {
background: #1a1a1a;
}
.message-card.from-viewer {
border-color: var(--accent);
}
/* Light theme: very subtle muted purple background for viewer messages */
:global([data-theme="light"]) .message-card.from-viewer {
background: rgba(138, 43, 226, 0.06);
}
/* Dark theme: subtle muted purple background for viewer messages */
:global([data-theme="dark"]) .message-card.from-viewer {
background: rgba(138, 43, 226, 0.08);
}
/* Black theme: slightly lighter gray with subtle accent border (not purple) */
:global([data-theme="black"]) .message-card.from-viewer {
background: #252525;
border-color: var(--accent);
}
.message-card.to-viewer {
border-left: 4px solid var(--accent);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.message-participants {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.participants-label {
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 500;
}
.message-time {
font-size: 0.875rem;
color: var(--text-muted);
white-space: nowrap;
}
.message-body {
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.6;
}
/* Activity */
.activity-section {
padding: 0;
}
.activity-header {
margin-bottom: 1.5rem;
}
.activity-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-card {
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.activity-card:hover {
border-color: var(--accent);
}
:global([data-theme="light"]) .activity-card {
background: #f5f5f5;
}
:global([data-theme="dark"]) .activity-card {
background: rgba(0, 0, 0, 0.3);
}
:global([data-theme="black"]) .activity-card {
background: #1a1a1a;
}
.activity-context {
margin-bottom: 1rem;
}
.activity-blurb {
color: var(--text-primary);
line-height: 1.6;
margin: 0;
word-wrap: break-word;
}
.activity-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.activity-author {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.activity-time {
font-size: 0.875rem;
color: var(--text-muted);
white-space: nowrap;
}
.activity-link-button {
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 0.5rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
display: inline-block;
}
.activity-link-button:hover {
opacity: 0.9;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--card-bg);
border-radius: 1rem;
padding: 2rem;
max-width: 500px;
width: 100%;
border: 1px solid var(--border-color);
}
.modal h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.modal-note {
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 0.5rem;
}
.message-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
font-family: inherit;
font-size: 1rem;
resize: vertical;
box-sizing: border-box;
background: var(--bg-primary);
color: var(--text-primary);
}
.message-input:focus {
outline: none;
border-color: var(--accent);
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.button-primary,
.button-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.button-primary {
background: var(--accent);
color: var(--accent-text, #ffffff);
}
.button-primary:hover:not(:disabled) {
opacity: 0.9;
}
.button-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.button-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
/* Responsive */
@media (max-width: 768px) {
.profile-page {
padding: 1rem;
}
.profile-header {
grid-template-columns: 1fr;
text-align: center;
}
.profile-avatar-section {
justify-self: center;
}
.repo-grid {
grid-template-columns: 1fr;
}
.payment-grid {
grid-template-columns: 1fr;
}
}
</style>