- Replace broken shield SVG in auth modal Nostr login button with a proper key icon (matches Nostr's key-based identity) - Show algorithm filter buttons inline on xl+ screens (1280px+) where there's room for all of them - Collapse into "Algos" dropdown on md–xl screens to prevent overflow when the header is too narrow Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
8.8 KiB
Vue
307 lines
8.8 KiB
Vue
<template>
|
|
<Transition name="modal-fade">
|
|
<div v-if="isOpen" class="auth-modal-overlay" @click.self="closeModal">
|
|
<div class="auth-modal-container">
|
|
<div class="auth-modal-content">
|
|
<!-- Close Button -->
|
|
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Header -->
|
|
<div class="text-center mb-8">
|
|
<h2 class="text-3xl font-bold text-white mb-2">
|
|
{{ mode === 'login' ? 'Welcome Back' : 'Join IndeedHub' }}
|
|
</h2>
|
|
<p class="text-white/60">
|
|
{{ mode === 'login' ? 'Sign in to continue' : 'Create an account to get started' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Error Message -->
|
|
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm">
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<!-- Cognito Auth Form -->
|
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
|
<!-- Legal Name (Register only) -->
|
|
<div v-if="mode === 'register'" class="form-group">
|
|
<label class="block text-white/80 text-sm font-medium mb-2">Full Name</label>
|
|
<input
|
|
v-model="formData.legalName"
|
|
type="text"
|
|
required
|
|
class="auth-input"
|
|
placeholder="John Doe"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div class="form-group">
|
|
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
|
<input
|
|
v-model="formData.email"
|
|
type="email"
|
|
required
|
|
class="auth-input"
|
|
placeholder="you@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Password -->
|
|
<div class="form-group">
|
|
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
|
<input
|
|
v-model="formData.password"
|
|
type="password"
|
|
required
|
|
class="auth-input"
|
|
:placeholder="mode === 'login' ? 'Enter your password' : 'Create a password'"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Forgot Password Link (Login only) -->
|
|
<div v-if="mode === 'login'" class="text-right">
|
|
<a href="#" @click.prevent="mode = 'forgot'" class="text-sm text-white/60 hover:text-white transition-colors">
|
|
Forgot password?
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<button
|
|
type="submit"
|
|
:disabled="isLoading"
|
|
class="hero-play-button w-full flex items-center justify-center"
|
|
>
|
|
<span v-if="!isLoading">{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
|
<span v-else>Loading...</span>
|
|
</button>
|
|
</form>
|
|
|
|
<!-- Divider -->
|
|
<div class="relative my-6">
|
|
<div class="absolute inset-0 flex items-center">
|
|
<div class="w-full border-t border-white/10"></div>
|
|
</div>
|
|
<div class="relative flex justify-center text-sm">
|
|
<span class="px-4 bg-transparent text-white/40">or</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nostr Login Button -->
|
|
<button
|
|
@click="handleNostrLogin"
|
|
:disabled="isLoading"
|
|
class="hero-info-button w-full flex items-center justify-center gap-2"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
</svg>
|
|
Sign in with Nostr
|
|
</button>
|
|
|
|
<!-- Toggle Mode -->
|
|
<div class="mt-6 text-center text-sm text-white/60">
|
|
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }}
|
|
<button
|
|
@click="toggleMode"
|
|
class="ml-1 text-white hover:text-white/80 font-medium transition-colors"
|
|
>
|
|
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useAuth } from '../composables/useAuth'
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
defaultMode?: 'login' | 'register'
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'close'): void
|
|
(e: 'success'): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
defaultMode: 'login',
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
|
|
|
|
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
|
const formData = ref({
|
|
email: '',
|
|
password: '',
|
|
legalName: '',
|
|
})
|
|
const errorMessage = ref<string | null>(null)
|
|
const isLoading = computed(() => authLoading.value)
|
|
|
|
function closeModal() {
|
|
emit('close')
|
|
// Reset form
|
|
formData.value = { email: '', password: '', legalName: '' }
|
|
errorMessage.value = null
|
|
}
|
|
|
|
function toggleMode() {
|
|
mode.value = mode.value === 'login' ? 'register' : 'login'
|
|
errorMessage.value = null
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
errorMessage.value = null
|
|
|
|
try {
|
|
if (mode.value === 'login') {
|
|
await login(formData.value.email, formData.value.password)
|
|
} else if (mode.value === 'register') {
|
|
await register(formData.value.email, formData.value.password, formData.value.legalName)
|
|
}
|
|
|
|
emit('success')
|
|
closeModal()
|
|
} catch (error: any) {
|
|
errorMessage.value = error.message || 'Authentication failed. Please try again.'
|
|
}
|
|
}
|
|
|
|
async function handleNostrLogin() {
|
|
errorMessage.value = null
|
|
|
|
try {
|
|
// Check for Nostr extension (NIP-07)
|
|
if (!window.nostr) {
|
|
errorMessage.value = 'Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.'
|
|
return
|
|
}
|
|
|
|
// Get public key from extension
|
|
const pubkey = await window.nostr.getPublicKey()
|
|
|
|
// Create authentication event
|
|
const authEvent = {
|
|
kind: 27235, // NIP-98 HTTP Auth
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['u', window.location.origin],
|
|
['method', 'POST'],
|
|
],
|
|
content: '',
|
|
}
|
|
|
|
// Sign event with extension
|
|
const signedEvent = await window.nostr.signEvent(authEvent)
|
|
|
|
// Create session with backend
|
|
await loginWithNostr(pubkey, signedEvent.sig, signedEvent)
|
|
|
|
emit('success')
|
|
closeModal()
|
|
} catch (error: any) {
|
|
console.error('Nostr login failed:', error)
|
|
errorMessage.value = error.message || 'Nostr authentication failed. Please try again.'
|
|
}
|
|
}
|
|
|
|
// Declare window.nostr for TypeScript
|
|
declare global {
|
|
interface Window {
|
|
nostr?: {
|
|
getPublicKey: () => Promise<string>
|
|
signEvent: (event: any) => Promise<any>
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.auth-modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9998;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
|
|
.auth-modal-container {
|
|
width: 100%;
|
|
max-width: 480px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.auth-modal-content {
|
|
position: relative;
|
|
background: rgba(0, 0, 0, 0.65);
|
|
backdrop-filter: blur(40px);
|
|
-webkit-backdrop-filter: blur(40px);
|
|
border-radius: 24px;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
box-shadow:
|
|
0 20px 60px rgba(0, 0, 0, 0.5),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
|
padding: 32px;
|
|
}
|
|
|
|
.auth-input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
color: white;
|
|
font-size: 14px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.auth-input:focus {
|
|
outline: none;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.auth-input::placeholder {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* Modal Transitions */
|
|
.modal-fade-enter-active,
|
|
.modal-fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-fade-enter-from,
|
|
.modal-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.modal-fade-enter-active .auth-modal-content {
|
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-fade-enter-from .auth-modal-content {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
</style>
|