feat : copy youtube studio
This commit is contained in:
@@ -13,7 +13,6 @@ import {
|
||||
SwordsIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AdminUnreadCount } from "@/components/comments/admin-unread-comments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PublicAccountMenu } from "@/components/public/public-account-menu";
|
||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||
@@ -35,7 +34,7 @@ export function AdminHeader() {
|
||||
<nav aria-label="เมนูแอดมิน" className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto sm:justify-end">
|
||||
<Button variant={pathname.startsWith("/admin/comments") ? "secondary" : "ghost"} size="sm" nativeButton={false}
|
||||
render={<Link href="/admin/comments" prefetch={false} />} aria-label="ความคิดเห็น">
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" /><span className="hidden xl:inline">ความคิดเห็น</span><AdminUnreadCount />
|
||||
<Image src="/icon/nav/Comment.webp" alt="" width={20} height={20} className="size-5 object-contain" /><span className="hidden xl:inline">ความคิดเห็น</span>
|
||||
</Button>
|
||||
<Button variant={pathname.startsWith("/admin/commission") ? "secondary" : "ghost"}
|
||||
size="sm" nativeButton={false} render={<Link href="/admin/commission" />} aria-label="Commission">
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 { CommentPushPrompt } from "./comment-push-prompt";
|
||||
import { CommentCard, CommentThread } from "./comment-card";
|
||||
import { CommentSkeleton } from "./comment-skeleton";
|
||||
import { useCommentEvents } from "./use-comment-events";
|
||||
@@ -26,14 +25,13 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
|
||||
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [answered, setAnswered] = useState("all");
|
||||
const query = new URLSearchParams({ view: "inbox", status, unanswered: String(answered === "unanswered") });
|
||||
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, unanswered: String(answered === "unanswered") });
|
||||
if (target) query.set("target", target.value);
|
||||
const feed = useCommentFeed(`/api/admin/comments?${query}`, initial);
|
||||
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true }));
|
||||
useCommentEvents({ admin: true }, () => feed.refresh({ silent: true, forceRevision: true }));
|
||||
const markRead = useMarkCommentsRead();
|
||||
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
|
||||
return <div className="flex flex-col gap-6">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin />}
|
||||
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
|
||||
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
|
||||
@@ -53,9 +51,9 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
|
||||
<ToggleGroupItem value="all">ทุกความคิดเห็น</ToggleGroupItem><ToggleGroupItem value="unanswered">ยังไม่ได้ตอบ</ToggleGroupItem>
|
||||
</ToggleGroup></Field>
|
||||
</FieldGroup>
|
||||
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh()}><RefreshCwIcon data-icon="inline-start" />รีเฟรช</Button>
|
||||
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />รีเฟรช</Button>
|
||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox replies={false} onChange={() => feed.refresh({ silent: true })} revision={feed.revision} />)}
|
||||
{feed.page.items.map((item) => <CommentCard key={item.id} item={item} viewer={feed.page.viewer} inbox onChange={() => feed.refresh({ silent: true, forceRevision: true })} revision={feed.revision} />)}
|
||||
{!feed.page.items.length && !feed.busy && !feed.error && <Empty><EmptyHeader><EmptyTitle>ไม่พบความคิดเห็นที่ตรงกัน</EmptyTitle><EmptyDescription>ความคิดเห็นจากทุกไกด์และทุกรอบ Stygian จะแสดงที่นี่</EmptyDescription></EmptyHeader></Empty>}
|
||||
{feed.busy && <CommentSkeleton count={feed.page.items.length ? 1 : 3} />}
|
||||
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>ดูความคิดเห็นเพิ่มเติม</Button>}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useMarkCommentsRead() { return useContext(UnreadContext).markRea
|
||||
export function AdminUnreadCount({ target }: { target?: string }) {
|
||||
const state = useContext(UnreadContext);
|
||||
const count = target ? state.counts[target] ?? 0 : state.total;
|
||||
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full px-1.5" aria-label={`ความคิดเห็นใหม่ ${count} รายการ`}>{count > 99 ? "99+" : count}</Badge> : null;
|
||||
return count > 0 ? <Badge variant="destructive" className="min-w-5 justify-center rounded-full bg-destructive px-1.5 text-background dark:bg-destructive" aria-label={`ความคิดเห็นใหม่ ${count} รายการ`}>{count > 99 ? "99+" : count}</Badge> : null;
|
||||
}
|
||||
export function AdminUnreadBadge({ target }: { target: string }) {
|
||||
const state = useContext(UnreadContext);
|
||||
|
||||
@@ -6,6 +6,7 @@ export const COMMENT_ERRORS: Record<string, string> = {
|
||||
"body-too-large": "แนบรูปภาพได้สูงสุด 5 รูป รูปละไม่เกิน 10 MiB",
|
||||
"unsupported-image-type": "รองรับรูปภาพ JPEG, PNG และ WebP ไม่รองรับวิดีโอ",
|
||||
"invalid-image": "ไม่สามารถอ่านรูปภาพได้ กรุณาเลือกไฟล์รูปภาพที่ถูกต้อง",
|
||||
"comment-image-upload-failed": "อัปโหลดรูปภาพไม่สำเร็จ กรุณาลองใหม่ ข้อความร่างและรูปภาพของคุณยังอยู่",
|
||||
"empty-comment": "เขียนความคิดเห็นหรือแนบรูปภาพ",
|
||||
"invalid-comment": "เขียนได้สูงสุด 4,000 ตัวอักษร และแนบรูปภาพได้ 5 รูป",
|
||||
"comment-edited-reload": "ความคิดเห็นนี้ถูกแก้ไขแล้ว กรุณาโหลดใหม่ก่อนบันทึก ข้อความร่างของคุณยังอยู่",
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
|
||||
import type { CommentPage } from "@/lib/comments/types";
|
||||
import { CommentPushPrompt } from "./comment-push-prompt";
|
||||
import { CommentComposer } from "./comment-composer";
|
||||
import { CommentCard, CommentThread } from "./comment-card";
|
||||
import { commentSignInHref } from "./client";
|
||||
@@ -39,7 +38,6 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
|
||||
return () => window.removeEventListener("hashchange", showThread);
|
||||
}, []);
|
||||
return <div className="flex flex-col gap-6 py-2">
|
||||
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin={feed.page.viewer.admin} />}
|
||||
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
|
||||
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ</p><Button onClick={() => router.push(commentSignInHref())}>เข้าสู่ระบบ</Button></div>}</div></div>
|
||||
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">ความคิดเห็น</h2>
|
||||
|
||||
@@ -117,8 +117,8 @@ function RevisionHistory({ id }: { id: string }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CommentCard({ item, viewer, onChange, revision = 0, inbox = false, replies = true, defaultExpanded = false, focusReplyId, highlighted = false }: {
|
||||
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: 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 }: {
|
||||
item: CommentItem; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; revision?: number; inbox?: boolean; moderationDetails?: boolean; replies?: boolean; defaultExpanded?: boolean; focusReplyId?: string; highlighted?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<"reply" | "edit" | null>(null);
|
||||
@@ -212,8 +212,8 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
<div className="flex items-start justify-between gap-2"><div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"><span className="text-sm font-semibold">{item.authorName}</span>{item.authorAdmin && <Badge variant="secondary">ผู้ดูแล</Badge>}
|
||||
<CommentTimestamp value={item.createdAt} />
|
||||
{inbox && item.rootId && <Badge variant="outline">ตอบกลับ</Badge>}
|
||||
{inbox && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
|
||||
{inbox && item.deleted && <Badge variant="secondary">ลบแล้ว</Badge>}
|
||||
{(inbox || moderationDetails) && item.hidden && <Badge variant="secondary">{item.ownHidden ? "ซ่อนอยู่" : "การสนทนาถูกซ่อน"}</Badge>}
|
||||
{(inbox || moderationDetails) && item.deleted && <Badge variant="secondary">ลบแล้ว</Badge>}
|
||||
{item.version > 1 && canReadHistory && <Button variant="link" size="xs" onClick={() => setHistory(true)}>แก้ไขแล้ว</Button>}
|
||||
</div>
|
||||
{(item.canEdit || viewer?.admin) && <DropdownMenu>
|
||||
@@ -236,14 +236,14 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
{viewer?.admin ? <Button variant={optimistic.hearted ? "secondary" : "ghost"} size="sm" aria-pressed={optimistic.hearted} disabled={pending || !item.canInteract} onClick={() => void mutate("heart", !desired.current.hearted)}>
|
||||
<HeartIcon data-icon="inline-start" className={cn(optimistic.hearted && "text-destructive")} fill={optimistic.hearted ? "currentColor" : "none"} />{optimistic.hearted ? "ผู้ดูแลถูกใจ" : null}</Button>
|
||||
: optimistic.hearted && !item.deleted && !item.hidden && <Badge variant="secondary"><HeartIcon data-icon="inline-start" className="text-destructive" fill="currentColor" />ผู้ดูแลถูกใจ</Badge>}
|
||||
{inbox && item.hasReplies && <Button variant="outline" size="sm" aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา</Button>}
|
||||
{inbox && !replies && item.hasReplies && <Button variant="outline" size="sm" aria-haspopup="dialog" onClick={() => setThread(true)}>ดูการสนทนา</Button>}
|
||||
</div>
|
||||
{mode && <CommentComposer key={mode} target={item.target} replyTo={mode === "reply" ? item : undefined} editing={mode === "edit" ? item : undefined}
|
||||
onCancel={() => setMode(null)} onSaved={() => { setMode(null); expandReplies(true); onChange(); }} />}
|
||||
{replies && !item.rootId && (item.replyCount > 0 || expanded) && <Accordion value={expanded ? ["replies"] : []} onValueChange={(value) => expandReplies(value.includes("replies"))}>
|
||||
<AccordionItem value="replies"><AccordionTrigger ref={replyToggleRef} className="flex-none gap-2 rounded-full px-3" headerClassName={expanded ? "hidden" : "justify-start"}>{`การตอบกลับ ${item.replyCount.toLocaleString("th-TH")} รายการ`}</AccordionTrigger>
|
||||
<AccordionContent keepMounted panelClassName="data-open:overflow-visible" className="[&_p]:mb-0">{hasOpenedReplies && <div className="flex flex-col gap-3">
|
||||
<CommentReplies rootId={item.id} focusReplyId={focusReplyId} viewer={viewer} onChange={onChange} revision={revision} />
|
||||
<CommentReplies rootId={item.id} focusReplyId={focusReplyId} viewer={viewer} onChange={onChange} revision={revision} moderationDetails={inbox || moderationDetails} />
|
||||
<div className="relative self-start before:absolute before:-left-8 before:top-0 before:h-5 before:w-8 before:rounded-bl-2xl before:border-b before:border-l before:border-border">
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => { setExpanded(false); requestAnimationFrame(() => replyToggleRef.current?.focus()); }}><ChevronUpIcon data-icon="inline-start" />Show less</Button>
|
||||
</div>
|
||||
@@ -271,7 +271,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
|
||||
</article>;
|
||||
}
|
||||
|
||||
function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown> }) {
|
||||
function CommentReplies({ rootId, revision, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; rootId: string; viewer: CommentViewer | null; revision: number; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
||||
const feed = useCommentFeed(`/api/comments/${rootId}/replies?view=thread`);
|
||||
const { refresh, busy, error, page, more } = feed;
|
||||
const previousRevision = useRef(revision);
|
||||
@@ -292,14 +292,14 @@ function CommentReplies({ rootId, revision, onChange, focusReplyId }: { focusRep
|
||||
}, [focusReplyId, busy, error, page, more]);
|
||||
return <div id={`replies-${rootId}`} className="flex flex-col gap-3 pt-2">
|
||||
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
|
||||
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
|
||||
<CommentReplyTree focusReplyId={focusReplyId} items={feed.page.items} viewer={feed.page.viewer} moderationDetails={moderationDetails} onChange={async () => { await refresh({ silent: true }); await onChange(); }} />
|
||||
{feed.busy && <CommentSkeleton count={2} />}
|
||||
{feed.page.nextCursor && <Button variant="outline" disabled={feed.busy} onClick={() => void feed.more()}>ดูคำตอบเพิ่มเติม</Button>}
|
||||
{feed.error && <Button variant="outline" disabled={feed.busy} onClick={() => void refresh()}>ลองใหม่</Button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown> }) {
|
||||
function CommentReplyTree({ items, viewer, onChange, focusReplyId, moderationDetails = false }: { focusReplyId?: string; items: CommentItem[]; viewer: CommentViewer | null; onChange: () => void | Promise<unknown>; moderationDetails?: boolean }) {
|
||||
const loaded = new Set(items.map((item) => item.id));
|
||||
const groups = new Map<string | null, CommentItem[]>();
|
||||
for (const item of items) {
|
||||
@@ -314,7 +314,7 @@ function CommentReplyTree({ items, viewer, onChange, focusReplyId }: { focusRepl
|
||||
parent !== null && index < siblings.length - 1 && "after:absolute after:-bottom-3 after:-left-8 after:top-0 after:border-l after:border-border",
|
||||
)}>
|
||||
<div className={cn(groups.has(reply.id) && "relative after:absolute after:-bottom-3 after:left-4 after:top-10 after:border-l after:border-border")}>
|
||||
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} replies={false} onChange={onChange} />
|
||||
<CommentCard item={reply} highlighted={reply.id === focusReplyId} viewer={viewer} moderationDetails={moderationDetails} replies={false} onChange={onChange} />
|
||||
</div>
|
||||
{groups.has(reply.id) && <div className="ml-4 flex min-w-0 flex-col gap-3 pl-8">
|
||||
{branch(reply.id)}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
'use client';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
|
||||
return <Button variant="outline" size="sm" className="self-start" onClick={() => window.dispatchEvent(new Event('notifications:enable'))}><BellIcon data-icon="inline-start" />{admin ? 'แจ้งเตือนความคิดเห็นใหม่' : 'แจ้งเตือนเมื่อมีคนตอบกลับ'}</Button>;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <Popover open={open} onOpenChange={value => { setOpen(value); if (value) void state.refresh(); }}>
|
||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}><BellIcon />
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full px-1">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
{state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
|
||||
|
||||
Reference in New Issue
Block a user