diff --git a/app/admin/comments/loading.tsx b/app/admin/comments/loading.tsx index f64d7a0..49931b9 100644 --- a/app/admin/comments/loading.tsx +++ b/app/admin/comments/loading.tsx @@ -1,5 +1,5 @@ -import { CommentBoardSkeleton } from "@/components/comments/comment-skeleton"; +import { AdminCommentInboxSkeleton } from "@/components/comments/comment-skeleton"; export default function Loading() { - return
; + return
; } diff --git a/components/comments/admin-comment-inbox.tsx b/components/comments/admin-comment-inbox.tsx index 3e10358..11f675b 100644 --- a/components/comments/admin-comment-inbox.tsx +++ b/components/comments/admin-comment-inbox.tsx @@ -10,12 +10,13 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"; -import type { CommentItem, CommentPage } from "@/lib/comments/types"; +import { Separator } from "@/components/ui/separator"; +import type { CommentPage } from "@/lib/comments/types"; import { characterRarityGradientClass } from "@/lib/catalog/rarity"; import { cn } from "@/lib/utils"; import { useMarkCommentsRead } from "./admin-unread-comments"; import { CommentCard, CommentThread } from "./comment-card"; -import { CommentSkeleton } from "./comment-skeleton"; +import { AdminCommentRowsSkeleton } from "./comment-skeleton"; import { useCommentEvents } from "./use-comment-events"; import { useCommentFeed } from "./use-comment-feed"; @@ -30,18 +31,12 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true })); const markRead = useMarkCommentsRead(); useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]); - const groups = new Map(); - for (const item of feed.page.items) { - const comments = groups.get(item.target) ?? []; - comments.push(item); - groups.set(item.target, comments); - } - return
+ return
{initialComment && initialTarget &&

การสนทนาจากการแจ้งเตือน

feed.refresh({ silent: true })} />
} - + ไกด์หรือรอบ Stygian item.label} itemToStringValue={(item) => item.value} searchNames={(item) => [item.label]} autoHighlight> @@ -49,27 +44,35 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm ไม่พบไกด์ที่ตรงกัน{(item: TargetOption) => {item.label.slice(0, 2)}{item.label}} - การแสดงผล { if (values[0]) setStatus(values[0]); }} variant="outline" aria-label="กรองการแสดงผลความคิดเห็น"> + การแสดงผล { if (values[0]) setStatus(values[0]); }} variant="outline" aria-label="กรองการแสดงผลความคิดเห็น"> ทั้งหมดแสดงอยู่ซ่อนอยู่ - การตอบกลับจากผู้ดูแล { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label="กรองการตอบกลับจากผู้ดูแล"> + การตอบกลับจากผู้ดูแล { if (values[0]) setAnswered(values[0]); }} variant="outline" aria-label="กรองการตอบกลับจากผู้ดูแล"> ทุกความคิดเห็นยังไม่ได้ตอบ + - {feed.error && {feed.error}} - {Array.from(groups, ([targetKey, items]) =>
- - {items[0].targetImage && <> - {/* eslint-disable-next-line @next/next/no-img-element */} - {`ภาพ - } -

{items[0].targetName}

- - {items.map((item) => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)} -
)} + {feed.page.items.length > 0 &&
+
+ ความคิดเห็นเนื้อหา +
+ + {feed.page.items.map((item) =>
+
+ feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} + overview={item.target.startsWith("stygian:") ? + {item.targetName} + : item.targetImage && + {/* eslint-disable-next-line @next/next/no-img-element */} + {`ภาพ + } /> +
+ +
)} +
} {!feed.page.items.length && !feed.busy && !feed.error && ไม่พบความคิดเห็นที่ตรงกันความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่} - {feed.busy && } + {feed.busy && } {feed.page.nextCursor && }
; } diff --git a/components/comments/comment-card.tsx b/components/comments/comment-card.tsx index 7fb66ed..373fd85 100644 --- a/components/comments/comment-card.tsx +++ b/components/comments/comment-card.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EllipsisVerticalIcon, HeartIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; import { toast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -116,8 +116,8 @@ function RevisionHistory({ id }: { id: string }) {
; } -export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: { - item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; +export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, moderationDetails = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false, overview }: { + item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean; overview?: ReactNode; }) { const router = useRouter(); const fullWidthMobile = inbox || moderationDetails; @@ -204,9 +204,16 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals setMode(next); } const canReadHistory = (!item.deleted && !item.hidden) || Boolean(viewer?.admin); - return
+ const optionsMenu = (item.canEdit || viewer?.admin) && + }> + + {item.canEdit && <> compose("edit")}>แก้ไข setDeleting(true)}>ลบ} + {viewer?.admin && void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}} + + ; + return
-
+
{item.authorName.slice(0, 2)}
{item.authorName}{item.authorAdmin && ผู้ดูแล} @@ -216,13 +223,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals {(inbox || moderationDetails) && item.deleted && ลบแล้ว} {item.version > 1 && canReadHistory && }
- {(item.canEdit || viewer?.admin) && - }> - - {item.canEdit && <> compose("edit")}>แก้ไข setDeleting(true)}>ลบ} - {viewer?.admin && void mutate("moderation", !item.ownHidden)}>{item.ownHidden ? "แสดงอีกครั้ง" : "ซ่อน"}} - - } + {!inbox && optionsMenu}
{item.replyToName &&

ตอบกลับ {item.replyToName}

} @@ -254,6 +255,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
+ {inbox &&
{optionsMenu}{overview}
} ประวัติการแก้ไขดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น {history && } ลบความคิดเห็นนี้?ความคิดเห็นนี้ คำตอบกลับทุกระดับ ประวัติการแก้ไข และรูปภาพจะถูกลบอย่างถาวร ไม่สามารถกู้คืนได้ diff --git a/components/comments/comment-skeleton.tsx b/components/comments/comment-skeleton.tsx index ae43e7e..79a8fa7 100644 --- a/components/comments/comment-skeleton.tsx +++ b/components/comments/comment-skeleton.tsx @@ -1,4 +1,36 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; + +export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) { + return
+ กำลังโหลดความคิดเห็น… + +
; +} + +export function AdminCommentInboxSkeleton() { + return
+
+
{Array.from({ length: 3 }, (_, index) => +
)} + +
+ +
; +} export function CommentSkeleton({ count = 3 }: { count?: number }) { return
diff --git a/lib/comments/repository.ts b/lib/comments/repository.ts index ddaf8c8..b388df7 100644 --- a/lib/comments/repository.ts +++ b/lib/comments/repository.ts @@ -47,7 +47,7 @@ export async function getCommentTarget(target: string, viewer: CommentViewer | n const [schedule] = await db.select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }) .from(stygianSchedules).where(eq(stygianSchedules.scheduleId, parsed.id)).limit(1); if (!schedule) throw new HttpError(404, "schedule-not-found"); - return { target: `stygian:${schedule.id}`, name: `Stygian ${schedule.name}`, href: `/stygian/comment?schedule=${schedule.id}`, guideId: null, scheduleId: schedule.id, writable: true }; + return { target: `stygian:${schedule.id}`, name: `Stygian (${schedule.name})`, href: `/stygian/comment?schedule=${schedule.id}`, guideId: null, scheduleId: schedule.id, writable: true }; } export async function findCommentThread(target: string, viewer: CommentViewer | null) { @@ -173,7 +173,7 @@ export async function listComments(options: { canModerate: Boolean(viewer?.admin) && !row.guideTrashedAt, canInteract: !c.deletedAt && !hidden && !row.guideTrashedAt && (row.publicTarget || Boolean(viewer?.admin)), target: row.guideId ? `guide:${row.guideId}` : `stygian:${row.scheduleId}`, - targetName: row.guideName ?? `Stygian ${row.scheduleName}`, + targetName: row.guideName ?? `Stygian (${row.scheduleName})`, href: `${row.guideSlug ? `/${row.guideSlug}/comment` : `/stygian/comment?schedule=${row.scheduleId}`}#comment-${c.rootId ?? c.id}`, }; }); @@ -293,5 +293,5 @@ export async function listCommentTargets() { getDb().select({ id: guides.id, name: guides.name, characterKey: guides.characterKey, imageKey: catalogCharacters.imageKey, rarity: catalogCharacters.rarity }).from(guides).leftJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey)).orderBy(asc(guides.name)), getDb().select({ id: stygianSchedules.scheduleId, name: stygianSchedules.challengeName }).from(stygianSchedules).orderBy(desc(stygianSchedules.scheduleId)), ]); - return [...characters.map((g) => ({ value: `guide:${g.id}`, label: g.name, image: g.imageKey ? publicMediaUrl(g.imageKey) : null, rarity: g.rarity, characterKey: g.characterKey })), ...schedules.map((s) => ({ value: `stygian:${s.id}`, label: `Stygian ${s.name}`, image: null, rarity: null, characterKey: null }))]; + return [...characters.map((g) => ({ value: `guide:${g.id}`, label: g.name, image: g.imageKey ? publicMediaUrl(g.imageKey) : null, rarity: g.rarity, characterKey: g.characterKey })), ...schedules.map((s) => ({ value: `stygian:${s.id}`, label: `Stygian (${s.name})`, image: null, rarity: null, characterKey: null }))]; }