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.
769 lines
21 KiB
769 lines
21 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, combineRelays } from '$lib/config.js'; |
|
import { KIND } from '$lib/types/nostr.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 ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; |
|
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'; |
|
// forwardEventIfEnabled is server-side only - import dynamically if needed |
|
import { userStore } from '$lib/stores/user-store.js'; |
|
|
|
const npub = ($page.params as { npub?: string }).npub || ''; |
|
|
|
let loading = $state(true); |
|
let error = $state<string | null>(null); |
|
let userPubkey = $state<string | null>(null); |
|
let viewerPubkeyHex = $state<string | null>(null); |
|
let lastViewerPubkeyHex = $state<string | null>(null); // Track last viewer pubkey to detect changes |
|
let isReloading = $state(false); // Guard to prevent concurrent reloads |
|
|
|
// Sync with userStore - only reload if viewer pubkey actually changed |
|
$effect(() => { |
|
const currentUser = $userStore; |
|
const newViewerPubkeyHex = currentUser.userPubkeyHex; |
|
|
|
// Only update if viewer pubkey actually changed (not just any store change) |
|
if (newViewerPubkeyHex !== lastViewerPubkeyHex) { |
|
const wasLoggedIn = viewerPubkeyHex !== null; |
|
const isNowLoggedIn = newViewerPubkeyHex !== null; |
|
|
|
// Update viewer pubkey |
|
viewerPubkeyHex = newViewerPubkeyHex; |
|
lastViewerPubkeyHex = newViewerPubkeyHex; |
|
|
|
// Only reload if login state actually changed (logged in -> logged out or vice versa) |
|
// AND we're not already loading/reloading |
|
if ((wasLoggedIn !== isNowLoggedIn) && !loading && !isReloading) { |
|
isReloading = true; |
|
loadUserProfile() |
|
.catch(err => console.warn('Failed to reload user profile after login state change:', err)) |
|
.finally(() => { |
|
isReloading = false; |
|
}); |
|
} |
|
} |
|
}); |
|
let repos = $state<NostrEvent[]>([]); |
|
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); |
|
|
|
// Messages tab |
|
let activeTab = $state<'repos' | 'messages'>('repos'); |
|
let messages = $state<PublicMessage[]>([]); |
|
let loadingMessages = $state(false); |
|
let showSendMessageDialog = $state(false); |
|
let newMessageContent = $state(''); |
|
let sendingMessage = $state(false); |
|
let messagesService: PublicMessagesService | null = null; |
|
|
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
|
|
|
onMount(async () => { |
|
await loadViewerPubkey(); |
|
await loadUserProfile(); |
|
}); |
|
|
|
// Load messages when messages tab is active |
|
$effect(() => { |
|
if (activeTab === 'messages' && userPubkey && messages.length === 0) { |
|
loadMessages(); |
|
} |
|
}); |
|
|
|
async function loadViewerPubkey() { |
|
// Check userStore first |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
userPubkey = currentUser.userPubkey; |
|
viewerPubkeyHex = currentUser.userPubkeyHex; |
|
return; |
|
} |
|
|
|
// Fallback: try NIP-07 if store doesn't have it |
|
if (!isNIP07Available()) { |
|
return; |
|
} |
|
|
|
try { |
|
const viewerPubkey = await getPublicKeyWithNIP07(); |
|
userPubkey = viewerPubkey; |
|
// Convert npub to hex for API calls |
|
try { |
|
const decoded = nip19.decode(viewerPubkey); |
|
if (decoded.type === 'npub') { |
|
viewerPubkeyHex = decoded.data as string; |
|
} |
|
} catch { |
|
viewerPubkeyHex = viewerPubkey; // Assume it's already hex |
|
} |
|
} catch (err) { |
|
console.warn('Failed to load viewer pubkey:', err); |
|
} |
|
} |
|
|
|
async function loadUserProfile() { |
|
// Prevent concurrent loads |
|
if (loading && !isReloading) { |
|
return; |
|
} |
|
|
|
loading = true; |
|
error = null; |
|
|
|
try { |
|
// Decode npub to get pubkey (this is the profile owner, not the viewer) |
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
error = 'Invalid npub format'; |
|
return; |
|
} |
|
const profileOwnerPubkey = decoded.data as string; |
|
|
|
// Only update userPubkey if it's different (avoid triggering effects) |
|
if (userPubkey !== profileOwnerPubkey) { |
|
userPubkey = profileOwnerPubkey; |
|
} |
|
|
|
// Fetch user's repositories via API (with privacy filtering) |
|
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 || []; |
|
|
|
// Try to fetch user profile (kind 0) |
|
const profileEvents = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [0], |
|
authors: [userPubkey], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (profileEvents.length > 0) { |
|
try { |
|
const profile = JSON.parse(profileEvents[0].content); |
|
userProfile = { |
|
name: profile.name, |
|
about: profile.about, |
|
picture: profile.picture |
|
}; |
|
} catch { |
|
// Invalid JSON, ignore |
|
} |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load user profile'; |
|
console.error('Error loading user profile:', err); |
|
} finally { |
|
loading = 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] || ''; |
|
} |
|
|
|
function getRepoId(event: NostrEvent): string { |
|
return event.tags.find(t => t[0] === 'd')?.[1] || ''; |
|
} |
|
|
|
function getForwardingPubkey(): string | null { |
|
if (userPubkey && viewerPubkeyHex && viewerPubkeyHex === userPubkey) { |
|
return userPubkey; |
|
} |
|
return null; |
|
} |
|
|
|
async function loadMessages() { |
|
if (!userPubkey) return; |
|
|
|
loadingMessages = true; |
|
error = null; |
|
|
|
try { |
|
if (!messagesService) { |
|
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS); |
|
} |
|
messages = await messagesService.getAllMessagesForUser(userPubkey, 100); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load messages'; |
|
console.error('Error loading messages:', err); |
|
} finally { |
|
loadingMessages = false; |
|
} |
|
} |
|
|
|
async function sendMessage() { |
|
if (!newMessageContent.trim() || !viewerPubkeyHex || !userPubkey) { |
|
alert('Please enter a message and make sure you are logged in'); |
|
return; |
|
} |
|
|
|
if (viewerPubkeyHex === userPubkey) { |
|
alert('You cannot send a message to yourself'); |
|
return; |
|
} |
|
|
|
sendingMessage = true; |
|
error = null; |
|
|
|
try { |
|
if (!messagesService) { |
|
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS); |
|
} |
|
|
|
// Create the message event |
|
const messageEvent = await messagesService.sendPublicMessage( |
|
viewerPubkeyHex, |
|
newMessageContent.trim(), |
|
[{ pubkey: userPubkey }] |
|
); |
|
|
|
// Get user's relays for publishing |
|
const { outbox } = await getUserRelays(viewerPubkeyHex, nostrClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
// Sign the event |
|
const signedEvent = await signEventWithNIP07(messageEvent); |
|
|
|
// Publish to relays |
|
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'); |
|
} |
|
|
|
// Forward to messaging platforms if user has unlimited access and preferences configured |
|
// This is done server-side via API endpoints, not from client |
|
// The server-side API endpoints (issues, prs, highlights) handle forwarding automatically |
|
|
|
// Reload messages |
|
await loadMessages(); |
|
|
|
// Close dialog and clear content |
|
showSendMessageDialog = false; |
|
newMessageContent = ''; |
|
|
|
alert('Message sent successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to send message'; |
|
console.error('Error sending message:', err); |
|
alert(error); |
|
} finally { |
|
sendingMessage = false; |
|
} |
|
} |
|
|
|
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(); |
|
} |
|
</script> |
|
|
|
<div class="container"> |
|
<header> |
|
<div class="profile-header"> |
|
{#if userProfile?.picture} |
|
<img src={userProfile.picture} alt="Profile" class="profile-picture" /> |
|
{:else} |
|
<div class="profile-picture-placeholder"> |
|
{npub.slice(0, 2).toUpperCase()} |
|
</div> |
|
{/if} |
|
<div class="profile-info"> |
|
<h1>{userProfile?.name || npub.slice(0, 16)}...</h1> |
|
{#if userProfile?.about} |
|
<p class="profile-about">{userProfile.about}</p> |
|
{/if} |
|
<p class="profile-npub">npub: {npub}</p> |
|
</div> |
|
</div> |
|
{#if getForwardingPubkey()} |
|
<ForwardingConfig userPubkeyHex={getForwardingPubkey()!} /> |
|
<div class="dashboard-link"> |
|
<a href="/dashboard" class="dashboard-button"> |
|
<img src="/icons/layout-dashboard.svg" alt="Dashboard" class="icon-inline" /> |
|
View Universal Git Dashboard |
|
</a> |
|
</div> |
|
{/if} |
|
</header> |
|
|
|
<main> |
|
{#if error} |
|
<div class="error">Error: {error}</div> |
|
{/if} |
|
|
|
{#if loading} |
|
<div class="loading">Loading profile...</div> |
|
{:else} |
|
<!-- Tabs --> |
|
<div class="tabs"> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'repos'} |
|
onclick={() => activeTab = 'repos'} |
|
> |
|
Repositories ({repos.length}) |
|
</button> |
|
<button |
|
class="tab-button" |
|
class:active={activeTab === 'messages'} |
|
onclick={() => activeTab = 'messages'} |
|
> |
|
Messages ({messages.length}) |
|
</button> |
|
</div> |
|
|
|
<!-- Repositories Tab --> |
|
{#if activeTab === 'repos'} |
|
<div class="repos-section"> |
|
<h2>Repositories ({repos.length})</h2> |
|
{#if repos.length === 0} |
|
<div class="empty">No repositories found</div> |
|
{:else} |
|
<div class="repo-grid"> |
|
{#each repos as event} |
|
<div |
|
class="repo-card" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
goto(`/repos/${npub}/${getRepoId(event)}`); |
|
} |
|
}} |
|
style="cursor: pointer;"> |
|
<h3>{getRepoName(event)}</h3> |
|
{#if getRepoDescription(event)} |
|
<p class="repo-description">{getRepoDescription(event)}</p> |
|
{/if} |
|
<div class="repo-meta"> |
|
<span class="repo-date"> |
|
{new Date(event.created_at * 1000).toLocaleDateString()} |
|
</span> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Messages Tab --> |
|
{#if activeTab === 'messages'} |
|
<div class="messages-section"> |
|
<div class="messages-header"> |
|
<h2>Public Messages</h2> |
|
{#if viewerPubkeyHex && viewerPubkeyHex !== userPubkey} |
|
<button onclick={() => showSendMessageDialog = true} class="send-message-button"> |
|
Send Message |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
{#if loadingMessages} |
|
<div class="loading">Loading messages...</div> |
|
{:else if messages.length === 0} |
|
<div class="empty">No messages found</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)} |
|
{@const isFromUser = userPubkey !== null && message.pubkey === userPubkey} |
|
{@const isToUser = userPubkey !== null && getMessageRecipients(message).includes(userPubkey)} |
|
<div class="message-item" class:from-viewer={isFromViewer} class:to-viewer={isToViewer && !isFromViewer}> |
|
<div class="message-header"> |
|
<UserBadge pubkey={message.pubkey} /> |
|
<span class="message-time">{formatMessageTime(message.created_at)}</span> |
|
</div> |
|
<div class="message-recipients"> |
|
{#if getMessageRecipients(message).length > 0} |
|
<span class="recipients-label">To:</span> |
|
{#each getMessageRecipients(message) as recipientPubkey} |
|
<UserBadge pubkey={recipientPubkey} /> |
|
{/each} |
|
{/if} |
|
</div> |
|
<div class="message-content">{message.content}</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{/if} |
|
</main> |
|
|
|
<!-- Send Message Dialog --> |
|
{#if showSendMessageDialog} |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Send message" |
|
onclick={() => showSendMessageDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showSendMessageDialog = false)} |
|
tabindex="-1" |
|
> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Send Public Message</h3> |
|
<p class="modal-note">This message will be publicly visible, but will usually not be displayed outside of notifications.</p> |
|
<label> |
|
Message: |
|
<textarea |
|
bind:value={newMessageContent} |
|
rows="6" |
|
placeholder="Type your message..." |
|
disabled={sendingMessage} |
|
></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button |
|
onclick={() => { showSendMessageDialog = false; newMessageContent = ''; }} |
|
class="cancel-button" |
|
disabled={sendingMessage} |
|
> |
|
Cancel |
|
</button> |
|
<button |
|
onclick={sendMessage} |
|
disabled={!newMessageContent.trim() || sendingMessage} |
|
class="send-button" |
|
> |
|
{sendingMessage ? 'Sending...' : 'Send Message'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.profile-header { |
|
display: flex; |
|
gap: 1.5rem; |
|
align-items: flex-start; |
|
margin-bottom: 2rem; |
|
padding-bottom: 2rem; |
|
border-bottom: 1px solid var(--border-color); |
|
} |
|
|
|
.profile-picture { |
|
width: 80px; |
|
height: 80px; |
|
border-radius: 50%; |
|
object-fit: cover; |
|
} |
|
|
|
.profile-picture-placeholder { |
|
width: 80px; |
|
height: 80px; |
|
border-radius: 50%; |
|
background: var(--accent); |
|
color: white; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
font-size: 2rem; |
|
font-weight: bold; |
|
} |
|
|
|
.profile-about { |
|
color: var(--text-secondary); |
|
margin: 0.5rem 0; |
|
} |
|
|
|
.profile-npub { |
|
color: var(--text-muted); |
|
font-size: 0.9rem; |
|
margin: 0.5rem 0 0 0; |
|
font-family: 'IBM Plex Mono', monospace; |
|
} |
|
|
|
.repos-section { |
|
margin-top: 2rem; |
|
} |
|
|
|
.repo-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
|
gap: 1.5rem; |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.messages-section { |
|
margin-top: 1rem; |
|
} |
|
|
|
.messages-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 1.5rem; |
|
} |
|
|
|
.messages-header h2 { |
|
margin: 0; |
|
} |
|
|
|
.send-message-button { |
|
padding: 0.5rem 1rem; |
|
background: var(--accent); |
|
color: white; |
|
border: none; |
|
border-radius: 6px; |
|
cursor: pointer; |
|
font-size: 0.9rem; |
|
transition: background 0.2s; |
|
} |
|
|
|
.send-message-button:hover { |
|
background: var(--accent-dark); |
|
} |
|
|
|
.messages-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.message-item { |
|
padding: 1rem; |
|
background: var(--bg-secondary); |
|
border: 1px solid var(--border-color); |
|
border-radius: 8px; |
|
} |
|
|
|
.message-item.from-viewer { |
|
background: var(--accent-light); |
|
border-color: var(--accent); |
|
} |
|
|
|
.message-item.to-viewer { |
|
border-left: 3px solid var(--accent); |
|
} |
|
|
|
.message-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 0.5rem; |
|
} |
|
|
|
.message-time { |
|
color: var(--text-muted); |
|
font-size: 0.85rem; |
|
} |
|
|
|
.message-recipients { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
margin-bottom: 0.75rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.recipients-label { |
|
color: var(--text-muted); |
|
font-size: 0.85rem; |
|
font-weight: 500; |
|
} |
|
|
|
.message-content { |
|
color: var(--text-primary); |
|
white-space: pre-wrap; |
|
word-wrap: break-word; |
|
line-height: 1.5; |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.modal { |
|
background: var(--card-bg); |
|
border-radius: 8px; |
|
padding: 2rem; |
|
max-width: 500px; |
|
width: 90%; |
|
max-height: 90vh; |
|
overflow-y: auto; |
|
} |
|
|
|
.modal h3 { |
|
margin: 0 0 1rem 0; |
|
} |
|
|
|
.modal-note { |
|
color: var(--text-secondary); |
|
font-size: 0.9rem; |
|
margin-bottom: 1rem; |
|
padding: 0.75rem; |
|
background: var(--bg-secondary); |
|
border-radius: 4px; |
|
} |
|
|
|
.modal label { |
|
display: block; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.modal label textarea { |
|
width: 100%; |
|
padding: 0.75rem; |
|
border: 1px solid var(--border-color); |
|
border-radius: 4px; |
|
font-family: inherit; |
|
font-size: 1rem; |
|
resize: vertical; |
|
box-sizing: border-box; |
|
} |
|
|
|
.modal-actions { |
|
display: flex; |
|
gap: 0.75rem; |
|
justify-content: flex-end; |
|
} |
|
|
|
.cancel-button, |
|
.send-button { |
|
padding: 0.5rem 1rem; |
|
border: none; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 0.9rem; |
|
} |
|
|
|
.cancel-button { |
|
background: var(--bg-secondary); |
|
color: var(--text-primary); |
|
} |
|
|
|
.send-button { |
|
background: var(--accent); |
|
color: white; |
|
} |
|
|
|
.send-button:hover:not(:disabled) { |
|
background: var(--accent-dark); |
|
} |
|
|
|
.send-button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
.dashboard-link { |
|
margin-top: 1rem; |
|
} |
|
|
|
.dashboard-button { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
padding: 0.75rem 1.5rem; |
|
background: var(--accent); |
|
color: white; |
|
text-decoration: none; |
|
border-radius: 6px; |
|
font-size: 0.95rem; |
|
font-weight: 500; |
|
transition: background 0.2s; |
|
} |
|
|
|
.icon-inline { |
|
width: 16px; |
|
height: 16px; |
|
display: inline-block; |
|
vertical-align: middle; |
|
} |
|
|
|
.dashboard-button:hover { |
|
background: var(--accent-dark); |
|
} |
|
</style>
|
|
|