feat: implement three-phase Amber login flow in AuthModal

- Updated Amber login button to reflect new three-phase process.
- Added handling for reading public key and signature from clipboard.
- Introduced new state management for Amber login phases.
- Enhanced user feedback during each phase with appropriate messaging.
- Refactored related functions for clarity and maintainability.
This commit is contained in:
Dorian
2026-02-14 09:28:25 +00:00
parent 38293b1f95
commit 276dab207c
6 changed files with 434 additions and 170 deletions

View File

@@ -86,7 +86,7 @@
</div>
</div>
<!-- Amber Login (NIP-55 Android Signer) -->
<!-- Amber Login (NIP-55 Android Signer) 3-phase flow -->
<button
v-if="amberPhase === 'idle'"
@click="handleAmberOpen"
@@ -99,12 +99,38 @@
</svg>
Sign in with Amber
</button>
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
<!-- Phase 2: Read pubkey from clipboard -->
<div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3">
<p class="text-sm text-white/60 text-center">
Approved in Amber? Tap below to complete sign-in.
Approved in Amber? Tap below to continue.
</p>
<button
@click="handleAmberComplete"
@click="handleAmberPubkeyRead"
:disabled="isLoading"
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Continue
</button>
<button
@click="amberPhase = 'idle'; amberPubkey = null"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
>
Cancel
</button>
</div>
<!-- Phase 3: Read signed NIP-98 event from clipboard -->
<div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3">
<p class="text-sm text-white/60 text-center">
Approve the sign-in request in Amber, then tap below.
</p>
<button
@click="handleAmberSignComplete"
:disabled="isLoading"
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
>
@@ -115,7 +141,7 @@
Complete Sign-in
</button>
<button
@click="amberPhase = 'idle'"
@click="amberPhase = 'idle'; amberPubkey = null"
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
>
Cancel
@@ -203,6 +229,7 @@ import { ref, computed, watch } from 'vue'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { accountManager } from '../lib/accounts'
import { authService } from '../services/auth.service'
interface Props {
isOpen: boolean
@@ -227,8 +254,9 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
const errorMessage = ref<string | null>(null)
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
// Amber two-phase login state
const amberPhase = ref<'idle' | 'waiting'>('idle')
// Amber three-phase login state
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
const amberPubkey = ref<string | null>(null)
// ── Sovereign Identity Flow ──────────────────────────────────────
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
@@ -254,6 +282,7 @@ function closeModal() {
emit('close')
errorMessage.value = null
amberPhase.value = 'idle'
amberPubkey.value = null
sovereignPhase.value = 'normal'
sovereignDismissed.value = false
generatedKeys.value = null
@@ -396,7 +425,7 @@ async function handleNostrLogin() {
}
/**
* Amber Login — Phase 1: Open Amber via Android intent.
* Amber Login — Phase 1: Open Amber via Android intent to get the pubkey.
*
* Amber copies the pubkey to the clipboard. Mobile browsers block
* automatic clipboard reads (navigator.clipboard.readText requires a
@@ -413,25 +442,41 @@ function handleAmberOpen() {
return
}
// Open Amber via the nostrsigner intent
// Open Amber via the nostrsigner intent to get pubkey
const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end'
window.open(intent, '_blank')
// Switch to phase 2 — user will tap "Complete Sign-in" after Amber approves
amberPhase.value = 'waiting'
// Switch to phase 2 — user will tap "Continue" after Amber approves
amberPhase.value = 'waiting-pubkey'
}
/**
* Amber Login — Phase 2: Read the pubkey from the clipboard.
* Read a pubkey from clipboard text. Handles hex, npub, and nprofile formats.
*/
async function decodePubkeyFromClipboard(clipboardText: string): Promise<string> {
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
return clipboardText
}
if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
const { decodeProfilePointer } = await import('applesauce-core/helpers')
const decoded = decodeProfilePointer(clipboardText)
if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard')
return decoded.pubkey
}
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...')
}
/**
* Amber Login — Phase 2: Read the pubkey from the clipboard, then
* construct the NIP-98 event and open Amber again to sign it.
*
* This runs inside a click handler (user gesture) so
* navigator.clipboard.readText() is permitted by the browser.
*/
async function handleAmberComplete() {
async function handleAmberPubkeyRead() {
errorMessage.value = null
try {
// Read pubkey from clipboard (allowed because this is a user gesture)
if (!navigator.clipboard?.readText) {
throw new Error('Clipboard API not available')
}
@@ -441,55 +486,131 @@ async function handleAmberComplete() {
throw new Error('Clipboard is empty. Please try again — make sure you approved the request in Amber.')
}
// Decode the pubkey (Amber may return hex, npub, or nprofile)
let pubkey: string
if (/^[0-9a-f]{64}$/i.test(clipboardText)) {
pubkey = clipboardText
} else if (clipboardText.startsWith('npub') || clipboardText.startsWith('nprofile')) {
// Decode using nostr-tools or applesauce helpers
const { decodeProfilePointer } = await import('applesauce-core/helpers')
const decoded = decodeProfilePointer(clipboardText)
if (!decoded?.pubkey) throw new Error('Could not decode npub from clipboard')
pubkey = decoded.pubkey
} else {
throw new Error('Clipboard does not contain a valid Nostr public key. Got: ' + clipboardText.slice(0, 20) + '...')
}
const pubkey = await decodePubkeyFromClipboard(clipboardText)
amberPubkey.value = pubkey
// Register the Amber account in the account manager
const { AmberClipboardSigner, AmberClipboardAccount, accountManager } = await import('../lib/accounts')
// Register the Amber account in the account manager so the app
// knows who is signing in (the signer is set but we won't call
// signEvent on it — we use intents + clipboard for that)
const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts')
const signer = new AmberClipboardSigner()
signer.pubkey = pubkey
const account = new AmberClipboardAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
// Create backend auth session
await loginWithNostr(pubkey, 'amber-nip55', {
// Build the unsigned NIP-98 event that the backend expects
const sessionUrl = authService.getNostrSessionUrl()
const unsignedEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin],
['u', sessionUrl],
['method', 'POST'],
],
content: '',
pubkey,
})
}
// Open Amber to sign this NIP-98 event
const eventJson = JSON.stringify(unsignedEvent)
const intent = `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=event;S.type=sign_event;S.event=${encodeURIComponent(eventJson)};end`
window.open(intent, '_blank')
// Move to phase 3 — user will tap "Complete Sign-in" after Amber signs
amberPhase.value = 'waiting-signature'
} catch (error: any) {
console.error('Amber pubkey read failed:', error)
handleAmberError(error)
}
}
/**
* Amber Login — Phase 3: Read the signed NIP-98 event from the
* clipboard and complete authentication.
*
* This runs inside a click handler (user gesture) so
* navigator.clipboard.readText() is permitted by the browser.
*/
async function handleAmberSignComplete() {
errorMessage.value = null
try {
if (!navigator.clipboard?.readText) {
throw new Error('Clipboard API not available')
}
const clipboardText = (await navigator.clipboard.readText()).trim()
if (!clipboardText) {
throw new Error('Clipboard is empty. Make sure you approved the signing request in Amber.')
}
// Amber may return the full signed event JSON or just the signature hex
let authHeader: string
if (clipboardText.startsWith('{')) {
// Full signed event JSON — use directly
const signedEvent = JSON.parse(clipboardText)
if (!signedEvent.sig) {
throw new Error('Signed event is missing the signature. Please try again.')
}
authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
} else if (/^[0-9a-f]{128}$/i.test(clipboardText)) {
// Just the signature hex — we need to reconstruct the full event
// Build the same unsigned event we sent to Amber
const pubkey = amberPubkey.value
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
const sessionUrl = authService.getNostrSessionUrl()
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', sessionUrl],
['method', 'POST'],
],
content: '',
pubkey,
sig: clipboardText,
}
// Calculate event ID using nostr-tools
const { getEventHash } = await import('nostr-tools/pure')
;(event as any).id = getEventHash(event as any)
authHeader = `Nostr ${btoa(JSON.stringify(event))}`
} else {
throw new Error('Clipboard does not contain a signed event. Got: ' + clipboardText.slice(0, 20) + '...')
}
const pubkey = amberPubkey.value
if (!pubkey) throw new Error('Lost track of the pubkey. Please start over.')
// Authenticate with the pre-signed NIP-98 header (bypasses signer.signEvent)
await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader)
amberPhase.value = 'idle'
amberPubkey.value = null
emit('success')
closeModal()
} catch (error: any) {
console.error('Amber clipboard read failed:', error)
console.error('Amber sign-in failed:', error)
handleAmberError(error)
}
}
if (error.message?.includes('Clipboard is empty')) {
errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap "Complete Sign-in".'
} else if (error.message?.includes('not available')) {
errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.'
} else if (error.message?.includes('permission')) {
errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.'
} else {
errorMessage.value = error.message || 'Failed to read from Amber. Please try again.'
}
/**
* Shared error handler for Amber flow phases.
*/
function handleAmberError(error: any) {
if (error.message?.includes('Clipboard is empty')) {
errorMessage.value = 'Clipboard is empty. Open Amber, approve the request, then come back and tap the button.'
} else if (error.message?.includes('not available')) {
errorMessage.value = 'Clipboard access is not available. Try using Chrome on Android.'
} else if (error.message?.includes('permission')) {
errorMessage.value = 'Clipboard permission denied. Please allow clipboard access and try again.'
} else {
errorMessage.value = error.message || 'Failed to read from Amber. Please try again.'
}
}

View File

@@ -312,6 +312,8 @@ async function fetchStream() {
isLoadingStream.value = true
streamError.value = null
let streamFile: string | null = null
try {
// Try the backend stream endpoint (handles DRM, presigned URLs, etc.)
const info = await indeehubApiService.getStreamingUrl(contentId)
@@ -322,20 +324,15 @@ async function fetchStream() {
if (errorMsg) {
console.warn('[VideoPlayer] Backend returned error:', errorMsg)
streamError.value = errorMsg
return
} else {
const url = (info as any).file || info.url
if (!url) {
console.error('[VideoPlayer] No stream URL in response:', info)
streamError.value = 'No video URL returned from server.'
} else {
streamFile = url
}
}
const streamFile = (info as any).file || info.url
if (!streamFile) {
console.error('[VideoPlayer] No stream URL in response:', info)
streamError.value = 'No video URL returned from server.'
return
}
console.log('[VideoPlayer] Playing URL:', streamFile)
hlsStreamUrl.value = streamFile
await nextTick()
initPlayer(streamFile)
} catch (err: any) {
const status = err?.response?.status
const responseData = err?.response?.data
@@ -353,6 +350,15 @@ async function fetchStream() {
} finally {
isLoadingStream.value = false
}
// Init the player AFTER isLoadingStream is false so the <video> element
// is rendered in the DOM and videoEl ref is available.
if (streamFile && !streamError.value) {
console.log('[VideoPlayer] Playing URL:', streamFile)
hlsStreamUrl.value = streamFile
await nextTick()
initPlayer(streamFile)
}
}
function initPlayer(url: string) {

View File

@@ -21,8 +21,13 @@ export function useAuth() {
return authStore.loginWithCognito(email, password)
}
const loginWithNostr = async (pubkey: string, signature: string, event: any) => {
return authStore.loginWithNostr(pubkey, signature, event)
const loginWithNostr = async (
pubkey: string,
signature: string,
event: any,
preSignedAuthHeader?: string,
) => {
return authStore.loginWithNostr(pubkey, signature, event, preSignedAuthHeader)
}
const register = async (email: string, password: string, legalName: string) => {

View File

@@ -117,8 +117,16 @@ class AuthService {
/**
* Create Nostr session via NIP-98 HTTP Auth.
* Signs a kind-27235 event and sends it as the Authorization header.
*
* @param _request Standard session request (pubkey, signature, event)
* @param preSignedAuthHeader Optional pre-built `Nostr <base64>` header.
* When provided the signer is NOT called — useful for Amber / clipboard
* signers that cannot sign inline.
*/
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
async createNostrSession(
_request: NostrSessionRequest,
preSignedAuthHeader?: string,
): Promise<NostrSessionResponse> {
const relativeUrl = `${apiConfig.baseURL}/auth/nostr/session`
const method = 'POST'
@@ -126,8 +134,8 @@ class AuthService {
// what the backend reconstructs from Host / X-Forwarded-Proto.
const absoluteUrl = new URL(relativeUrl, window.location.origin).toString()
// Create NIP-98 auth header — no body is sent
const authHeader = await createNip98AuthHeader(absoluteUrl, method)
// Use the pre-signed header if provided; otherwise sign inline
const authHeader = preSignedAuthHeader ?? await createNip98AuthHeader(absoluteUrl, method)
// Send the request without a body.
// We use axios({ method }) instead of axios.post(url, data) to
@@ -156,6 +164,16 @@ class AuthService {
return data
}
/**
* Get the absolute URL for the Nostr session endpoint.
* Exposed so callers (e.g. Amber flow) can construct the NIP-98 event
* with the correct URL tag before sending it to an external signer.
*/
getNostrSessionUrl(): string {
const relativeUrl = `${apiConfig.baseURL}/auth/nostr/session`
return new URL(relativeUrl, window.location.origin).toString()
}
/**
* Refresh Nostr session
*/

View File

@@ -243,9 +243,19 @@ export const useAuthStore = defineStore('auth', () => {
}
/**
* Login with Nostr signature
* Login with Nostr signature.
*
* @param preSignedAuthHeader Optional pre-built `Nostr <base64>` header.
* When provided the active signer is NOT called for NIP-98 signing.
* This is required for Amber / clipboard-based signers that cannot
* sign events inline.
*/
async function loginWithNostr(pubkey: string, signature: string, event: any) {
async function loginWithNostr(
pubkey: string,
signature: string,
event: any,
preSignedAuthHeader?: string,
) {
isLoading.value = true
// Mock Nostr login helper
@@ -271,12 +281,12 @@ export const useAuthStore = defineStore('auth', () => {
}
// Real API call — creates NIP-98 signed session via the active
// Nostr account in accountManager (set by useAccounts before this call)
const response = await authService.createNostrSession({
pubkey,
signature,
event,
})
// Nostr account in accountManager (set by useAccounts before this call).
// When preSignedAuthHeader is provided the signer is bypassed.
const response = await authService.createNostrSession(
{ pubkey, signature, event },
preSignedAuthHeader,
)
nostrPubkey.value = pubkey
authType.value = 'nostr'

View File

@@ -1,118 +1,148 @@
<template>
<div class="profile-view">
<!-- Sticky Top Bar (backstage-style) -->
<div class="page-top-bar-wrapper">
<div class="page-top-bar-inner flex items-center gap-3">
<h1 class="text-xl md:text-2xl font-bold text-white flex-shrink-0">Profile</h1>
<!-- Desktop Quick Actions -->
<div class="hidden md:flex items-center gap-2 flex-1 justify-end">
<button @click="$router.push('/library')" class="bar-tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<span>My Library</span>
</button>
<button @click="handleSourceToggle" class="bar-tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<span>{{ contentSourceLabel }}</span>
</button>
<button @click="handleSignOut" class="bar-tab bar-tab-danger">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Sign Out</span>
</button>
</div>
</div>
</div>
<!-- Main Content -->
<main class="pt-24 pb-20 px-4">
<div class="mx-auto max-w-4xl">
<!-- Page Title -->
<h1 class="text-4xl md:text-5xl font-bold text-white mb-8">Profile</h1>
<!-- User Info Section -->
<section class="glass-card p-6 mb-6">
<h2 class="text-2xl font-bold text-white mb-6">Account Information</h2>
<div class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Name</label>
<div class="text-white font-medium">{{ user?.legalName }}</div>
</div>
<main class="page-main">
<div class="mx-auto max-w-6xl">
<!-- Desktop 2x2 Grid / Mobile single column -->
<div class="profile-grid">
<!-- Row 1, Col 1: Account Information -->
<section class="glass-card p-6">
<h2 class="text-xl font-bold text-white mb-5">Account Information</h2>
<div>
<label class="block text-white/60 text-sm mb-1">Email</label>
<div class="text-white font-medium">{{ user?.email }}</div>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Member Since</label>
<div class="text-white font-medium">{{ formatDate(user?.createdAt) }}</div>
</div>
</div>
</section>
<!-- Subscription Section -->
<section class="glass-card p-6 mb-6">
<h2 class="text-2xl font-bold text-white mb-6">Subscription</h2>
<div v-if="subscription" class="space-y-4">
<div class="flex justify-between items-center">
<div class="space-y-4">
<div>
<div class="text-lg font-bold text-white capitalize">{{ subscription.tier.replace('-', ' ') }}</div>
<div class="text-white/60 text-sm">{{ subscription.status === 'active' ? 'Active' : 'Inactive' }}</div>
<label class="block text-white/60 text-sm mb-1">Name</label>
<div class="text-white font-medium">{{ user?.legalName }}</div>
</div>
<div class="text-right">
<div class="text-white font-medium">Renews on</div>
<div class="text-white/60 text-sm">{{ formatDate(subscription.currentPeriodEnd) }}</div>
<div>
<label class="block text-white/60 text-sm mb-1">Email</label>
<div class="text-white font-medium">{{ user?.email }}</div>
</div>
<div>
<label class="block text-white/60 text-sm mb-1">Member Since</label>
<div class="text-white font-medium">{{ formatDate(user?.createdAt) }}</div>
</div>
</div>
</section>
<!-- Row 1, Col 2: Subscription -->
<section class="glass-card p-6">
<h2 class="text-xl font-bold text-white mb-5">Subscription</h2>
<div v-if="subscription" class="space-y-4">
<div class="flex justify-between items-center">
<div>
<div class="text-lg font-bold text-white capitalize">{{ subscription.tier.replace('-', ' ') }}</div>
<div class="text-white/60 text-sm">{{ subscription.status === 'active' ? 'Active' : 'Inactive' }}</div>
</div>
<div class="text-right">
<div class="text-white font-medium">Renews on</div>
<div class="text-white/60 text-sm">{{ formatDate(subscription.currentPeriodEnd) }}</div>
</div>
</div>
<button
v-if="subscription.status === 'active' && !subscription.cancelAtPeriodEnd"
@click="handleCancelSubscription"
class="hero-info-button w-full"
>
Cancel Subscription
</button>
<div v-if="subscription.cancelAtPeriodEnd" class="text-orange-400 text-sm">
Your subscription will end on {{ formatDate(subscription.currentPeriodEnd) }}
</div>
</div>
<button
v-if="subscription.status === 'active' && !subscription.cancelAtPeriodEnd"
@click="handleCancelSubscription"
class="hero-info-button w-full"
>
Cancel Subscription
</button>
<div v-if="subscription.cancelAtPeriodEnd" class="text-orange-400 text-sm">
Your subscription will end on {{ formatDate(subscription.currentPeriodEnd) }}
<div v-else class="text-center py-6">
<div class="text-white/60 mb-4">No active subscription</div>
<button @click="$router.push('/')" class="hero-play-button">
Browse Plans
</button>
</div>
</div>
<div v-else class="text-center py-6">
<div class="text-white/60 mb-4">No active subscription</div>
<button @click="$router.push('/')" class="hero-play-button">
Browse Plans
</button>
</div>
</section>
</section>
<!-- Nostr Section -->
<section class="glass-card p-6 mb-6">
<h2 class="text-2xl font-bold text-white mb-6">Nostr Integration</h2>
<div v-if="user?.nostrPubkey" class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Linked Nostr Public Key</label>
<div class="text-white font-mono text-xs break-all">{{ user.nostrPubkey }}</div>
<!-- Row 2, Col 1: Nostr Integration -->
<section class="glass-card p-6">
<h2 class="text-xl font-bold text-white mb-5">Nostr Integration</h2>
<div v-if="user?.nostrPubkey" class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Linked Nostr Public Key</label>
<div class="text-white font-mono text-xs break-all">{{ user.nostrPubkey }}</div>
</div>
<button
@click="handleUnlinkNostr"
class="hero-info-button"
>
Unlink Nostr Account
</button>
</div>
<button
@click="handleUnlinkNostr"
class="hero-info-button"
>
Unlink Nostr Account
</button>
</div>
<div v-else class="text-center py-6">
<div class="text-white/60 mb-4">Link your Nostr account to enable social features</div>
<button @click="handleLinkNostr" class="hero-play-button">
Link Nostr Account
</button>
</div>
</section>
<!-- Filmmaker Section (if applicable) -->
<section v-if="user?.filmmaker" class="glass-card p-6 mb-6">
<h2 class="text-2xl font-bold text-white mb-6">Filmmaker Dashboard</h2>
<div class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Professional Name</label>
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
<div v-else class="text-center py-6">
<div class="text-white/60 mb-4">Link your Nostr account to enable social features</div>
<button @click="handleLinkNostr" class="hero-play-button">
Link Nostr Account
</button>
</div>
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
Go to Backstage
</button>
</div>
</section>
</section>
<!-- Quick Actions (mirrors desktop profile dropdown) -->
<section class="glass-card p-6 mb-6">
<h2 class="text-2xl font-bold text-white mb-6">Quick Actions</h2>
<!-- Row 2, Col 2: Filmmaker Dashboard (if applicable) -->
<section v-if="user?.filmmaker" class="glass-card p-6">
<h2 class="text-xl font-bold text-white mb-5">Filmmaker Dashboard</h2>
<div class="space-y-4">
<div>
<label class="block text-white/60 text-sm mb-1">Professional Name</label>
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
</div>
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
Go to Backstage
</button>
</div>
</section>
</div>
<!-- Mobile Quick Actions (visible only on mobile) -->
<section class="glass-card p-6 mt-4 md:hidden">
<h2 class="text-xl font-bold text-white mb-5">Quick Actions</h2>
<div class="space-y-3">
<!-- My Library -->
<button @click="$router.push('/library')" class="profile-action-row">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@@ -123,7 +153,6 @@
</svg>
</button>
<!-- Content Source Toggle -->
<button @click="handleSourceToggle" class="profile-action-row">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
@@ -132,7 +161,6 @@
<span class="text-sm text-white/50">{{ contentSourceLabel }}</span>
</button>
<!-- Sign Out -->
<div class="border-t border-white/10 my-1"></div>
<button @click="handleSignOut" class="profile-action-row text-red-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -144,7 +172,6 @@
</section>
</div>
</main>
</div>
</template>
@@ -282,6 +309,82 @@ declare global {
background: #0a0a0a;
}
/* Sticky top bar - matches backstage pages */
.page-top-bar-wrapper {
position: sticky;
top: 0;
z-index: 40;
padding: 112px 16px 8px 16px;
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
}
.page-top-bar-inner {
padding: 12px 16px;
border-radius: 16px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
/* Bar tab buttons for quick actions */
.bar-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.7);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.bar-tab:hover {
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
}
.bar-tab-danger {
color: rgba(248, 113, 113, 0.8);
}
.bar-tab-danger:hover {
color: rgba(248, 113, 113, 1);
background: rgba(248, 113, 113, 0.1);
}
/* Main content area */
.page-main {
padding: 8px 16px 96px 16px;
}
@media (min-width: 768px) {
.page-main {
padding-bottom: 32px;
}
}
/* 2x2 grid layout for desktop */
.profile-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 768px) {
.profile-grid {
grid-template-columns: 1fr 1fr;
}
}
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
@@ -293,6 +396,7 @@ declare global {
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Mobile quick action rows */
.profile-action-row {
display: flex;
align-items: center;