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:
@@ -337,10 +337,13 @@ th {
|
|||||||
.global-status {
|
.global-status {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 1.5rem;
|
top: 1.5rem;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translateX(-50%);
|
right: 0;
|
||||||
|
margin: 0 auto;
|
||||||
z-index: 1200;
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,10 +380,11 @@ th {
|
|||||||
|
|
||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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) {
|
body:has(#auth-iframe) {
|
||||||
overflow: hidden;
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#auth-iframe {
|
#auth-iframe {
|
||||||
@@ -698,8 +734,6 @@ body:has(#auth-iframe) {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
color-scheme: auto;
|
color-scheme: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-machine {
|
.slot-machine {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||||
<div class="reg-header-row">
|
<div class="reg-header-row">
|
||||||
<h2 id="regTitle" class="reg-title">
|
<h2 id="regTitle" class="reg-title">
|
||||||
@@ -9,34 +9,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="device-link-section">
|
<div class="device-link-section">
|
||||||
<!-- Loading state -->
|
<p class="reg-help">
|
||||||
<div v-if="loading" class="loading-state">
|
Scan this QR code on the new device, or copy the link and open it there.
|
||||||
<div class="spinner-small"></div>
|
</p>
|
||||||
<span>Generating registration link...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
<QRCodeDisplay
|
||||||
<div v-else-if="error" class="error-state">
|
:url="linkUrl"
|
||||||
<p class="error-message">{{ error }}</p>
|
:show-link="true"
|
||||||
<button class="btn-secondary" @click="generateLink">Retry</button>
|
@copied="onCopied"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<!-- Success state with QR code and link -->
|
<p class="expiry-note" v-if="expiresAt">
|
||||||
<template v-else-if="linkUrl">
|
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
|
||||||
<p class="reg-help">
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reg-actions">
|
<div class="reg-actions">
|
||||||
@@ -47,9 +32,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||||
import { apiJson } from '@/utils/api'
|
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from '@/utils/api'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -59,29 +44,20 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['close', 'copied'])
|
const emit = defineEmits(['close', 'copied'])
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref(null)
|
|
||||||
const linkUrl = ref(null)
|
const linkUrl = ref(null)
|
||||||
const expiresAt = ref(null)
|
const expiresAt = ref(null)
|
||||||
|
|
||||||
async function generateLink() {
|
async function generateLink() {
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
linkUrl.value = null
|
|
||||||
expiresAt.value = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
linkUrl.value = data.url
|
linkUrl.value = data.url
|
||||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||||
} else {
|
} else {
|
||||||
error.value = data.detail || 'Failed to generate link'
|
emit('close')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
error.value = err.message || 'Failed to generate link'
|
emit('close')
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +66,15 @@ function onCopied() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Hold backdrop before fetch to avoid gap if auth iframe shows
|
||||||
|
holdGlobalBackdrop()
|
||||||
generateLink()
|
generateLink()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// Release backdrop when modal closes
|
||||||
|
releaseGlobalBackdrop()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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); }
|
.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-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; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -49,6 +49,29 @@ let authPromise = null
|
|||||||
let authResolve = null
|
let authResolve = null
|
||||||
let authReject = 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
|
// Cache for auth iframe URL by mode
|
||||||
const authIframeUrlCache = {}
|
const authIframeUrlCache = {}
|
||||||
|
|
||||||
@@ -93,6 +116,7 @@ export function isAuthIframeOpen() {
|
|||||||
/**
|
/**
|
||||||
* Show the authentication iframe and return a promise that resolves on success.
|
* 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.
|
* 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
|
* @param {string} iframeUrl - The URL for the iframe src
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||||
@@ -119,6 +143,9 @@ export function showAuthIframe(iframeUrl) {
|
|||||||
// Remove existing iframe if any
|
// Remove existing iframe if any
|
||||||
hideAuthIframe()
|
hideAuthIframe()
|
||||||
|
|
||||||
|
// Hold global backdrop for auth iframe
|
||||||
|
holdGlobalBackdrop()
|
||||||
|
|
||||||
// Create new iframe for authentication using src URL
|
// Create new iframe for authentication using src URL
|
||||||
authIframe = document.createElement('iframe')
|
authIframe = document.createElement('iframe')
|
||||||
authIframe.id = 'auth-iframe'
|
authIframe.id = 'auth-iframe'
|
||||||
@@ -134,6 +161,7 @@ function hideAuthIframe() {
|
|||||||
if (authIframe) {
|
if (authIframe) {
|
||||||
authIframe.remove()
|
authIframe.remove()
|
||||||
authIframe = null
|
authIframe = null
|
||||||
|
releaseGlobalBackdrop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user