feat: lightbox supports navigation, video, and click-outside-to-close
- Refactor ImagePreviewDialog to expose open(url, type) / openCollection(items, idx) via defineExpose; drop external props/state. GalleryBrowser and PostEditorComposer now call lightbox.open()/openCollection() instead of managing previewItem/previewIndex locally. - Lightbox closes when clicking the empty space inside the wrapper (between image and arrows) using @click.self. Image click also closes; video click triggers play/pause without bubbling to close. Arrow buttons keep @click.stop so navigation never closes. - Assets gallery (uploads / Unsplash / Giphy) now opens the lightbox in collection mode so the user can navigate the full tab list with arrows or keyboard. - Add 'delete' typed-confirmation on the post-delete dialog (Index + Edit pages) and on the asset-delete dialog using the new common.confirm_modal.delete_keyword translation (en: 'delete', pt-BR: 'deletar', es: 'eliminar').
This commit is contained in:
parent
a1d5589f2d
commit
d15450d2f0
11 changed files with 89 additions and 73 deletions
|
|
@ -8,6 +8,7 @@
|
|||
'type' => 'Type',
|
||||
'to_confirm' => 'to confirm.',
|
||||
'copy_to_clipboard' => 'Copy to clipboard',
|
||||
'delete_keyword' => 'delete',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
'type' => 'Escribe',
|
||||
'to_confirm' => 'para confirmar.',
|
||||
'copy_to_clipboard' => 'Copiar al portapapeles',
|
||||
'delete_keyword' => 'eliminar',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,6 +8,7 @@
|
|||
'type' => 'Digite',
|
||||
'to_confirm' => 'para confirmar.',
|
||||
'copy_to_clipboard' => 'Copiar para a área de transferência',
|
||||
'delete_keyword' => 'deletar',
|
||||
],
|
||||
|
||||
'photo_upload' => [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-vue';
|
||||
import { computed, onUnmounted, watch } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
|
|
@ -9,58 +9,43 @@ interface MediaItem {
|
|||
type: 'image' | 'video';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Single-image mode (backward compatible). */
|
||||
src?: string | null;
|
||||
/** Multi-image mode (legacy) — list of image URLs. */
|
||||
images?: string[];
|
||||
/** Multi-media mode — list of items with type. Use this for mixed image/video. */
|
||||
items?: MediaItem[];
|
||||
}
|
||||
const items = ref<MediaItem[]>([]);
|
||||
const index = ref<number | null>(null);
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
src: null,
|
||||
images: () => [],
|
||||
items: () => [],
|
||||
});
|
||||
|
||||
const index = defineModel<number | null>('index', { default: null });
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
// Resolve to a unified MediaItem[] regardless of which prop variant was used.
|
||||
const allItems = computed<MediaItem[]>(() => {
|
||||
if (props.items.length > 0) return props.items;
|
||||
if (props.images.length > 0) {
|
||||
return props.images.map((url) => ({ url, type: 'image' as const }));
|
||||
}
|
||||
if (props.src) return [{ url: props.src, type: 'image' as const }];
|
||||
return [];
|
||||
});
|
||||
|
||||
// In multi-item mode the dialog is open when index is a number. Single-src mode
|
||||
// (legacy) is open whenever src is set.
|
||||
const isOpen = computed({
|
||||
get: () => allItems.value.length > 0
|
||||
&& (props.images.length === 0 && props.items.length === 0 ? true : index.value !== null),
|
||||
get: () => index.value !== null && items.value.length > 0,
|
||||
set: (val) => {
|
||||
if (!val) emit('close');
|
||||
if (!val) close();
|
||||
},
|
||||
});
|
||||
|
||||
const safeIndex = computed(() =>
|
||||
Math.max(0, Math.min(index.value ?? 0, allItems.value.length - 1)),
|
||||
Math.max(0, Math.min(index.value ?? 0, items.value.length - 1)),
|
||||
);
|
||||
const currentItem = computed(() => allItems.value[safeIndex.value] ?? null);
|
||||
const currentItem = computed(() => items.value[safeIndex.value] ?? null);
|
||||
const hasPrev = computed(() => safeIndex.value > 0);
|
||||
const hasNext = computed(() => safeIndex.value < allItems.value.length - 1);
|
||||
const showNav = computed(() => allItems.value.length > 1);
|
||||
const hasNext = computed(() => safeIndex.value < items.value.length - 1);
|
||||
const showNav = computed(() => items.value.length > 1);
|
||||
|
||||
const open = (url: string, type: 'image' | 'video' = 'image') => {
|
||||
items.value = [{ url, type }];
|
||||
index.value = 0;
|
||||
};
|
||||
|
||||
const openCollection = (collection: MediaItem[], startIndex = 0) => {
|
||||
if (collection.length === 0) return;
|
||||
items.value = [...collection];
|
||||
index.value = Math.max(0, Math.min(startIndex, collection.length - 1));
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
index.value = null;
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (hasPrev.value) index.value = safeIndex.value - 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (hasNext.value) index.value = safeIndex.value + 1;
|
||||
};
|
||||
|
|
@ -89,6 +74,8 @@ watch(
|
|||
);
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
||||
|
||||
defineExpose({ open, openCollection, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -98,13 +85,13 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
:show-close-button="false"
|
||||
>
|
||||
<DialogTitle class="sr-only">Media preview</DialogTitle>
|
||||
<div class="relative flex justify-center">
|
||||
<div class="relative flex justify-center" @click.self="close">
|
||||
<img
|
||||
v-if="currentItem && currentItem.type === 'image'"
|
||||
:src="currentItem.url"
|
||||
alt="Preview"
|
||||
class="max-h-[85vh] max-w-full cursor-pointer rounded-2xl object-contain"
|
||||
@click="emit('close')"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<video
|
||||
|
|
@ -116,6 +103,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
autoplay
|
||||
preload="metadata"
|
||||
playsinline
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<button
|
||||
|
|
@ -142,7 +130,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
|
|||
v-if="showNav"
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs text-white tabular-nums"
|
||||
>
|
||||
{{ safeIndex + 1 }} / {{ allItems.length }}
|
||||
{{ safeIndex + 1 }} / {{ items.length }}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -83,16 +83,37 @@ const selected = defineModel<PickedMedia[]>('selected', { default: () => [] });
|
|||
|
||||
const isPicker = computed(() => props.mode === 'picker');
|
||||
|
||||
const previewImage = ref<string | null>(null);
|
||||
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
|
||||
|
||||
const handleAssetClick = (asset: { id: string; url: string; type?: string }) => {
|
||||
const handleAssetClick = (asset: AssetMedia) => {
|
||||
if (isPicker.value) {
|
||||
toggleSelect(asset as AssetMedia);
|
||||
toggleSelect(asset);
|
||||
return;
|
||||
}
|
||||
if (asset.type !== 'video') {
|
||||
previewImage.value = asset.url;
|
||||
}
|
||||
const items = uploads.value.map((a) => ({
|
||||
url: a.url,
|
||||
type: a.type === 'video' ? ('video' as const) : ('image' as const),
|
||||
}));
|
||||
const idx = uploads.value.findIndex((a) => a.id === asset.id);
|
||||
lightbox.value?.openCollection(items, idx);
|
||||
};
|
||||
|
||||
const previewUnsplashPhoto = (photo: UnsplashPhoto) => {
|
||||
const items = displayedPhotos.value.map((p) => ({
|
||||
url: p.url_regular,
|
||||
type: 'image' as const,
|
||||
}));
|
||||
const idx = displayedPhotos.value.findIndex((p) => p.id === photo.id);
|
||||
lightbox.value?.openCollection(items, idx);
|
||||
};
|
||||
|
||||
const previewGiphyGif = (gif: GiphyGif) => {
|
||||
const items = displayedGifs.value.map((g) => ({
|
||||
url: g.url_original,
|
||||
type: 'image' as const,
|
||||
}));
|
||||
const idx = displayedGifs.value.findIndex((g) => g.id === gif.id);
|
||||
lightbox.value?.openCollection(items, idx);
|
||||
};
|
||||
|
||||
const selectedIds = computed(() => new Set(selected.value.map((m) => m.id)));
|
||||
|
|
@ -227,7 +248,10 @@ const uploadFiles = async (files: File[]) => {
|
|||
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const handleDelete = (assetId: string) => {
|
||||
deleteModal.value?.open({ url: assetsDestroy.url(assetId) });
|
||||
deleteModal.value?.open({
|
||||
url: assetsDestroy.url(assetId),
|
||||
confirmText: trans('common.confirm_modal.delete_keyword'),
|
||||
});
|
||||
};
|
||||
|
||||
const createPostFromAsset = (asset: AssetMedia) => {
|
||||
|
|
@ -719,7 +743,7 @@ onUnmounted(() => {
|
|||
v-for="photo in displayedPhotos"
|
||||
:key="photo.id"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
@click="previewImage = photo.url_regular"
|
||||
@click="previewUnsplashPhoto(photo)"
|
||||
>
|
||||
<div class="aspect-[4/3]">
|
||||
<img
|
||||
|
|
@ -821,7 +845,7 @@ onUnmounted(() => {
|
|||
v-for="gif in displayedGifs"
|
||||
:key="gif.id"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
@click="previewImage = gif.url_original"
|
||||
@click="previewGiphyGif(gif)"
|
||||
>
|
||||
<div class="aspect-[4/3]">
|
||||
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
|
||||
|
|
@ -900,6 +924,6 @@ onUnmounted(() => {
|
|||
:cancel="trans('assets.delete.cancel')"
|
||||
/>
|
||||
|
||||
<ImagePreviewDialog :src="previewImage" @close="previewImage = null" />
|
||||
<ImagePreviewDialog ref="lightbox" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -71,18 +71,18 @@ const signaturesModal = ref<InstanceType<typeof SignaturesModal> | null>(null);
|
|||
const dragMediaIndex = ref<number | null>(null);
|
||||
const dragOverIndex = ref<number | null>(null);
|
||||
const mediaThumbRefs = ref<HTMLElement[]>([]);
|
||||
const previewIndex = ref<number | null>(null);
|
||||
|
||||
const previewItems = computed<{ url: string; type: 'image' | 'video' }[]>(() =>
|
||||
media.value.map((m) => ({
|
||||
url: m.url,
|
||||
type: isVideo(m) ? 'video' : 'image',
|
||||
})),
|
||||
);
|
||||
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);
|
||||
|
||||
const openPreview = (item: MediaItem) => {
|
||||
const idx = media.value.findIndex((m) => m.id === item.id);
|
||||
previewIndex.value = idx >= 0 ? idx : 0;
|
||||
if (idx < 0) return;
|
||||
lightbox.value?.openCollection(
|
||||
media.value.map((m) => ({
|
||||
url: m.url,
|
||||
type: isVideo(m) ? 'video' as const : 'image' as const,
|
||||
})),
|
||||
idx,
|
||||
);
|
||||
};
|
||||
|
||||
const isVideo = (item: MediaItem): boolean =>
|
||||
|
|
@ -434,11 +434,6 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
|
||||
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
|
||||
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
|
||||
<ImagePreviewDialog
|
||||
:items="previewItems"
|
||||
:index="previewIndex"
|
||||
@update:index="previewIndex = $event"
|
||||
@close="previewIndex = null"
|
||||
/>
|
||||
<ImagePreviewDialog ref="lightbox" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -407,7 +407,10 @@ const toggleLabel = (labelId: string) => {
|
|||
|
||||
const deletePost = () => {
|
||||
if (isReadOnly.value) return;
|
||||
deleteModal.value?.open({ url: destroyPost.url(post.value.id) });
|
||||
deleteModal.value?.open({
|
||||
url: destroyPost.url(post.value.id),
|
||||
confirmText: trans('common.confirm_modal.delete_keyword'),
|
||||
});
|
||||
};
|
||||
|
||||
const unschedulePost = () => {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,10 @@ const postUrl = (post: Post): string =>
|
|||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const handleDelete = (post: Post) => {
|
||||
deleteModal.value?.open({ url: destroyPost.url(post.id) });
|
||||
deleteModal.value?.open({
|
||||
url: destroyPost.url(post.id),
|
||||
confirmText: trans('common.confirm_modal.delete_keyword'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = (post: Post) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue