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:
Paulo Castellano 2026-05-08 19:16:38 -03:00
parent a1d5589f2d
commit d15450d2f0
11 changed files with 89 additions and 73 deletions

View file

@ -8,6 +8,7 @@
'type' => 'Type',
'to_confirm' => 'to confirm.',
'copy_to_clipboard' => 'Copy to clipboard',
'delete_keyword' => 'delete',
],
'photo_upload' => [

View file

@ -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

View file

@ -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' => [

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 = () => {

View file

@ -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) => {