admin/app/(dashboard)/chat-oversight/[id]/_components/ChatImagePreviewModal.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

140 lines
4.3 KiB
TypeScript

'use client'
/**
* ChatImagePreviewModal — full-screen-ish preview + download for chat images.
*
* Purpose
* - Opens from `MessageBubble`; uses ConsumerModal. Download URL resolution
* stays local (guess filename / trusted upload URL).
*/
import Image from 'next/image'
import { useState } from 'react'
import ConsumerButton from '@/components/consumer/ConsumerButton'
import CloseIcon from '@/components/icons/CloseIcon'
import CloudDownloadIcon from '@/components/icons/CloudDownloadIcon'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import { withBasePath } from '@/constants/images'
import { texts } from '@/texts'
import { addToast } from '@/lib/toast'
interface ChatImagePreviewModalProps {
imageUrl: string | null
isOpen: boolean
onOpenChange: (open: boolean) => void
}
const guessFileName = (imageUrl: string) => {
try {
const pathname = new URL(imageUrl, window.location.origin).pathname
const base = pathname.split('/').filter(Boolean).at(-1)
if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base)
} catch {
// ignore invalid URL
}
return `chat-image-${Date.now()}.jpg`
}
const resolveDownloadUrl = (imageUrl: string) => {
try {
const target = new URL(imageUrl, window.location.origin)
if (target.origin === window.location.origin) return target.toString()
} catch {
return imageUrl
}
// Cross-origin file hosts (e.g. ghabilee.ir from localhost) often omit CORS
// for /uploads — proxy through Next so the browser sees a same-origin blob.
return `${withBasePath('/api/download-upload')}?url=${encodeURIComponent(imageUrl)}`
}
const ChatImagePreviewModal = ({ imageUrl, isOpen, onOpenChange }: ChatImagePreviewModalProps) => {
const [isDownloading, setIsDownloading] = useState(false)
const handleDownload = async () => {
if (!imageUrl || isDownloading) return
setIsDownloading(true)
try {
const response = await fetch(resolveDownloadUrl(imageUrl))
if (!response.ok) throw new Error('download failed')
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = guessFileName(imageUrl)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
} catch {
window.open(imageUrl, '_blank', 'noopener,noreferrer')
addToast({ title: texts.chats.downloadFallbackToast, color: 'warning' })
} finally {
setIsDownloading(false)
}
}
return (
<ConsumerModal
hideCloseButton
hideFooter
hideHeader
backdrop="blur"
bodyClassName="!p-0"
className="!max-h-[95dvh] border-0 bg-transparent p-0 shadow-none"
containerClassName="bg-transparent"
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<div className="relative flex min-h-[40vh] flex-col rounded-t-2xl bg-white">
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between gap-2 p-3">
<ConsumerButton
iconOnly
aria-label={texts.common.close}
className="min-h-11 min-w-11 bg-consumer-surface-muted text-consumer-text hover:bg-consumer-surface-muted/80"
fill="none"
size="sm"
onClick={() => {
onOpenChange(false)
}}
>
<CloseIcon className="size-5" />
</ConsumerButton>
<ConsumerButton
aria-label={texts.chats.downloadImageAria}
className="min-h-11 min-w-11 bg-consumer-surface-muted text-consumer-text hover:bg-consumer-surface-muted/80"
fill="none"
iconStart={!isDownloading ? <CloudDownloadIcon className="size-4" /> : undefined}
isLoading={isDownloading}
onClick={() => void handleDownload()}
>
{texts.chats.download}
</ConsumerButton>
</div>
<div className="relative min-h-[40vh] w-full flex-1">
{imageUrl ? (
<Image
fill
alt={texts.chats.chatAttachmentAlt}
className="object-contain p-3 pt-14"
sizes="100vw"
src={imageUrl}
/>
) : null}
</div>
</div>
</ConsumerModal>
)
}
export default ChatImagePreviewModal