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.
 
 
 
 
 

547 lines
16 KiB

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools';
import { determineUserLevel, decodePubkey } from '../lib/services/nostr/user-level-service.js';
import { userStore } from '../lib/stores/user-store.js';
import { updateActivity, isSessionExpired } from '../lib/services/activity-tracker.js';
let userPubkey = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null);
let checkingAuth = $state(true);
let checkingLevel = $state(false);
let levelMessage = $state<string | null>(null);
// Component mount tracking to prevent state updates after destruction
let isMounted = $state(true);
// React to userStore changes (e.g., when user logs in or out)
$effect(() => {
if (!isMounted || typeof window === 'undefined') return;
try {
const currentUser = $userStore;
if (!currentUser || !isMounted) return;
if (currentUser.userPubkey && currentUser.userPubkeyHex && isMounted) {
// User is logged in - sync local state with store
userPubkey = currentUser.userPubkey;
userPubkeyHex = currentUser.userPubkeyHex;
} else if (isMounted) {
// User has logged out - clear local state
userPubkey = null;
userPubkeyHex = null;
}
} catch (err) {
// Ignore errors during destruction
if (isMounted) {
console.warn('User store sync error in splash page:', err);
}
}
});
onMount(() => {
if (typeof window === 'undefined' || !isMounted) return;
// Prevent body scroll when splash page is shown
try {
document.body.style.overflow = 'hidden';
} catch (err) {
if (isMounted) {
console.warn('Failed to set body overflow:', err);
}
}
// Check for session expiry first
try {
if (isSessionExpired()) {
// Session expired - logout user
if (isMounted) {
userStore.reset();
checkingAuth = true;
checkAuth();
}
} else if (isMounted) {
// Check userStore first - if user is already logged in, use store values
const currentUser = $userStore;
if (currentUser && currentUser.userPubkey && currentUser.userPubkeyHex) {
// User is already logged in - use store values
userPubkey = currentUser.userPubkey;
userPubkeyHex = currentUser.userPubkeyHex;
checkingAuth = false;
// Update activity to extend session
updateActivity();
// Don't redirect immediately - let the user see they're logged in
// They can click "View Repositories" or navigate away
} else if (isMounted) {
// User not logged in - check if extension is available
checkingAuth = true;
checkAuth();
}
}
} catch (err) {
if (isMounted) {
console.warn('Failed to check auth on mount:', err);
}
}
});
onDestroy(() => {
// Mark component as unmounted first
isMounted = false;
// Re-enable scrolling when component is destroyed
try {
if (typeof document !== 'undefined' && document.body) {
document.body.style.overflow = '';
}
} catch (err) {
// Ignore errors during cleanup
}
});
async function checkAuth() {
if (!isMounted || typeof window === 'undefined') return;
if (isMounted) {
checkingAuth = true;
}
// Check userStore first - if user has logged out, clear state
try {
const currentUser = $userStore;
if (!currentUser || !currentUser.userPubkey) {
if (isMounted) {
userPubkey = null;
userPubkeyHex = null;
checkingAuth = false;
}
return;
}
if (!isMounted) return;
if (isNIP07Available()) {
try {
userPubkey = await getPublicKeyWithNIP07();
if (!isMounted) return;
// Convert npub to hex for API calls
// NIP-07 may return either npub or hex
if (/^[0-9a-f]{64}$/i.test(userPubkey)) {
// Already hex format
if (isMounted) {
userPubkeyHex = userPubkey.toLowerCase();
}
} else if (isMounted) {
// Try to decode as npub
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
} else {
userPubkeyHex = userPubkey; // Unknown type, use as-is
}
} catch {
if (isMounted) {
userPubkeyHex = userPubkey; // Assume it's already hex or use as-is
}
}
}
} catch (err) {
if (isMounted) {
console.warn('Failed to load user pubkey:', err);
userPubkey = null;
userPubkeyHex = null;
}
}
} else if (isMounted) {
// Extension not available, clear state
userPubkey = null;
userPubkeyHex = null;
}
if (isMounted) {
checkingAuth = false;
}
} catch (err) {
if (isMounted) {
console.warn('Auth check error:', err);
checkingAuth = false;
}
}
}
async function handleLogin() {
if (!isNIP07Available()) {
alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.');
return;
}
try {
checkingLevel = true;
levelMessage = 'Connecting to Nostr extension...';
// Get public key directly from NIP-07
let pubkey: string;
try {
pubkey = await getPublicKeyWithNIP07();
if (!pubkey) {
throw new Error('No public key returned from extension');
}
} catch (err) {
console.error('Failed to get public key from NIP-07:', err);
checkingLevel = false;
levelMessage = null;
alert('Failed to connect to Nostr extension. Please make sure your extension is unlocked and try again.');
return;
}
// Convert npub to hex for API calls
let pubkeyHex: string;
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
// Already hex format
pubkeyHex = pubkey.toLowerCase();
userPubkey = pubkey;
} else {
// Try to decode as npub
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkeyHex = decoded.data as string;
userPubkey = pubkey; // Keep original npub format
} else {
throw new Error('Invalid pubkey format');
}
} catch (decodeErr) {
console.error('Failed to decode pubkey:', decodeErr);
checkingLevel = false;
levelMessage = null;
alert('Invalid public key format. Please try again.');
return;
}
}
userPubkeyHex = pubkeyHex;
levelMessage = 'Verifying relay write access...';
// Determine user level (checks relay write access)
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex);
// Update user store
userStore.setUser(
levelResult.userPubkey,
levelResult.userPubkeyHex,
levelResult.level,
levelResult.error || null
);
// Update activity tracking on successful login
updateActivity();
// Check for pending transfer events
if (levelResult.userPubkeyHex) {
try {
const response = await fetch('/api/transfers/pending', {
headers: {
'X-User-Pubkey': levelResult.userPubkeyHex
}
});
if (response.ok) {
const data = await response.json();
if (data.pendingTransfers && data.pendingTransfers.length > 0) {
// Trigger a custom event to notify layout about pending transfers
// The layout component will handle displaying the notifications
window.dispatchEvent(new CustomEvent('pendingTransfers', {
detail: { transfers: data.pendingTransfers }
}));
}
}
} catch (err) {
console.error('Failed to check for pending transfers:', err);
// Don't fail login if transfer check fails
}
}
checkingLevel = false;
levelMessage = null;
// Show appropriate message based on level
const { hasUnlimitedAccess } = await import('../lib/utils/user-access.js');
if (hasUnlimitedAccess(levelResult.level)) {
levelMessage = 'Unlimited access granted!';
} else if (levelResult.level === 'rate_limited') {
levelMessage = 'Logged in with rate-limited access.';
}
// User is logged in, go to repos page
goto('/repos');
} catch (err) {
console.error('Login failed:', err);
checkingLevel = false;
levelMessage = null;
const errorMessage = err instanceof Error ? err.message : String(err);
alert(`Failed to login: ${errorMessage}. Please make sure your Nostr extension is unlocked and try again.`);
}
}
function handleViewPublic() {
goto('/repos');
}
// Get page data for OpenGraph metadata
const pageData = $page.data as {
title?: string;
description?: string;
image?: string;
url?: string;
ogType?: string;
};
</script>
<svelte:head>
<title>{pageData.title || 'GitRepublic - Decentralized Git Hosting on Nostr'}</title>
<meta name="description" content={pageData.description || 'A decentralized git hosting platform built on Nostr. Host your repositories, collaborate with others, and maintain full control of your code.'} />
<!-- OpenGraph / Facebook -->
<meta property="og:type" content={pageData.ogType || 'website'} />
<meta property="og:title" content={pageData.title || 'GitRepublic - Decentralized Git Hosting on Nostr'} />
<meta property="og:description" content={pageData.description || 'A decentralized git hosting platform built on Nostr. Host your repositories, collaborate with others, and maintain full control of your code.'} />
<meta property="og:url" content={pageData.url || `https://${$page.url.host}${$page.url.pathname}`} />
{#if pageData.image}
<meta property="og:image" content={pageData.image} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
{/if}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageData.title || 'GitRepublic - Decentralized Git Hosting on Nostr'} />
<meta name="twitter:description" content={pageData.description || 'A decentralized git hosting platform built on Nostr. Host your repositories, collaborate with others, and maintain full control of your code.'} />
{#if pageData.image}
<meta name="twitter:image" content={pageData.image} />
{/if}
</svelte:head>
<div class="splash-container">
<div class="splash-background">
<img src="/logo.png" alt="GitRepublic Logo" class="splash-logo-bg" />
</div>
<div class="splash-content">
<div class="splash-header">
<h1 class="splash-title">GitRepublic</h1>
<p class="splash-subtitle">Decentralized Git Hosting on Nostr</p>
</div>
<div class="splash-message">
{#if checkingAuth || checkingLevel}
<p class="splash-text">{levelMessage || 'Checking authentication...'}</p>
{#if checkingLevel && levelMessage}
<p class="splash-text-secondary">This may take a few seconds...</p>
{/if}
{:else if userPubkey}
<p class="splash-text">Welcome back! You're logged in.</p>
<p class="splash-text-secondary">You can now access all repositories you have permission to view.</p>
{:else}
<p class="splash-text">Login for full functionality</p>
<p class="splash-text-secondary">Access your private repositories, create new ones, and collaborate with others.</p>
<p class="splash-text-secondary">Or browse public repositories without logging in.</p>
{/if}
</div>
<div class="splash-actions">
{#if checkingAuth || checkingLevel}
<div class="splash-loading">Loading...</div>
{:else if userPubkey}
<button class="splash-button splash-button-primary" onclick={() => goto('/repos')}>
View Repositories
</button>
{:else}
<button class="splash-button splash-button-primary" onclick={handleLogin}>
Login with Nostr
</button>
<button class="splash-button splash-button-secondary" onclick={handleViewPublic}>
View Public Repositories
</button>
{/if}
</div>
</div>
</div>
<style>
.splash-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
overflow: hidden;
background: linear-gradient(135deg, var(--bg-primary, #f5f5f5) 0%, var(--bg-secondary, #e8e8e8) 100%);
/* Ensure it covers everything and blocks interaction */
pointer-events: auto;
}
.splash-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.05;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
}
.splash-logo-bg {
width: 80vw;
height: 80vh;
object-fit: contain;
filter: blur(20px);
}
.splash-content {
position: relative;
z-index: 1;
text-align: center;
padding: 3rem 2rem;
max-width: 800px;
width: 100%;
}
.splash-header {
margin-bottom: 3rem;
}
.splash-title {
font-size: 3.5rem;
font-weight: 700;
margin: 0 0 0.5rem;
color: var(--text-primary, #1a1a1a);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.splash-subtitle {
font-size: 1.5rem;
color: var(--text-secondary, #666);
margin: 0;
font-weight: 300;
}
.splash-message {
margin-bottom: 3rem;
}
.splash-text {
font-size: 1.5rem;
color: var(--text-primary, #1a1a1a);
margin: 0 0 1rem;
font-weight: 500;
}
.splash-text-secondary {
font-size: 1.1rem;
color: var(--text-secondary, #666);
margin: 0.5rem 0;
line-height: 1.6;
}
.splash-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 4rem;
}
.splash-button {
padding: 1rem 2.5rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
min-width: 200px;
}
.splash-button-primary {
background: var(--accent, #007bff);
color: var(--accent-text, #ffffff);
box-shadow: 0 4px 6px rgba(0, 123, 255, 0.3);
}
.splash-button-primary:hover {
background: var(--accent-hover, #0056b3);
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 123, 255, 0.4);
}
.splash-button-secondary {
background: var(--card-bg, #ffffff);
color: var(--accent, #007bff);
border: 2px solid var(--accent, #007bff);
}
.splash-button-secondary:hover {
background: var(--accent, #007bff);
color: var(--accent-text, #ffffff);
transform: translateY(-2px);
}
.splash-loading {
font-size: 1.2rem;
color: var(--text-secondary, #666);
padding: 2rem;
}
@media (max-width: 768px) {
.splash-title {
font-size: 2.5rem;
}
.splash-subtitle {
font-size: 1.2rem;
}
.splash-text {
font-size: 1.2rem;
}
.splash-button {
width: 100%;
min-width: unset;
}
}
@media (prefers-color-scheme: dark) {
.splash-container {
background: linear-gradient(135deg, var(--bg-primary, #1a1a1a) 0%, var(--bg-secondary, #2d2d2d) 100%);
}
.splash-title {
color: var(--text-primary, #f5f5f5);
}
.splash-text {
color: var(--text-primary, #f5f5f5);
}
.splash-button-secondary {
background: var(--bg-secondary, #2d2d2d);
border-color: var(--accent, #007bff);
}
}
</style>