fix(accounts,media): address PR review findings
Connect flow:
- surface OAuth failures: useOAuthPopup forwards {success, message}; the
accounts grid toasts the error instead of silently reloading
- detect a blocked popup and toast a "allow popups" message
- PopupCallback only promises auto-close when it has an opener, otherwise
tells the user to close it; adds role=status/aria-live and aria-hidden icons
- drop the dead LinkedInPage connect-route entry and the unsent access_token
field on the Facebook page-select type
Media/gallery:
- delete dead code: MediaDropzone.vue and useDragAndDrop.ts (no references)
- GalleryBrowser toasts per-file upload failures instead of swallowing them;
cards are always clickable now that videos open in the lightbox
- formatDurationMs rounds the sub-second branch and tolerates undefined
- advertise PDF in the gallery's accepted-formats hint
i18n: popup_blocked, manual_close (accounts) and upload.failed (assets) in all locales.
This commit is contained in:
parent
91b353c569
commit
f0e2907ab5
14 changed files with 63 additions and 408 deletions
|
|
@ -125,6 +125,8 @@
|
|||
'title_success' => 'Connected',
|
||||
'title_error' => 'Error',
|
||||
'closing' => 'This window will close automatically...',
|
||||
'manual_close' => 'You can close this window.',
|
||||
'popup_blocked' => 'Could not open the connection window. Please allow popups and try again.',
|
||||
'connected' => 'Account connected!',
|
||||
'reconnected' => 'Account reconnected!',
|
||||
'error_connecting' => 'Error connecting account. Please try again.',
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
|
||||
'upload' => [
|
||||
'drag_drop' => 'Drag & drop your files here, or click to select',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
|
||||
'uploading' => 'Uploading...',
|
||||
'failed' => 'Could not upload :file. Please try again.',
|
||||
],
|
||||
|
||||
'empty' => [
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@
|
|||
'title_success' => 'Conectado',
|
||||
'title_error' => 'Error',
|
||||
'closing' => 'Esta ventana se cerrará automáticamente...',
|
||||
'manual_close' => 'Puedes cerrar esta ventana.',
|
||||
'popup_blocked' => 'No se pudo abrir la ventana de conexión. Permite las ventanas emergentes e inténtalo de nuevo.',
|
||||
'connected' => '¡Cuenta conectada!',
|
||||
'reconnected' => '¡Cuenta reconectada!',
|
||||
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@
|
|||
|
||||
'upload' => [
|
||||
'drag_drop' => 'Arrastra y suelta tus archivos aquí o haz clic para seleccionar',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
|
||||
'uploading' => 'Subiendo...',
|
||||
'failed' => 'No se pudo subir :file. Inténtalo de nuevo.',
|
||||
],
|
||||
|
||||
'empty' => [
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@
|
|||
'title_success' => 'Conectado',
|
||||
'title_error' => 'Erro',
|
||||
'closing' => 'Esta janela será fechada automaticamente...',
|
||||
'manual_close' => 'Você pode fechar esta janela.',
|
||||
'popup_blocked' => 'Não foi possível abrir a janela de conexão. Permita pop-ups e tente novamente.',
|
||||
'connected' => 'Conta conectada!',
|
||||
'reconnected' => 'Conta reconectada!',
|
||||
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@
|
|||
|
||||
'upload' => [
|
||||
'drag_drop' => 'Arraste e solte seus arquivos aqui ou clique para selecionar',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4',
|
||||
'formats' => 'JPEG, PNG, GIF, WebP, MP4, PDF',
|
||||
'uploading' => 'Enviando...',
|
||||
'failed' => 'Não foi possível enviar :file. Tente novamente.',
|
||||
],
|
||||
|
||||
'empty' => [
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCloudUpload, IconPhoto, IconVideo, IconX, IconLoader2, IconPlus } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { fromMimeType, isImage, isVideo, MediaType } from '@/lib/mediaType';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface Props {
|
||||
media: MediaItem[];
|
||||
maxFiles: number;
|
||||
minFiles?: number;
|
||||
acceptImages: boolean;
|
||||
acceptVideos: boolean;
|
||||
isUploading?: boolean;
|
||||
disabled?: boolean;
|
||||
aspectRatio?: string;
|
||||
variant?: 'default' | 'compact' | 'vertical';
|
||||
showGrid?: boolean;
|
||||
emptyStateText?: string;
|
||||
emptyStateSubtext?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minFiles: 0,
|
||||
isUploading: false,
|
||||
disabled: false,
|
||||
aspectRatio: 'aspect-square',
|
||||
variant: 'default',
|
||||
showGrid: true,
|
||||
emptyStateText: 'Drag & drop or click to upload',
|
||||
emptyStateSubtext: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [files: File[]];
|
||||
remove: [mediaId: string];
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const acceptMimeTypes = computed(() => {
|
||||
const types: string[] = [];
|
||||
if (props.acceptImages) {
|
||||
types.push('image/*');
|
||||
}
|
||||
if (props.acceptVideos) {
|
||||
types.push('video/*');
|
||||
}
|
||||
return types.join(',');
|
||||
});
|
||||
|
||||
const acceptDescription = computed(() => {
|
||||
if (props.acceptImages && props.acceptVideos) {
|
||||
return props.emptyStateSubtext || 'Images or videos';
|
||||
}
|
||||
if (props.acceptImages) {
|
||||
return props.emptyStateSubtext || 'Images only';
|
||||
}
|
||||
if (props.acceptVideos) {
|
||||
return props.emptyStateSubtext || 'Videos only';
|
||||
}
|
||||
return 'No media allowed';
|
||||
});
|
||||
|
||||
const canAddMore = computed(() => props.media.length < props.maxFiles);
|
||||
const isMultiple = computed(() => props.maxFiles > 1);
|
||||
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const type = fromMimeType(file.type);
|
||||
|
||||
if (type === MediaType.Image && !props.acceptImages) {
|
||||
return false;
|
||||
}
|
||||
if (type === MediaType.Video && !props.acceptVideos) {
|
||||
return false;
|
||||
}
|
||||
return type === MediaType.Image || type === MediaType.Video;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!props.disabled && canAddMore.value) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
if (props.disabled || !canAddMore.value || !e.dataTransfer?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(input.files);
|
||||
processFiles(files);
|
||||
|
||||
// Reset input
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const processFiles = (files: File[]) => {
|
||||
// Filter valid files
|
||||
const validFiles = files.filter(isValidFileType);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to remaining slots
|
||||
const remainingSlots = props.maxFiles - props.media.length;
|
||||
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||
|
||||
emit('upload', filesToUpload);
|
||||
};
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!props.disabled && canAddMore.value) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (mediaId: string) => {
|
||||
emit('remove', mediaId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- Empty state: dropzone -->
|
||||
<div
|
||||
v-if="media.length === 0"
|
||||
class="flex flex-col items-center justify-center cursor-pointer transition-all duration-200"
|
||||
:class="[
|
||||
aspectRatio,
|
||||
isDragging
|
||||
? 'bg-primary/5 border-primary border-2 border-dashed'
|
||||
: 'bg-muted/50 hover:bg-muted/70 border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40',
|
||||
variant === 'vertical' ? 'rounded-2xl' : 'rounded-lg',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
]"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<div v-if="isUploading" class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<IconLoader2 class="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Uploading...</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-3">
|
||||
<div class="p-4 rounded-full bg-muted">
|
||||
<IconCloudUpload v-if="isDragging" class="h-8 w-8 text-primary" />
|
||||
<IconVideo v-else-if="acceptVideos && !acceptImages" class="h-8 w-8 text-muted-foreground" />
|
||||
<IconPhoto v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ emptyStateText }}</p>
|
||||
<p class="text-xs text-muted-foreground/70 mt-1">{{ acceptDescription }}</p>
|
||||
<p v-if="maxFiles > 1" class="text-xs text-muted-foreground/50 mt-1">
|
||||
Up to {{ maxFiles }} {{ maxFiles === 1 ? 'file' : 'files' }}
|
||||
<span v-if="minFiles && minFiles > 1">(min {{ minFiles }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media grid with thumbnails -->
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="showGrid && media.length > 1"
|
||||
class="grid gap-2"
|
||||
:class="media.length === 2 ? 'grid-cols-2' : 'grid-cols-3'"
|
||||
>
|
||||
<div
|
||||
v-for="item in media"
|
||||
:key="item.id"
|
||||
class="relative group aspect-square rounded-lg overflow-hidden bg-muted"
|
||||
>
|
||||
<img
|
||||
v-if="isImage(item)"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div
|
||||
v-if="isVideo(item)"
|
||||
class="absolute bottom-1 left-1 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded flex items-center gap-0.5"
|
||||
>
|
||||
<IconVideo class="h-3 w-3" />
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
@click.stop="handleRemove(item.id)"
|
||||
class="absolute top-1 right-1 bg-black/60 hover:bg-black/80 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add more button -->
|
||||
<button
|
||||
v-if="canAddMore && !disabled"
|
||||
type="button"
|
||||
@click="openFilePicker"
|
||||
class="aspect-square rounded-lg border-2 border-dashed border-muted-foreground/20 hover:border-muted-foreground/40 flex items-center justify-center transition-colors"
|
||||
:class="isUploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:bg-muted/50'"
|
||||
>
|
||||
<IconLoader2 v-if="isUploading" class="h-6 w-6 text-muted-foreground animate-spin" />
|
||||
<IconPlus v-else class="h-6 w-6 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Single media display -->
|
||||
<div
|
||||
v-else
|
||||
class="relative group overflow-hidden bg-muted"
|
||||
:class="[aspectRatio, variant === 'vertical' ? 'rounded-2xl' : 'rounded-lg']"
|
||||
>
|
||||
<img
|
||||
v-if="isImage(media[0])"
|
||||
:src="media[0].url"
|
||||
:alt="media[0].original_filename"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="media[0].url"
|
||||
class="w-full h-full object-cover bg-black"
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
<!-- Video indicator -->
|
||||
<div
|
||||
v-if="isVideo(media[0])"
|
||||
class="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
>
|
||||
<IconVideo class="h-3.5 w-3.5" />
|
||||
<span>Video</span>
|
||||
</div>
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
@click.stop="handleRemove(media[0].id)"
|
||||
class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
<!-- Carousel indicator (if more than 1 and not showing grid) -->
|
||||
<div v-if="media.length > 1 && !showGrid" class="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
|
||||
<div
|
||||
v-for="(_, index) in media.slice(0, 5)"
|
||||
:key="index"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="index === 0 ? 'bg-white' : 'bg-white/50'"
|
||||
/>
|
||||
<span v-if="media.length > 5" class="text-white text-[10px] ml-1">+{{ media.length - 5 }}</span>
|
||||
</div>
|
||||
<!-- Upload more overlay (if can add more and single display) -->
|
||||
<button
|
||||
v-if="canAddMore && !disabled && !showGrid"
|
||||
type="button"
|
||||
@click="openFilePicker"
|
||||
class="absolute bottom-2 right-2 bg-black/60 hover:bg-black/80 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconPlus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptMimeTypes"
|
||||
:multiple="isMultiple"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
:disabled="disabled || isUploading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@ import { router } from '@inertiajs/vue3';
|
|||
import { IconAlertTriangle, IconCheck, IconPlus } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
|
|
@ -153,8 +154,14 @@ const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
|
|||
null,
|
||||
);
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup(() => {
|
||||
router.reload();
|
||||
const { openOAuthPopup } = useOAuthPopup((result) => {
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.message);
|
||||
});
|
||||
|
||||
const disconnectAccount = (account: ConnectedAccount) => {
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ const uploadFiles = async (files: File[]) => {
|
|||
collection: 'assets',
|
||||
});
|
||||
} catch {
|
||||
// ignore individual failure
|
||||
toast.error(trans('assets.upload.failed', { file: file.name }));
|
||||
}
|
||||
}
|
||||
uploading.value = false;
|
||||
|
|
@ -662,7 +662,7 @@ onUnmounted(() => {
|
|||
:key="asset.id"
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
:class="[
|
||||
(isPicker || !isVideo(asset)) ? 'cursor-pointer' : '',
|
||||
'cursor-pointer',
|
||||
isPicker && isSelected(asset.id) ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
|
||||
]"
|
||||
@click="handleAssetClick(asset)"
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import { ref } from 'vue';
|
||||
|
||||
import { fromMimeType, MediaType } from '@/lib/mediaType';
|
||||
|
||||
export interface DragAndDropOptions {
|
||||
acceptImages: boolean;
|
||||
acceptVideos: boolean;
|
||||
maxFiles: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function useDragAndDrop(options: DragAndDropOptions) {
|
||||
const isDragging = ref(false);
|
||||
|
||||
const isValidFileType = (file: File): boolean => {
|
||||
const type = fromMimeType(file.type);
|
||||
|
||||
if (type === MediaType.Image && !options.acceptImages) {
|
||||
return false;
|
||||
}
|
||||
if (type === MediaType.Video && !options.acceptVideos) {
|
||||
return false;
|
||||
}
|
||||
return type === MediaType.Image || type === MediaType.Video;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!options.disabled) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, currentMediaCount: number, onUpload: (files: File[]) => void) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
if (options.disabled || !e.dataTransfer?.files) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFiles = files.filter(isValidFileType);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit to remaining slots
|
||||
const remainingSlots = options.maxFiles - currentMediaCount;
|
||||
const filesToUpload = validFiles.slice(0, remainingSlots);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
onUpload(filesToUpload);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { connect as blueskyConnect } from '@/routes/app/social/bluesky';
|
||||
import { connect as discordConnect } from '@/routes/app/social/discord';
|
||||
|
|
@ -24,7 +26,6 @@ const CONNECT_ROUTES: Record<string, { url: () => string }> = {
|
|||
[Platform.Instagram]: instagramConnect,
|
||||
[Platform.InstagramFacebook]: instagramFacebookConnect,
|
||||
[Platform.LinkedIn]: linkedinConnect,
|
||||
[Platform.LinkedInPage]: linkedinConnect,
|
||||
[Platform.Mastodon]: mastodonConnect,
|
||||
[Platform.Pinterest]: pinterestConnect,
|
||||
[Platform.Threads]: threadsConnect,
|
||||
|
|
@ -33,12 +34,18 @@ const CONNECT_ROUTES: Record<string, { url: () => string }> = {
|
|||
[Platform.YouTube]: youtubeConnect,
|
||||
};
|
||||
|
||||
export interface SocialOAuthResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
platform: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a platform's OAuth connect flow in a centered popup and invokes
|
||||
* `onSuccess` when the popup posts back the `social-oauth-callback` message.
|
||||
* The listener is wired to the calling component's lifecycle.
|
||||
* `onResult` with the popup's `{success, message}` outcome. The listener is
|
||||
* wired to the calling component's lifecycle.
|
||||
*/
|
||||
export const useOAuthPopup = (onSuccess: () => void) => {
|
||||
export const useOAuthPopup = (onResult: (result: SocialOAuthResult) => void) => {
|
||||
const openOAuthPopup = (platform: string) => {
|
||||
const route = CONNECT_ROUTES[platform];
|
||||
|
||||
|
|
@ -49,18 +56,26 @@ export const useOAuthPopup = (onSuccess: () => void) => {
|
|||
const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
|
||||
const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
|
||||
|
||||
window.open(
|
||||
const popup = window.open(
|
||||
route.url(),
|
||||
'oauth-popup',
|
||||
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
toast.error(trans('accounts.popup_callback.popup_blocked'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== 'social-oauth-callback') return;
|
||||
|
||||
onSuccess();
|
||||
onResult({
|
||||
success: Boolean(event.data.success),
|
||||
message: String(event.data.message ?? ''),
|
||||
platform: event.data.platform ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener('message', handleMessage));
|
||||
|
|
|
|||
|
|
@ -243,12 +243,12 @@ export default {
|
|||
/**
|
||||
* Format a duration in milliseconds (e.g. "—", "500ms", "1.5s", "2m 30s").
|
||||
*/
|
||||
formatDurationMs(ms: number | null): string {
|
||||
if (ms === null) {
|
||||
formatDurationMs(ms: number | null | undefined): string {
|
||||
if (ms == null) {
|
||||
return '—';
|
||||
}
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
const seconds = ms / 1000;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ interface Page {
|
|||
name: string;
|
||||
username: string | null;
|
||||
picture: string | null;
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import PopupLayout from '@/layouts/PopupLayout.vue';
|
||||
|
||||
|
|
@ -12,10 +12,17 @@ const props = defineProps<{
|
|||
|
||||
const CLOSE_DELAY = 1500;
|
||||
|
||||
// A window not opened by script can't be closed via window.close() in modern
|
||||
// browsers, so only promise an auto-close when we actually have an opener.
|
||||
const canAutoClose = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
const opener = window.opener && !window.opener.closed ? window.opener : null;
|
||||
canAutoClose.value = opener !== null;
|
||||
|
||||
if (opener) {
|
||||
try {
|
||||
window.opener.postMessage(
|
||||
opener.postMessage(
|
||||
{
|
||||
type: 'social-oauth-callback',
|
||||
success: props.success,
|
||||
|
|
@ -27,15 +34,15 @@ onMounted(() => {
|
|||
} catch {
|
||||
// Opener may be cross-origin or already gone; the timed close still applies.
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => window.close(), CLOSE_DELAY);
|
||||
window.setTimeout(() => window.close(), CLOSE_DELAY);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopupLayout :title="success ? $t('accounts.popup_callback.title_success') : $t('accounts.popup_callback.title_error')">
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center" dusk="popup-callback">
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center" dusk="popup-callback" role="status" aria-live="polite">
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-full"
|
||||
:class="
|
||||
|
|
@ -44,11 +51,11 @@ onMounted(() => {
|
|||
: 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400'
|
||||
"
|
||||
>
|
||||
<IconCircleCheck v-if="success" class="h-7 w-7" />
|
||||
<IconCircleX v-else class="h-7 w-7" />
|
||||
<IconCircleCheck v-if="success" class="h-7 w-7" aria-hidden="true" />
|
||||
<IconCircleX v-else class="h-7 w-7" aria-hidden="true" />
|
||||
</div>
|
||||
<p class="text-lg font-medium text-foreground">{{ message }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('accounts.popup_callback.closing') }}</p>
|
||||
<p class="text-sm text-muted-foreground">{{ canAutoClose ? $t('accounts.popup_callback.closing') : $t('accounts.popup_callback.manual_close') }}</p>
|
||||
</div>
|
||||
</PopupLayout>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue