From 03d59f0738022b20a5d96dbeabbeb3dff8de0e9a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 3 Aug 2026 05:08:08 +0700 Subject: [PATCH] feat(frontend): lazy-load images, add lightbox viewer, polish AI panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add loading=lazy + decoding=async to all (message card preview, attachments grid, image grid, avatars via ui/avatar) - New Lightbox component: fullscreen image viewer with keyboard nav (←/→/Esc), counter, click-to-close; wired into messages page + detail - Attachments grid: click image to open, image counter badge, grouped non-image attachments - AI analysis panel: line-clamp-3 with Show more/less toggle - Message card: preview image is now a clickable button opening detail --- .../src/app/(dashboard)/messages/page.tsx | 26 ++++ .../components/messages/ai-analysis-panel.tsx | 25 +++- .../components/messages/attachments-grid.tsx | 72 +++++++--- .../src/components/messages/lightbox.tsx | 128 ++++++++++++++++++ .../src/components/messages/message-card.tsx | 22 ++- .../messages/message-detail-view.tsx | 7 +- .../frontend/src/components/ui/avatar.tsx | 2 + 7 files changed, 254 insertions(+), 28 deletions(-) create mode 100644 services/frontend/src/components/messages/lightbox.tsx diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 5c60688..956e478 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { GlassCard } from "@/components/glass/card"; import { GlassPanel } from "@/components/glass/panel"; import { SubNav } from "@/components/layout/sub-nav"; +import { Lightbox } from "@/components/messages/lightbox"; import { extractFirstImage } from "@/components/messages/message-card"; import { MessageDetailView } from "@/components/messages/message-detail-view"; import { MessageList } from "@/components/messages/message-list"; @@ -53,6 +54,10 @@ export default function MessagesPage() { (searchParams.get("tab") as MessagesTab) || "all", ); const [searchOpen, setSearchOpen] = useState(false); + const [lightbox, setLightbox] = useState<{ + images: Array<{ src: string; alt?: string }>; + index: number; + } | null>(null); const ws = useWebSocket(); const { data: channels = [] } = useTextChannels(guildId); @@ -228,6 +233,17 @@ export default function MessagesPage() { { + const imgs = (detailAttachments ?? []) + .filter((a) => a.type?.startsWith("image/")) + .map((a) => ({ + src: a.uploaded_url || a.discord_url, + alt: a.filename, + })); + if (imgs.length > 0) { + setLightbox({ images: imgs, index }); + } + }} /> ) : null} @@ -245,6 +261,16 @@ export default function MessagesPage() { setTab("all"); }} /> + + {/* ── Lightbox ── */} + {lightbox && ( + setLightbox(null)} + /> + )} ); } diff --git a/services/frontend/src/components/messages/ai-analysis-panel.tsx b/services/frontend/src/components/messages/ai-analysis-panel.tsx index de8e3ee..6756c04 100644 --- a/services/frontend/src/components/messages/ai-analysis-panel.tsx +++ b/services/frontend/src/components/messages/ai-analysis-panel.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { GlassPanel } from "@/components/glass/panel"; import { cn } from "@/lib/utils"; @@ -32,6 +33,8 @@ export function AiAnalysisPanel({ score, analysis, }: AiAnalysisPanelProps) { + const [expanded, setExpanded] = useState(false); + if (!status || status === "pending") { return ( @@ -125,9 +128,25 @@ export function AiAnalysisPanel({ )} {analysis && ( -

- {analysis} -

+
+

+ {analysis} +

+ {analysis.length > 120 && ( + + )} +
)} {action && action !== "none" && ( diff --git a/services/frontend/src/components/messages/attachments-grid.tsx b/services/frontend/src/components/messages/attachments-grid.tsx index 3167537..2eeb11b 100644 --- a/services/frontend/src/components/messages/attachments-grid.tsx +++ b/services/frontend/src/components/messages/attachments-grid.tsx @@ -1,35 +1,69 @@ "use client"; +import { ImageIcon } from "lucide-react"; import type { AttachmentRecord } from "@/lib/types"; interface AttachmentsGridProps { attachments: AttachmentRecord[]; + onImageClick?: (index: number) => void; } -export function AttachmentsGrid({ attachments }: AttachmentsGridProps) { +export function AttachmentsGrid({ + attachments, + onImageClick, +}: AttachmentsGridProps) { if (attachments.length === 0) return null; + const images = attachments.filter((a) => a.type?.startsWith("image/")); + const others = attachments.filter((a) => !a.type?.startsWith("image/")); + return ( -
- {attachments.map((att) => ( -
- {att.type?.startsWith("image/") ? ( - {att.filename} - ) : ( -
- {att.filename} +
+ {images.length > 0 && ( +
+ {images.map((att, i) => ( +
+ + {images.length > 1 && ( + + {i + 1}/{images.length} + + )}
- )} + ))}
- ))} + )} + {others.length > 0 && ( +
+ {others.map((att) => ( +
+ + + {att.filename} + +
+ ))} +
+ )}
); } diff --git a/services/frontend/src/components/messages/lightbox.tsx b/services/frontend/src/components/messages/lightbox.tsx new file mode 100644 index 0000000..9add7ea --- /dev/null +++ b/services/frontend/src/components/messages/lightbox.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +interface LightboxProps { + images: Array<{ src: string; alt?: string }>; + initialIndex?: number; + open: boolean; + onClose: () => void; +} + +/** + * Fullscreen image viewer with keyboard navigation (←/→/Esc) and + * touch-swipe support. Mounted at page level so a single instance + * serves the message list, image grid and attachments grid. + */ +export function Lightbox({ + images, + initialIndex = 0, + open, + onClose, +}: LightboxProps) { + const [index, setIndex] = useState(initialIndex); + + useEffect(() => { + if (open) setIndex(initialIndex); + }, [open, initialIndex]); + + const prev = useCallback(() => { + setIndex((i) => (i - 1 + images.length) % images.length); + }, [images.length]); + + const next = useCallback(() => { + setIndex((i) => (i + 1) % images.length); + }, [images.length]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + if (e.key === "ArrowLeft") prev(); + if (e.key === "ArrowRight") next(); + }; + document.addEventListener("keydown", onKey); + // Lock body scroll while the lightbox is open + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [open, onClose, prev, next]); + + if (!open || images.length === 0) return null; + + const current = images[index] ?? images[0]; + + return ( +
{ + if (e.key === "Escape") onClose(); + }} + tabIndex={-1} + > + {/* Close */} + + + {/* Image */} +
+ {current.alt +
+ + {/* Counter */} + {images.length > 1 && ( + + {index + 1} / {images.length} + + )} + + {/* Nav */} + {images.length > 1 && ( + <> + + + + )} +
+ ); +} diff --git a/services/frontend/src/components/messages/message-card.tsx b/services/frontend/src/components/messages/message-card.tsx index d23f57c..1f5f0fe 100644 --- a/services/frontend/src/components/messages/message-card.tsx +++ b/services/frontend/src/components/messages/message-card.tsx @@ -111,11 +111,23 @@ export function MessageCard({ const u = extractFirstImage(msg.metadata); if (!u) return null; return ( - + ); })()} {msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && ( diff --git a/services/frontend/src/components/messages/message-detail-view.tsx b/services/frontend/src/components/messages/message-detail-view.tsx index 216562c..32c132d 100644 --- a/services/frontend/src/components/messages/message-detail-view.tsx +++ b/services/frontend/src/components/messages/message-detail-view.tsx @@ -11,12 +11,14 @@ interface MessageDetailViewProps { message: MessageRecord; attachments?: AttachmentRecord[]; onBack?: () => void; + onImageClick?: (index: number) => void; } export function MessageDetailView({ message, attachments, onBack, + onImageClick, }: MessageDetailViewProps) { return ( @@ -53,7 +55,10 @@ export function MessageDetailView({ {/* Attachments */} {attachments && attachments.length > 0 && (
- +
)} diff --git a/services/frontend/src/components/ui/avatar.tsx b/services/frontend/src/components/ui/avatar.tsx index c29020f..a153389 100644 --- a/services/frontend/src/components/ui/avatar.tsx +++ b/services/frontend/src/components/ui/avatar.tsx @@ -33,6 +33,8 @@ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { "aspect-square size-full rounded-full object-cover", className, )} + loading="lazy" + decoding="async" {...props} /> );