Cleaner up registration link creation. Don't show the dialog until when there is a valid link. Implement a global blur backdrop with nicer effect and proper scrollbar handling (avoiding layout shifting a bit). Use the global backdrop to ensure consistent visuals between authentication and the modal being shown, along with in/out transitions.

This commit is contained in:
Leo Vasanko
2025-12-09 23:58:04 +00:00
parent ff8acaa5f5
commit 4a5d61cab1
3 changed files with 102 additions and 54 deletions
+44 -10
View File
@@ -337,10 +337,13 @@ th {
.global-status {
position: fixed;
top: 1.5rem;
left: 50%;
transform: translateX(-50%);
left: 0;
right: 0;
margin: 0 auto;
z-index: 1200;
min-width: min(520px, calc(100vw - 2rem));
width: fit-content;
min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem);
display: none;
}
@@ -377,10 +380,11 @@ th {
.dialog-overlay {
position: fixed;
inset: 0;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
z-index: 1100;
display: flex;
align-items: center;
@@ -683,9 +687,41 @@ th {
}
}
/* Auth iframe overlay styles */
/* Global backdrop controlled by api.js ref-counting */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.has-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
/* Prevent scrolling via html to avoid scrollbar layout shift */
html:has(body.has-backdrop),
html:has(#auth-iframe) {
overflow: clip;
}
body.has-backdrop {
overflow: auto;
}
/* Make fixed elements (toast, dialog) position relative to body, not viewport */
/* This works because transform creates a new containing block for fixed children */
body.has-backdrop,
body:has(#auth-iframe) {
overflow: hidden;
transform: translateZ(0);
}
#auth-iframe {
@@ -698,8 +734,6 @@ body:has(#auth-iframe) {
z-index: 9999;
color-scheme: auto;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
.slot-machine {
@@ -1,5 +1,5 @@
<template>
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div v-if="linkUrl" class="dialog-overlay no-backdrop" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
@@ -9,34 +9,19 @@
</div>
<div class="device-link-section">
<!-- Loading state -->
<div v-if="loading" class="loading-state">
<div class="spinner-small"></div>
<span>Generating registration link...</span>
</div>
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
</p>
<!-- Error state -->
<div v-else-if="error" class="error-state">
<p class="error-message">{{ error }}</p>
<button class="btn-secondary" @click="generateLink">Retry</button>
</div>
<QRCodeDisplay
:url="linkUrl"
:show-link="true"
@copied="onCopied"
/>
<!-- Success state with QR code and link -->
<template v-else-if="linkUrl">
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
</p>
<QRCodeDisplay
:url="linkUrl"
:show-link="true"
@copied="onCopied"
/>
<p class="expiry-note" v-if="expiresAt">
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
</p>
</template>
<p class="expiry-note" v-if="expiresAt">
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
</p>
</div>
<div class="reg-actions">
@@ -47,9 +32,9 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/utils/api'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from '@/utils/api'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
@@ -59,29 +44,20 @@ const props = defineProps({
const emit = defineEmits(['close', 'copied'])
const loading = ref(true)
const error = ref(null)
const linkUrl = ref(null)
const expiresAt = ref(null)
async function generateLink() {
loading.value = true
error.value = null
linkUrl.value = null
expiresAt.value = null
try {
const data = await apiJson(props.endpoint, { method: 'POST' })
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
} else {
error.value = data.detail || 'Failed to generate link'
emit('close')
}
} catch (err) {
error.value = err.message || 'Failed to generate link'
} finally {
loading.value = false
} catch {
emit('close')
}
}
@@ -90,8 +66,15 @@ function onCopied() {
}
onMounted(() => {
// Hold backdrop before fetch to avoid gap if auth iframe shows
holdGlobalBackdrop()
generateLink()
})
onUnmounted(() => {
// Release backdrop when modal closes
releaseGlobalBackdrop()
})
</script>
<style scoped>
@@ -102,8 +85,11 @@ onMounted(() => {
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
.reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
.loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
.error-state { text-align: center; padding: 1rem 0; }
.error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
/* Use global backdrop, not local */
.dialog-overlay.no-backdrop {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
</style>
+28
View File
@@ -49,6 +49,29 @@ let authPromise = null
let authResolve = null
let authReject = null
// Global backdrop ref-count (works independently of Pinia store)
let backdropHolders = 0
/**
* Hold global backdrop (increment ref-count).
* Multiple callers can hold the backdrop; it only hides when all release.
*/
export function holdGlobalBackdrop() {
backdropHolders++
document.body.classList.add('has-backdrop')
}
/**
* Release global backdrop (decrement ref-count).
* Backdrop hides only when ref-count reaches zero.
*/
export function releaseGlobalBackdrop() {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('has-backdrop')
}
}
// Cache for auth iframe URL by mode
const authIframeUrlCache = {}
@@ -93,6 +116,7 @@ export function isAuthIframeOpen() {
/**
* Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion.
* Uses global backdrop system to avoid flicker between auth and caller's UI.
* @param {string} iframeUrl - The URL for the iframe src
* @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user
@@ -119,6 +143,9 @@ export function showAuthIframe(iframeUrl) {
// Remove existing iframe if any
hideAuthIframe()
// Hold global backdrop for auth iframe
holdGlobalBackdrop()
// Create new iframe for authentication using src URL
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
@@ -134,6 +161,7 @@ function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}